diff --git a/applications/multi_robot/README.md b/applications/multi_robot/README.md new file mode 100644 index 0000000000000000000000000000000000000000..56dd21bec7e708916abf05462aecfbd652c6396e --- /dev/null +++ b/applications/multi_robot/README.md @@ -0,0 +1,32 @@ +# Multi-robot motion planning + +Reproduces the multi-robot results of *Continuous-Time Constrained Motion Planning with Global +Convergence Guarantees*, in two experiments: + +| folder | experiment | paper | +|---|---|---| +| [`robotarium/`](robotarium/README.md) | the Robotarium arena: 5 maps x N in {3,6,9,12}, single-plan success, the hardware runs; GlobMP behind a diffusion planner's projector | Sec. 4.2, App. `sec:steering` | +| [`multi_robot_extension/`](multi_robot_extension/README.md) | the simulated suite: 8 maps x N = 3..21 x 3 velocity caps, the eight suite tables and figures | App. `app:exp-multirobot` | + +Each folder has the same layout and runs on its own: + +``` +/ + *.py GlobMP's side of the experiment (instances, evaluate and figures are shared, identical copies) + run/ the entry scripts + data/ instances and the result files the paper's tables and figures read + *_baselines/ the baselines and the benchmark they run in: config/, data_prep/, train/, run/, eval/, tools/, + checkpoints/ and one folder per original repository (same layout as steering/xhand_baselines/) +``` + +GlobMP itself is the `globmp` package at the repository root; nothing here changes it. + +## Environments + +* GlobMP side: Python >= 3.10 with numpy, scipy, torch (CPU is enough), matplotlib, and the `globmp` package + on the path: `export PYTHONPATH=` (or `pip install -e `). The runs behind the paper + used one thread per job (`OMP_NUM_THREADS=1`, `run_grid.py --threads 1`) on pinned cores. +* Benchmark side (only for the benchmark's predicate, the instance export and the Robotarium sampler): the + co-authors' reproduction of the DiRecT/MMD multi-robot benchmark (`_baselines/` in each folder) with its `direct` conda env. Two quirks: + `PYTHONPATH` must put `multi_robot/` and `multi_robot/deps/torch_robotics` first, and `PYTHONNOUSERSITE` + must not be set. `EnvEmpty2D` has no checkpoint; `EnvEmptyNoWait2D` is used. diff --git a/applications/multi_robot/robotarium/README.md b/applications/multi_robot/robotarium/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d869d623e8126645516836080f3f1dd9a1b630c4 --- /dev/null +++ b/applications/multi_robot/robotarium/README.md @@ -0,0 +1,114 @@ +# Multi-robot: Robotarium arena (Sec. 4.2) + +Single-plan success of GlobMP on the Robotarium arena (5 maps x N in {3,6,9,12}) and the baselines it is compared +with, plus GlobMP behind a diffusion planner's projector interface (App. `sec:steering`). + +| paper item | what | produced by | +|---|---|---| +| Sec. 4.2, Fig. `robotarium_sr_one_compact.pdf` | single-plan success on the Robotarium arena, 5 maps x N in {3,6,9,12} | `run/run_cell.sh` (GlobMP as the sampler's projector), `run/score.py`, `figures.py robotarium` | +| Sec. 4.2, Fig. `robotarium_gallery.pdf` | frames of the real Robotarium runs | not reproducible from this package (see [Hardware gallery](#hardware-gallery)) | +| App. `sec:steering` (Steering a generative planner) | GlobMP behind a diffusion planner's projector interface | `projector.py`, `batch.py` (this is the configuration of the Robotarium runs) | + +## Files + +``` +projector.py GlobMP as DiRecT's projector (BaseProjector.project): steering calls, final certified solve, + robot-by-robot certification with wall, timing and detour repairs +batch.py all candidate teams of one projector call as one batched problem (CPU or GPU) +instances.py map names, obstacle scales; load_robotarium; disc covers of boxes (shared, identical copy) +evaluate.py exact continuous-time robot-robot check and obstacle checks (shared, identical copy) +figures.py the multi-robot figures from result files (`figures.py robotarium`) (shared, identical copy) +run/ + direct_hook.py the three-line wiring of projector.py into the benchmark's DiRecT fork + run_cell.sh one Robotarium cell: diffusion sampler + GlobMP projector, the paper's configuration + score.py select (first certified candidate per instance), bench (the comparison's predicate), + exact (offline re-check without the benchmark) +data/robotarium/ instances.npz (10 per cell), maps.json, geometry.json (boxes in metres) +data/results/ robotarium_cells.json (baselines), robotarium_gpa_sr_one.json, robotarium_gpa_sr_one_detail.json +robotarium_baselines/ the baselines (DiRecT, CD, Final Proj., PCD-SHD, PCD-LB, MMD-CBS) on this arena: the forks, data + preparation, training, runners, scorer, models, the N = 12 upload scripts (robotarium_baselines/README.md) +``` + +`instances.py`, `evaluate.py` and `figures.py` are shared by both experiments; each folder keeps an identical copy, +so either folder runs on its own. + +## Environments + +* GlobMP side: Python >= 3.10 with numpy, scipy, torch (CPU is enough), matplotlib, and the `globmp` package + on the path: `export PYTHONPATH=` (or `pip install -e `). The runs behind the paper + used one thread per job on pinned cores. +* Benchmark side (only for the benchmark's predicate, the instance export and the Robotarium sampler): the + co-authors' reproduction of the DiRecT/MMD multi-robot benchmark (in `robotarium_baselines/`) with its `direct` conda env. Two quirks: + `PYTHONPATH` must put `multi_robot/` and `multi_robot/deps/torch_robotics` first, and `PYTHONNOUSERSITE` + must not be set. `EnvEmpty2D` has no checkpoint; `EnvEmptyNoWait2D` is used. + +The baselines' code is in `robotarium_baselines/` (`robotarium_baselines/README.md`). Their records +(`data/results/robotarium_cells.json`, a copy of `robotarium_baselines/eval/robotarium_cells.json`) were written by its +Robotarium scorer (`robotarium_baselines/eval/original/`; `robotarium_baselines/eval/score_cells.py` is the scorer for +new runs and reproduces them). The MMD-CBS cells are from the 2026-10-15 re-run with the isotropic normalizer +(`robotarium_baselines/eval/mmd_cbs_rerun.md`). + +The trained single-robot models of the paper's setting (v2) are in `robotarium_baselines/checkpoints/-RobotPlanarDisk/` +(`args.yaml` and the final EMA weights `checkpoints/ema_model_current_state_dict.pth`), byte-identical to the models +GlobMP's runs loaded. Conveyor's is the wide-channel model retrained for `RBT_WIDE=1`; the other four are shared with +the earlier v1 setting. Point the baselines' `MODELS_ROOT` / `--models-root`, and the `data_trained_models/` of the +fork behind `DIRECT_MR`, at this folder. The planners rebuild their normalizer from the training demonstrations at +start-up; those (about 4 GB) are not included and are regenerated by `robotarium_baselines/data_prep/generate_data.sh`. + +GlobMP is scored with the same predicates: robot pairs at least 2r apart at the 64 waypoints and the +benchmark's `task.get_trajs_collision_and_free` at its default interpolation (k = 0). + +## Robotarium (Sec. 4.2) + +The Robotarium numbers come from GlobMP used as the projector of the comparison's own diffusion sampler +(DiRecT with the single-robot models trained for the arena, r = 0.13 m, isotropic normalizer), on the +comparison's 10 instances per cell. At each of the 25 projection calls of an instance the sampler's batch of +64 candidate teams is the reference of a tracking cost; intermediate calls steer (short restoration), the final +call solves every candidate team, and the returned plan is the first candidate GlobMP certifies (its +piecewise-linear motion is exact-checked against walls, arena, speed and 2r). If no candidate certifies with +seed 18, fresh diffusion batches are drawn (seeds 19, 20, ...) and the first certified plan is returned. + +```bash +export DIRECT_MR=$PWD/robotarium_baselines/DiRecT # the baselines' DiRecT fork, with run/direct_hook.py wired in +export DIRECT_PY=/envs/direct/bin/python OUT=$PWD/rbt_out +run/run_cell.sh crossroom 12 0 # map N gpu: 10 instances x 64 candidates +SEED=19 EXP_TAG=_s19i5 NINITS=1 INST_NPZ= run/run_cell.sh crossroom 12 0 # a retry +python run/score.py select --results $DIRECT_MR/results --certs $OUT --out ret +(cd $DIRECT_MR && $DIRECT_PY /run/score.py bench crossroom 12 ret/returned_crossroom_N12.npz) +python run/score.py exact --returned ret --sr-json data/results/robotarium_gpa_sr_one.json +python figures.py robotarium --cells data/results/robotarium_cells.json --gpa data/results/robotarium_gpa_sr_one.json --out robotarium_sr_one_compact.pdf +``` + +The cells were run on one A100 each with 40 CPU workers for the robot-by-robot certification; Conveyor N = 9 +and N = 12 were split into five jobs of two instances (`score.py` SPLITS), and the retried instances are listed +in `score.py` RETRIES (Conveyor N = 12: instances 1, 3, 4, 5, 8, 9; Cross-Room N = 12: instance 5). Conveyor +N = 12 instances 3 and 4 never certified (seeds 18 to 24), which is the 0.8 in the figure. Wall time per cell +ranged from minutes (Empty, small N) to several hours (Conveyor N = 12). The comparison's predicate does not +require the goal to be reached exactly: the sampler pins only the start, and the returned plans end on average +4 to 5 cm (at most 22 cm) from their goals; the same holds for every method in the comparison. + +### Hardware gallery + +`robotarium_gallery.pdf` shows frames of the overhead videos of the real runs (12-robot ring instance, +Cross-Room and Conveyor) with tracked paths; it is drawn by the co-authors' `fig_robotarium_gallery.py` from the +videos and tracks, which are not part of this package. The GlobMP plans executed there ("best picks") were +produced on 2026-09-24 by the projector above, on the antipodal ring instances of an earlier benchmark +version, with a selection loop over steering settings (keep-right, circulation `GPA_CIRC_MODE=reflect`, +`GPA_SHIFT_SMOOTH`, intermediate budget) that kept the first certified plan passing a review (channel use, path +length at most 1.3 times the straight lines, a clean replay in the Robotarium simulator). The submission +scripts were generated by the co-authors' uploader. `projector.py` supports those settings; the loop and the +uploader are not included. + +## Validation of this port (2026-10-12) + +Robotarium: `projector.py` + `batch.py` against the original projector, on identical inputs (Cross-Room N = 3 +and N = 6, a steering call and a final call, including the robot-by-robot certification): outputs agree to +3e-15 in iteration-limited runs (each code differs from its own repeat by 1e-14) and to 6e-4 (normalized units) +in full runs, with identical certified teams. The final-call certificate screen of `projector.py` on the saved +64 x 10 candidates of Empty N = 3, Cross-Room N = 6 and Drop-Region N = 12 reproduces the original certificate +logs on 30 of 30 instances (625, 267 and 314 certified candidates; same first certified candidate). +`score.py exact` on the 20 saved returned-plan files gives the published single-plan success on all 20 cells +(Conveyor N = 12: 0.8, instances 3 and 4). An end-to-end rerun of the diffusion sampler was not done here (it +needs the benchmark's Robotarium fork and a GPU). + +Figures from the files in `data/results/`: `figures.py robotarium` matches the paper's PDF. diff --git a/applications/multi_robot/robotarium/batch.py b/applications/multi_robot/robotarium/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..8e40d7aeac7ea49aa10dbcc7fdb8c87e3f9e2f97 --- /dev/null +++ b/applications/multi_robot/robotarium/batch.py @@ -0,0 +1,231 @@ +"""All candidate teams of one projector call solved as ONE batched problem with a leading team axis G. + +Same algorithm as globmp.Problem in 'orthant' mode: Algorithm 1 (restoration, Barzilai-Borwein steps with a +nonmonotone backtracking reference) and a few blocks of Algorithm 2 (gradient projection), with per-team +masks instead of a Python loop over teams. The chart (N, pseudoinverse), the cost Hessian and the constraint +layout are shared by all teams; endpoints, tracking reference and step sizes are per team. Runs on CPU or GPU. + +Constraint layout (the same for every team): pairwise separation 2r, a per-robot speed bound, the workspace +box (hard) and, per robot, every obstacle disc within `reach` of that robot's reference in ANY team. + +Used by projector.py (the GlobMP projector behind the diffusion sampler of the Robotarium runs).""" +import math +import time + +import numpy as np +import torch + +from globmp import Problem, spline as bern +from globmp import problem as _problem + +DT = torch.float64 + + +class TeamBatch: + def __init__(self, W, n_agents, d, M, delta, robot_r, obstacles, ws_bounds, dx_max, De=30, reach=0.25, + device='cuda'): + """W: (G, L+1, n) references (sampler frame). obstacles: list of (center (2,), radius).""" + self.dev = torch.device(device) + G, L1, n = W.shape; L = L1 - 1 + self.G, self.n, self.na, self.d, self.M, self.dl = G, n, n // 2, d, M, float(delta) + tk = dict(dtype=DT, device=self.dev) + # shared chart from one CPU problem (cached per (d, ell, ell_j, M) inside globmp) + q = Problem(n, d, 4, 0, M, W[0, 0], W[0, -1], ell_j=2, delta=delta, mode='orthant', De=De) + Api, N = _problem._CHART_CACHE[('chart', d, 0, 2, M)] + self.N = N.to(**tk); self.P = M * (d + 1) + self.NpT = torch.linalg.pinv(N).T.to(**tk) # for the initial fit + self.u = bern.ones_u(d).to(**tk); self.uu = torch.outer(self.u, self.u) + self.D1 = q.D1.to(**tk); self.E1 = q.E1.to(**tk); self.Eo = q.Eo.to(**tk) + C_, idx_ = bern._S_coeffs(d); self.SC = C_.to(**tk); self.Sidx = idx_.to(self.dev) + C1_, idx1_ = bern._S_coeffs(d - 1); self.SC1 = C1_.to(**tk); self.Sidx1 = idx1_.to(self.dev) + # per-team endpoints -> Gamma0 = start (x) Api[:,0] + goal (x) Api[:,1] + Wt = torch.as_tensor(W, **tk) + A0 = Api[0].to(**tk); A1 = Api[1].to(**tk) + self.Gamma0 = Wt[:, 0, :, None] * A0 + Wt[:, -1, :, None] * A1 # (G, n, P) + # tracking cost against the piecewise-linear reference (linear in W; exact Gauss-Legendre) + Tm, Qm = self._tracking_maps(L, M, d) + Gk = torch.block_diag(*[bern.cost_gram(d, 0)] * M).to(**tk) + self.H = self.N.T @ Gk @ self.N; self.H = 0.5 * (self.H + self.H.T) + Rd = torch.einsum('lc,gln->gnc', Tm.to(**tk), Wt) # (G, n, P) + cref = torch.einsum('lk,gln,gkn->g', Qm.to(**tk), Wt, Wt) + A = self.Gamma0 + self.G0 = torch.einsum('gnp,pq,qf->gnf', A, Gk, self.N) - torch.einsum('gnp,pf->gnf', Rd * M, self.N) + self.c0 = torch.einsum('pq,gip,giq->g', Gk, A, A) - 2.0 * (Rd * M * A).sum((1, 2)) + cref * M + ev = torch.linalg.eigvalsh(self.H); self.beta = 2 * float(ev.max()) + # constraint layout + na = self.na; r2 = 2.0 * robot_r + pairs = [(a, b) for a in range(na) for b in range(a + 1, na)] + self.sepI = torch.tensor([[2 * a, 2 * a + 1] for a, _ in pairs], device=self.dev) + self.sepJ = torch.tensor([[2 * b, 2 * b + 1] for _, b in pairs], device=self.dev) + self.sepR = r2 + self.spdI = torch.tensor([[2 * a, 2 * a + 1] for a in range(na)], device=self.dev) + self.vmax = float(dx_max) * L / M + rows, cen, rad = [], [], [] + if obstacles: + Cc = np.array([c for c, _ in obstacles], float); Rc = np.array([r for _, r in obstacles], float) + for a in range(na): + Pa = W[:, :, 2 * a:2 * a + 2].reshape(-1, 2) + dmin = np.linalg.norm(Pa[:, None] - Cc[None], axis=-1).min(0) + for k in np.where(dmin <= Rc + robot_r + delta + reach)[0]: + rows.append([2 * a, 2 * a + 1]); cen.append(Cc[k]); rad.append(Rc[k] + robot_r) + self.nsph = len(rows) + if rows: + self.sphI = torch.tensor(rows, device=self.dev); self.sphC = torch.as_tensor(np.array(cen), **tk) + self.sphR = torch.as_tensor(np.array(rad), **tk) + lo, hi = np.asarray(ws_bounds[0], float), np.asarray(ws_bounds[1], float) + self.lo = torch.as_tensor(np.tile(lo + robot_r - delta + 1e-6, na), **tk) + self.hi = torch.as_tensor(np.tile(hi - robot_r + delta - 1e-6, na), **tk) + # tolerances per block, in pi_all order + dl = self.dl; tv = [] + if self.nsph: tv.append(((2 * self.sphR * dl + dl * dl) ** 2)[:, None].expand(-1, M).reshape(-1)) + tv.append(torch.full((len(pairs) * M,), (2 * r2 * dl + dl * dl) ** 2, **tk)) + tv.append(torch.full((na * M,), 1e-12, **tk)) # speed + tv.append(torch.full((2 * M * n,), 1e-12, **tk)) # box: hard + self.tv = torch.cat(tv) + # initial chart coordinates: least squares to the M+1 waypoints, linear inside each segment + wp = Wt[:, np.round(np.linspace(0, L, M + 1)).astype(int), :] # (G, M+1, n) + ss = torch.linspace(0, 1, d + 1, **tk) + tgt = wp[:, :-1, :, None] * (1 - ss) + wp[:, 1:, :, None] * ss # (G, M, n, d+1) + Gm = tgt.permute(0, 2, 1, 3).reshape(G, n, -1) + self.Z0 = (Gm - self.Gamma0) @ self.NpT # (G, n, d_f) + + @staticmethod + def _tracking_maps(L, M, d): + """Rd(W) = sum_l Tm[l,c] W[l,:] and cref(W) = W^T Qm W, both exact (piecewise-linear reference).""" + nq = int(math.ceil((d + 2) / 2)); xg, wg = np.polynomial.legendre.leggauss(nq) + xg = 0.5 * (xg + 1.0); wg = 0.5 * wg + Tm = np.zeros((L + 1, M * (d + 1))); Qm = np.zeros((L + 1, L + 1)) + tb = np.linspace(0, 1, L + 1) + for a in range(M): + t0, t1 = a / M, (a + 1) / M + cuts = np.unique(np.r_[t0, t1, tb[(tb > t0) & (tb < t1)]]) + for c0, c1 in zip(cuts[:-1], cuts[1:]): + h = c1 - c0; tq = c0 + h * xg; uq = (tq - t0) / (t1 - t0) + bu = bern.bernstein_basis_eval(d, torch.as_tensor(uq, dtype=DT)).numpy() # (d+1, nq) + i = np.clip(np.floor(tq * L).astype(int), 0, L - 1); f = tq * L - i + for qq in range(nq): + w_ = wg[qq] * h + for (li, cf) in ((i[qq], 1 - f[qq]), (i[qq] + 1, f[qq])): + Tm[li, a * (d + 1):(a + 1) * (d + 1)] += w_ * cf * bu[:, qq] + Qm[i[qq], i[qq]] += w_ * (1 - f[qq]) ** 2; Qm[i[qq] + 1, i[qq] + 1] += w_ * f[qq] ** 2 + Qm[i[qq], i[qq] + 1] += w_ * (1 - f[qq]) * f[qq]; Qm[i[qq] + 1, i[qq]] += w_ * (1 - f[qq]) * f[qq] + return torch.as_tensor(Tm, dtype=DT), torch.as_tensor(Qm, dtype=DT) + + # ---- blocks + def _S(self, Q, d1=False): + C, idx = (self.SC1, self.Sidx1) if d1 else (self.SC, self.Sidx) + W = (Q * C).reshape(*Q.shape[:-2], -1); dd = (self.d - 1) if d1 else self.d + out = torch.zeros(*Q.shape[:-2], 2 * dd + 1, dtype=DT, device=self.dev) + out.index_add_(-1, idx, W) + return out + + def _elev(self, c): + Mx = 0.5 * (c[..., :, None] * self.u + self.u[:, None] * c[..., None, :]) + return self._S(Mx) + + def segs(self, Z): + Gm = self.Gamma0 + Z @ self.N.T # (G, n, P) + return Gm.view(self.G, self.n, self.M, self.d + 1).permute(0, 2, 1, 3) # (G, M, n, d+1) + + def pi_all(self, Z): + S = self.segs(Z); dl = self.dl; out = [] + if self.nsph: + Gc = S[:, :, self.sphI, :] - self.sphC[None, None, :, :, None] * self.u # (G,M,K,2,d+1) + Mm = torch.einsum('gmkip,gmkiq->gmkpq', Gc, Gc) - ((self.sphR + dl) ** 2)[None, None, :, None, None] * self.uu + out.append(self._S(Mm).permute(0, 2, 1, 3).reshape(self.G, -1, 2 * self.d + 1)) + Gc = S[:, :, self.sepI, :] - S[:, :, self.sepJ, :] # (G,M,Pp,2,d+1) + Mm = torch.einsum('gmkip,gmkiq->gmkpq', Gc, Gc) - (self.sepR + dl) ** 2 * self.uu + out.append(self._S(Mm).permute(0, 2, 1, 3).reshape(self.G, -1, 2 * self.d + 1)) + Gd = S[:, :, self.spdI, :] @ self.D1 # (G,M,na,2,d) + Mm = torch.einsum('gmkip,gmkiq->gmkpq', Gd, Gd) + pe = self._S(Mm, d1=True) @ self.E1.T # (G,M,na,2d+1) + out.append((self.vmax ** 2 - pe).permute(0, 2, 1, 3).reshape(self.G, -1, 2 * self.d + 1)) + lo = self.lo + dl; hi = self.hi - dl + pl = self._elev(S - lo[None, None, :, None] * self.u).reshape(self.G, -1, 2 * self.d + 1) + ph = self._elev(hi[None, None, :, None] * self.u - S).reshape(self.G, -1, 2 * self.d + 1) + out += [pl, ph] + return torch.cat(out, 1) # (G, B, 2d+1) + + def per_block(self, Z): + pe = self.pi_all(Z) @ self.Eo.T + return (torch.clamp(-pe, min=0) ** 2).sum(-1) # (G, B) + + def cost(self, Z): + return self.c0 + 2 * (Z * self.G0).sum((1, 2)) + torch.einsum('pq,gip,giq->g', self.H, Z, Z) + + # ---- Algorithm 1, batched + def project(self, Z, cap=3000, deadline=None, active=None): + G = self.G; tv = self.tv + eta = torch.full((G,), 1e-3, dtype=DT, device=self.dev) + Zp = None; gp = None; hist = [] + live = torch.ones(G, dtype=torch.bool, device=self.dev) if active is None else active.clone() + ok = torch.zeros(G, dtype=torch.bool, device=self.dev) + for it in range(cap): + with torch.no_grad(): + d2 = self.per_block(Z); act = d2 > tv + done = ~act.any(1); ok |= done & live; live &= ~done + if not bool(live.any()) or (deadline and time.time() > deadline): + break + Zg = Z.detach().requires_grad_(True) + with torch.enable_grad(): + per = self.per_block(Zg); f = (act.to(DT) * per).sum(1) + (g,) = torch.autograd.grad(f.sum(), [Zg]) + f0 = f.detach() + if Zp is not None: + s_ = (Z - Zp).reshape(G, -1); y_ = (g - gp).reshape(G, -1); sy = (s_ * y_).sum(1) + eta = torch.where(sy > 1e-300, (s_ * s_).sum(1) / sy.clamp_min(1e-300), + torch.minimum(eta * 1.3, 2 * f0 / (g * g).sum((1, 2)).clamp_min(1e-300))) + else: + eta = torch.minimum(eta, 2 * f0 / (g * g).sum((1, 2)).clamp_min(1e-300)) + hist.append(f0); fref = torch.stack(hist[-10:]).max(0).values if Zp is not None else f0 * (1 + 1e-12) + 1e-300 + Zt = Z; acc = ~live + for _ in range(40): + cand = (Z - eta[:, None, None] * g).detach() + with torch.no_grad(): + ft = (act.to(DT) * self.per_block(cand)).sum(1) + good = (ft < fref) & ~acc + Zt = torch.where(good[:, None, None], cand, Zt); acc |= good + if bool(acc.all()): break + eta = torch.where(acc, eta, eta * 0.5) + Zp = torch.where(live[:, None, None], Z, Zp if Zp is not None else Z) + gp = torch.where(live[:, None, None], g, gp if gp is not None else g) + Z = torch.where(live[:, None, None], Zt, Z) + with torch.no_grad(): + ok |= ~(self.per_block(Z) > tv).any(1) + return Z.detach(), ok + + # ---- Algorithm 2, batched (a few blocks) + def solve(self, Z, ok, blocks=4, steps=3, deadline=None): + Zp = Z.clone(); tc = torch.zeros(self.G, dtype=DT, device=self.dev) + for _ in range(blocks): + if deadline and time.time() > deadline: break + tc = tc + 1 + y = Z + ((tc - 1) / (tc + 2))[:, None, None] * (Z - Zp) + for _ in range(steps): + y = (y - (1 / self.beta) * 2 * (self.G0 + y @ self.H)).detach() + cand, okc = self.project(y, cap=600, deadline=deadline, active=ok) + better = ok & okc & (self.cost(cand) <= self.cost(Z)) + Zp = torch.where(better[:, None, None], Z, Zp); Z = torch.where(better[:, None, None], cand, Z) + tc = torch.where(better, tc, torch.zeros_like(tc)) + return Z + + def curve(self, Z, L): + tt = torch.linspace(0, 1, L + 1, dtype=DT, device=self.dev) + seg = torch.clamp((tt * self.M).floor().long(), 0, self.M - 1) + bu = bern.bernstein_basis_eval(self.d, (tt * self.M - seg).cpu()).to(device=self.dev, dtype=DT) + S = self.segs(Z) # (G, M, n, d+1) + return torch.einsum('ginp,pi->gin', S[:, seg], bu) # (G, L+1, n) + + +def solve_teams(W, n_agents, robot_r, obstacles, ws_bounds, dx_max, d=5, M=21, delta=0.005, budget=20.0, + cap=3000, alg2_blocks=4, partial=False, device='cuda'): + """W: (G, L+1, n) numpy. Returns ((G, L+1, n) numpy, ok (G,) bool). With partial=True, teams that did not + restore return their partially restored curve (intermediate steering calls).""" + L = W.shape[1] - 1; M = min(M, L) + tb = TeamBatch(W, n_agents, d, M, delta, robot_r, obstacles, ws_bounds, dx_max, device=device) + dl = time.time() + budget + Z, ok = tb.project(tb.Z0, cap=cap, deadline=dl) + if alg2_blocks > 0 and bool(ok.any()): + Z = tb.solve(Z, ok, blocks=alg2_blocks, deadline=dl) + out = tb.curve(Z, L).cpu().numpy() + return out, ok.cpu().numpy() diff --git a/applications/multi_robot/robotarium/evaluate.py b/applications/multi_robot/robotarium/evaluate.py new file mode 100644 index 0000000000000000000000000000000000000000..069436ca2da4cdd48d4e76c26b92d85a8ba8bc2c --- /dev/null +++ b/applications/multi_robot/robotarium/evaluate.py @@ -0,0 +1,208 @@ +"""Scoring of waypoint plans. + +A plan is executed (and scored by the benchmark) as the straight lines between its waypoints. Three checks: + +* robot-robot, exact in continuous time: for two robots moving linearly over one interval the minimum + distance has a closed form (pair_min_dist_exact), so the check is over every t, not a dense sample. +* obstacles with the BENCHMARK'S OWN predicate, `task.get_trajs_collision_and_free(num_interpolation=k)`; + the tables use k = 0 (the 64 waypoints only, the benchmark's default). Needs the benchmark code. +* obstacles and arena, exact for discs and axis-aligned boxes (exact_obstacle_clearance), with no benchmark + code; used to re-check the Robotarium plans offline. + +The paper's success numbers use the benchmark's own definitions (suite_metrics, robotarium/run/score.py); +nothing here re-defines success. + + python evaluate.py suite --results-dir DIR --trained-models DIR # benchmark env: SR CS Coll DA Time + python evaluate.py robotarium [--inst K] # any env: exact offline check +""" +import argparse +import glob +import json +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +# ---- robot-robot, exact +def pair_min_dist_exact(A, B): + """A, B: (..., H, 2) waypoints. Exact minimum distance over the piecewise-linear motion.""" + P = A[..., :-1, :] - B[..., :-1, :] + D = (A[..., 1:, :] - A[..., :-1, :]) - (B[..., 1:, :] - B[..., :-1, :]) + dd = (D * D).sum(-1) + s = np.clip(np.where(dd > 1e-18, -(P * D).sum(-1) / np.maximum(dd, 1e-18), 0.0), 0.0, 1.0) + Q = P + s[..., None] * D + return np.sqrt((Q * Q).sum(-1)).min(-1) + + +def eval_trajs(pos, radius): + """pos: (S, I, A, H, 2). Per-configuration robot-robot success at the waypoints and in continuous time.""" + S, I, A, H, _ = pos.shape + need = 2.0 * radius + wp_ok = np.ones((S, I), bool); ct_ok = np.ones((S, I), bool) + wp_min = np.full((S, I), np.inf); ct_min = np.full((S, I), np.inf) + for a in range(A): + for b in range(a + 1, A): + Pa, Pb = pos[:, :, a], pos[:, :, b] + dw = np.linalg.norm(Pa - Pb, axis=-1).min(-1) + dc = pair_min_dist_exact(Pa, Pb) + wp_min = np.minimum(wp_min, dw); ct_min = np.minimum(ct_min, dc) + wp_ok &= dw >= need; ct_ok &= dc >= need + return dict(wp_success=float(wp_ok.any(0).mean()), ct_success=float(ct_ok.any(0).mean()), + wp_sample_rate=float(wp_ok.mean()), ct_sample_rate=float(ct_ok.mean()), + hidden_collision_rate=float((wp_ok & ~ct_ok).sum() / max(1, wp_ok.sum())), + wp_min=float(wp_min.min()), ct_min=float(ct_min.min()), need=float(need), + ct_ok=ct_ok, n_samples=S, n_inits=I, n_agents=A, horizon=H) + + +# ---- obstacles, exact for discs and boxes +def _seg_box_dist(P0, P1, c, h, iters=100): + """Distance from each segment P0[k]->P1[k] to the box |x - c| <= h (componentwise). The distance to a + convex set along a segment is convex in the segment parameter, so ternary search converges to the exact + minimum ((2/3)^100 of the segment after 100 iterations).""" + def dist(s): + X = P0 + s[:, None] * (P1 - P0) + q = np.maximum(np.abs(X - c) - h, 0.0) + return np.sqrt((q * q).sum(-1)) + lo = np.zeros(len(P0)); hi = np.ones(len(P0)) + for _ in range(iters): + m1 = lo + (hi - lo) / 3; m2 = hi - (hi - lo) / 3 + left = dist(m1) <= dist(m2) + hi = np.where(left, m2, hi); lo = np.where(left, lo, m1) + return np.minimum.reduce([dist(lo), dist(hi), dist(np.zeros(len(P0))), dist(np.ones(len(P0)))]) + + +def exact_obstacle_clearance(path, discs=(), boxes=(), ws=None): + """path: (H, 2) waypoints of one robot. Minimum centre-to-obstacle distance of the piecewise-linear + motion (discs: distance minus radius; boxes: distance to the box), and the minimum distance to the arena + edges. Returns (obstacle clearance, arena clearance).""" + P0, P1 = path[:-1], path[1:] + best = np.inf + for c, r in discs: + D = P1 - P0; w = np.asarray(c, float) - P0 + t = np.clip((w * D).sum(-1) / np.maximum((D * D).sum(-1), 1e-18), 0, 1) + best = min(best, float((np.linalg.norm(w - t[:, None] * D, axis=-1) - r).min())) + for c, h in boxes: + best = min(best, float(_seg_box_dist(P0, P1, np.asarray(c, float), np.asarray(h, float)).min())) + edge = np.inf + if ws is not None: + edge = float(np.minimum(path - ws[0], ws[1] - path).min()) # a segment stays inside iff its ends do + return best, edge + + +def robotarium_exact(plans, mp, N, inst=None, data_dir=None): + """Exact offline check of returned Robotarium plans (I, N, H, 2), metres: robot-robot separation 2r over + every segment, robot body (r = 0.13) clear of every box, inside the arena. Returns per-instance dict.""" + from instances import load_robotarium + G = load_robotarium(mp, N, data_dir) + r = G['robot_r']; boxes = list(zip(G['box_c'], G['box_h'])) + T = np.asarray(plans, float) + out = [] + for i in range(T.shape[0]): + rr = min([float(pair_min_dist_exact(T[i, a], T[i, b])) for a in range(N) for b in range(a + 1, N)] or [np.inf]) + oc, ed = np.inf, np.inf + for a in range(N): + o, e = exact_obstacle_clearance(T[i, a], boxes=boxes, ws=G['ws']) + oc = min(oc, o); ed = min(ed, e) + k = i if inst is None else inst + ge = float(np.linalg.norm(T[i, :, -1] - G['goals'][k], axis=-1).max()) + se = float(np.linalg.norm(T[i, :, 0] - G['starts'][k], axis=-1).max()) + out.append(dict(inst=k, rr_min=rr, obs_clear=oc - r, arena_clear=ed - r, goal_err=ge, start_err=se, + free=bool(rr >= 2 * r and oc >= r and ed >= r))) + return out + + +# ---- the benchmark's own predicate (benchmark environment only) +def build_task(env_model_id, trained_models_dir, device='cpu', extra_objects=False): + import torch + from mmd.utils.loading import load_params_from_yaml + from mmd.trainer import get_dataset + args = load_params_from_yaml(os.path.join(trained_models_dir, env_model_id, 'args.yaml')) + trainset, _, _, _ = get_dataset(dataset_class='TrajectoryDataset', use_extra_objects=extra_objects, + obstacle_cutoff_margin=0.05, **args, + tensor_args=dict(device=device, dtype=torch.float32)) + return trainset.dataset.task + + +def obstacle_rates(task, pos, k, device='cpu'): + """pos: (S, I, A, H, 2) -> per-(sample, configuration) mask of obstacle-free plans at interpolation k.""" + import torch + S, I, A, H, _ = pos.shape + T = torch.from_numpy(np.ascontiguousarray(pos.reshape(S * I * A, H, 2))).to(device=device, dtype=torch.float32) + _, _, _, _, wpc = task.get_trajs_collision_and_free(T, return_indices=True, num_interpolation=int(k)) + m = wpc.cpu().numpy().reshape(S, I, A, -1) + return ~m.any(-1).any(-1) + + +def suite_metrics(mp, results_dir, trained_models): + """The five table metrics of every saved cell of one map, with the benchmark's definitions: SR (some + candidate collision-free; here the one plan), CS (fraction of candidates), Coll (mean colliding robot pairs + at the waypoints), DA (the environment's data adherence), Time (median wall seconds per configuration). + Collision-free = robot pairs >= 2r at every waypoint and the benchmark's obstacle predicate at k = 0.""" + import torch + from instances import OBS_SCALE, ENV_CLASS, suite_robot_radius + os.environ['OBS_SCALE'] = str(OBS_SCALE[mp]) + TA = dict(device='cpu', dtype=torch.float32) + task = build_task(ENV_CLASS[mp] + '-RobotPlanarDisk', trained_models) + import torch_robotics.environments as _env_module + envobj = getattr(_env_module, ENV_CLASS[mp])(tensor_args=TA) + rob = suite_robot_radius(mp) + rows = [] + for f in sorted(glob.glob(os.path.join(results_dir, f'{mp}__N*__*.npz'))): + D = np.load(f, allow_pickle=True); P = np.asarray(D['paths'], float) + B, I, A, H, _ = P.shape; pos = P[..., :2] + if A > 1: + dd = np.linalg.norm(pos[:, :, :, None, :, :] - pos[:, :, None, :, :, :], axis=-1) + iu = np.triu_indices(A, 1) + pairhit = (dd[:, :, iu[0], iu[1], :] < 2 * rob).any(-1) + coll = pairhit.sum(-1).astype(float); pair_ok = ~pairhit.any(-1) + else: + coll = np.zeros((B, I)); pair_ok = np.ones((B, I), bool) + feas = pair_ok & obstacle_rates(task, pos, 0) + da = envobj.compute_traj_data_adherence_batch( + torch.from_numpy(pos.reshape(-1, H, 2)).to(**TA)).cpu().numpy().reshape(B, I, A) + n = os.path.basename(f)[:-4].split('__') + r = dict(map=mp, N=A, regime=n[2], B=int(B), I=int(I), + SR=100 * float(feas.any(0).mean()), CS=100 * float(feas.mean()), CS_sd=100 * float(feas.mean(0).std()), + Coll=float(coll.mean()), Coll_sd=float(coll.std()), DA=float(da.mean()), DA_sd=float(da.std()), + Time=float(np.median(D['secs'])), Time_sd=float(np.std(D['secs']))) + rows.append(r); print(json.dumps(r), flush=True) + return rows + + +def cells_json(metrics_jsonl, results_dir, out_json): + """figures/gpa_cells.json records (fractions, median and std of wall time) from suite_metrics output.""" + recs = [] + for l in open(metrics_jsonl): + if not l.startswith('{'): continue + r = json.loads(l) + z = np.load(os.path.join(results_dir, f"{r['map']}__N{r['N']}__{r['regime']}.npz")); sec = np.asarray(z['secs'], float) + recs.append(dict(method='gpa', map=r['map'], N=r['N'], regime=r['regime'], src=os.path.basename(os.path.normpath(results_dir)), + SR=r['SR'] / 100.0, CS=r['CS'] / 100.0, CS_sd=r['CS_sd'] / 100.0, Coll=r['Coll'], Coll_sd=r['Coll_sd'], + DA=r['DA'], DA_sd=r['DA_sd'], T=float(np.median(sec)), T_sd=float(sec.std()))) + json.dump(recs, open(out_json, 'w'), indent=0) + print(f'wrote {len(recs)} records to {out_json}') + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + s = sub.add_parser('suite'); s.add_argument('map'); s.add_argument('--results-dir', required=True) + s.add_argument('--trained-models', required=True, help="the benchmark's data_trained_models directory") + c = sub.add_parser('cells'); c.add_argument('metrics_jsonl'); c.add_argument('--results-dir', required=True) + c.add_argument('--out', required=True) + r = sub.add_parser('robotarium'); r.add_argument('map'); r.add_argument('N', type=int); r.add_argument('plan') + r.add_argument('--inst', type=int, default=None, help='the plan file holds one instance, benchmark index K') + a = ap.parse_args() + if a.cmd == 'suite': + suite_metrics(a.map, a.results_dir, a.trained_models) + elif a.cmd == 'cells': + cells_json(a.metrics_jsonl, a.results_dir, a.out) + else: + T = np.load(a.plan)['trajs'] + res = robotarium_exact(T, a.map, a.N, a.inst) + for x in res: print(json.dumps(x)) + print(json.dumps(dict(map=a.map, N=a.N, SR_exact=float(np.mean([x['free'] for x in res])), + failed=[x['inst'] for x in res if not x['free']]))) diff --git a/applications/multi_robot/robotarium/figures.py b/applications/multi_robot/robotarium/figures.py new file mode 100644 index 0000000000000000000000000000000000000000..90b8d67e05adf278255a495cbf897067d86198c5 --- /dev/null +++ b/applications/multi_robot/robotarium/figures.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +"""Multi-robot figures of the paper, from result files only (no solving). + + # Sec. 4.2, figures/robotarium_sr_one_compact.pdf: single-plan success on the Robotarium arena + python figures.py robotarium --cells data/results/robotarium_cells.json --gpa data/results/robotarium_gpa_sr_one.json --out robotarium_sr_one_compact.pdf + # appendix, figures/hardsuite_single_plan{,_compact}.pdf and hardsuite_best64.pdf + python figures.py suite single --baselines data/results/baseline_cells.json --gpa data/results/gpa_cells.json --out hardsuite_single_plan.pdf + python figures.py suite compact ... --out hardsuite_single_plan_compact.pdf + python figures.py suite best64 ... --out hardsuite_best64.pdf + # appendix, figures/hardsuite_velocity.pdf, read back from the generated tables + python figures.py velocity --tables hardsuite_tables.tex --out hardsuite_velocity.pdf [--n 9] + +GlobMP returns one trajectory per instance; for a sampling baseline, single-plan success is that of its first +candidate (SR_at['1'] in the baseline records), best-of-64 is the tables' SR. Every figure is written as .pdf +and .png. +""" +import argparse +import glob as _glob +import json +import re + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np +from matplotlib import font_manager as _fm + +# ---- print style of the paper (true ICLR text width, Times, thin axes; GlobMP in blue) +for _f in _glob.glob('/usr/share/fonts/truetype/msttcorefonts/[Tt]imes*.ttf'): + _fm.fontManager.addfont(_f) +TEXTWIDTH = 5.5 +BLUE = '#2a78d6' +INK, INK2, GRID = '#222222', '#5a5a5a', '#e9e9e6' +plt.rcParams.update({ + 'font.family': 'serif', 'font.serif': ['Times New Roman', 'Nimbus Roman', 'STIXGeneral'], 'mathtext.fontset': 'stix', + 'font.size': 8, 'axes.labelsize': 8, 'axes.titlesize': 8, 'xtick.labelsize': 7, 'ytick.labelsize': 7, 'legend.fontsize': 7, + 'axes.linewidth': 0.6, 'axes.edgecolor': INK2, 'axes.labelcolor': INK, 'xtick.color': INK2, 'ytick.color': INK2, + 'xtick.major.width': 0.6, 'ytick.major.width': 0.6, 'xtick.major.size': 2.5, 'ytick.major.size': 2.5, + 'xtick.minor.size': 1.5, 'ytick.minor.size': 1.5, 'xtick.minor.width': 0.4, 'ytick.minor.width': 0.4, + 'axes.spines.top': False, 'axes.spines.right': False, 'axes.axisbelow': True, + 'lines.linewidth': 1.1, 'lines.markersize': 3.8, 'errorbar.capsize': 1.6, + 'legend.frameon': False, 'legend.handlelength': 2.2, 'legend.columnspacing': 1.2, 'legend.handletextpad': 0.5, + 'pdf.fonttype': 42, 'ps.fonttype': 42, 'savefig.dpi': 300, 'savefig.bbox': 'tight', 'savefig.pad_inches': 0.02, +}) +OURS = 'GlobMP (Ours)' +STY = {'pdm': dict(color='#7f7f7f', marker='h', label='PDM'), 'cd': dict(color='#8c564b', marker='P', label='CD'), + 'mmd_cbs': dict(color='#17becf', marker='<', label='MMD-CBS'), 'mdoc': dict(color='#bcbd22', marker='>', label='MDOC'), + 'diffusion_policy': dict(color='#9467bd', marker='x', label='Diff. Policy'), + 'pcd_lb': dict(color='#ff7f0e', marker='v', label='PCD-LB'), 'pcd_shd': dict(color='#2ca02c', marker='s', label='PCD-SHD'), + 'final_projection': dict(color='#e377c2', marker='D', label='Final Proj.'), 'direct': dict(color='#d62728', marker='o', label='DiRecT')} + + +def _save(fig, out): + for ext in ('.pdf', '.png'): + fig.savefig(out.rsplit('.', 1)[0] + ext, dpi=300) + + +def _mfc(st): + return 'white' if st['marker'] not in ('x', 'P') else st['color'] + + +# ---- Sec. 4.2: Robotarium +def robotarium(cells_json, gpa_json, out): + """One row of five map panels: single-plan success against team size.""" + MAPS = ['empty', 'corridor', 'shelf', 'corridorbay', 'crossroom'] + TITLES = {'empty': 'Empty', 'corridor': 'Conveyor', 'shelf': 'Drop-Region', 'corridorbay': 'Corridor-Bay', 'crossroom': 'Cross-Room'} + NS = [3, 6, 9, 12] + ORDER = ['cd', 'mmd_cbs', 'pcd_lb', 'pcd_shd', 'final_projection', 'direct'] + C = {(r['method'], r['map'], r['N']): r.get('SR_one') for r in json.load(open(cells_json))} + G = {(r['map'], r['N']): r.get('SR_one', r.get('SR')) for r in json.load(open(gpa_json))} + order = [m for m in ORDER if any(C.get((m, mp, n)) is not None for mp in MAPS for n in NS)] + FH = 1.35 + fig, axes = plt.subplots(1, len(MAPS), figsize=(TEXTWIDTH, FH), sharex=True, sharey=True) + fig.subplots_adjust(left=0.07, right=0.995, top=1 - 0.42 / FH, bottom=0.35 / FH, wspace=0.12) + H = [] + for k, mp in enumerate(MAPS): + ax = axes[k] + for m in order: + y = np.array([np.nan if C.get((m, mp, n)) is None else C[m, mp, n] for n in NS], float) + if np.all(np.isnan(y)): continue + st = STY[m] + h, = ax.plot(NS, y, color=st['color'], marker=st['marker'], ms=3.1, lw=0.9, mew=0.6, mfc=_mfc(st), label=st['label']) + if k == 0: H.append(h) + yg = np.array([np.nan if G.get((mp, n)) is None else G[mp, n] for n in NS], float) + ax.plot(NS, yg, color='white', lw=3.2, zorder=5.5, solid_capstyle='round') # casing where lines cross + h, = ax.plot(NS, yg, color=BLUE, marker='o', ms=3.8, lw=1.7, label=OURS, zorder=6) + if k == 0: H.insert(0, h) + ax.set_title(TITLES[mp], fontsize=8, pad=2.5, color=INK) + ax.set_ylim(-0.05, 1.05); ax.set_yticks([0, 0.5, 1]); ax.set_xticks(NS) + ax.grid(axis='y', color=GRID, lw=0.5); ax.set_axisbelow(True) + fig.supxlabel('number of robots', fontsize=8, y=0.015) + fig.supylabel('single-plan success', fontsize=7, x=0.012, y=(1 - 0.42 / FH + 0.35 / FH) / 2) + fig.legend(H, [h.get_label() for h in H], loc='upper center', bbox_to_anchor=(0.53, 1.0), ncol=len(H), frameon=False, + fontsize=7, handlelength=1.8, columnspacing=0.9) + _save(fig, out); print('wrote', out) + + +# ---- appendix: simulated suite +SUITE_MAPS = [('empty', 'Empty'), ('basic', 'Basic'), ('dense', 'Dense'), ('corridor', 'Conveyor'), ('room', 'Highways'), + ('shelf', 'Drop-Region'), ('corridorbay', 'Corridor-Bay'), ('crossroom', 'Cross-Room')] +VMAX = {'empty': (0.483, 0.571, 0.675), 'basic': (0.477, 0.567, 0.675), 'dense': (0.565, 0.617, 0.675), 'room': (0.647, 0.783, 0.878), + 'corridor': (1.21, 1.46, 1.76), 'corridorbay': (1.21, 1.46, 1.76), 'shelf': (0.928, 1.13, 1.34), 'crossroom': (0.928, 1.13, 1.34)} +REGS = ['low', 'medium', 'high']; SNS = [3, 6, 9, 12, 15, 18, 21] +ORDER_ALL = ['pdm', 'cd', 'mmd_cbs', 'mdoc', 'diffusion_policy', 'pcd_lb', 'pcd_shd', 'final_projection', 'direct'] + + +def _sr_at(r, b): + if not r or not r.get('SR_at'): return np.nan + s = r['SR_at']; ks = sorted(int(k) for k in s); bb = max([k for k in ks if k <= b] or [ks[0]]); return s[str(bb)] + + +def _metric(r, kind): + if r is None: return np.nan + if kind == 'single': return _sr_at(r, 1) + return r.get('SR', np.nan) if r.get('SR') is not None else np.nan + + +def suite(kind, baselines_json, gpa_json, out): + """single / best64: one row per map, one column per velocity cap (all 168 cells). compact: one panel per + map, single-plan success averaged over the three caps.""" + C = {(r['method'], r['map'], r['N'], r['regime']): r for r in json.load(open(baselines_json))} + G = {(r['map'], r['N'], r['regime']): r for r in json.load(open(gpa_json))} + mk = 'single' if kind in ('single', 'compact') else 'best64' + order = [m for m in ORDER_ALL if any(not np.isnan(_metric(C.get((m, mp, n, rg)), mk)) for mp, _ in SUITE_MAPS for n in SNS for rg in REGS)] + if kind == 'compact': + fig, axes = plt.subplots(2, 4, figsize=(TEXTWIDTH, 2.95), sharex=True, sharey=True) + fig.subplots_adjust(left=0.07, right=0.995, top=0.86, bottom=0.115, wspace=0.12, hspace=0.34) + H = [] + for k, (mp, title) in enumerate(SUITE_MAPS): + ax = axes.ravel()[k] + for m in order: + y = np.nanmean([[_metric(C.get((m, mp, n, rg)), 'single') for n in SNS] for rg in REGS], axis=0) + if np.all(np.isnan(y)): continue + st = STY[m]; h, = ax.plot(SNS, y, color=st['color'], marker=st['marker'], ms=3.1, lw=0.9, mew=0.6, mfc=_mfc(st), label=st['label']) + if k == 0: H.append(h) + yg = np.nanmean([[(G.get((mp, n, rg)) or {}).get('SR', np.nan) for n in SNS] for rg in REGS], axis=0) + ax.plot(SNS, yg, color='white', lw=3.2, zorder=5.5, solid_capstyle='round') + h, = ax.plot(SNS, yg, color=BLUE, marker='o', ms=3.8, lw=1.7, label=OURS, zorder=6) + if k == 0: H.append(h) + ax.set_title(title, fontsize=8, pad=2.5, color=INK) + ax.set_ylim(-0.05, 1.05); ax.set_yticks([0, 0.5, 1]); ax.set_xticks([3, 9, 15, 21]) + ax.grid(axis='y', color=GRID, lw=0.5); ax.set_axisbelow(True) + fig.supxlabel('number of agents', fontsize=8, y=0.015); fig.supylabel('single-plan success', fontsize=8, x=0.012) + H = [H[-1]] + H[:-1] + fig.legend(H, [h.get_label() for h in H], loc='upper center', bbox_to_anchor=(0.53, 1.0), ncol=len(H), frameon=False, + fontsize=7, handlelength=1.8, columnspacing=0.9) + _save(fig, out); print('wrote', out); return + fig, axes = plt.subplots(len(SUITE_MAPS), 3, figsize=(TEXTWIDTH, 8.3), sharex=True, sharey=True) + for ri, (mp, title) in enumerate(SUITE_MAPS): + for ci, rg in enumerate(REGS): + ax = axes[ri, ci] + for m in order: + y = [_metric(C.get((m, mp, n, rg)), mk) for n in SNS] + if all(np.isnan(y)): continue + st = STY[m]; ax.plot(SNS, y, color=st['color'], marker=st['marker'], ms=2.4, lw=0.8, mew=0.5, mfc=_mfc(st), label=st['label']) + yg = [(G.get((mp, n, rg)) or {}).get('SR', np.nan) for n in SNS] + ax.plot(SNS, yg, color=BLUE, marker='o', ms=2.8, lw=1.5, label=OURS, zorder=6) + ax.set_xticks([3, 9, 15, 21]); ax.set_ylim(-0.05, 1.05); ax.set_yticks([0, 0.5, 1]); ax.grid(axis='y', color=GRID, lw=0.5); ax.set_axisbelow(True) + ax.set_title(f"{title}, $v_{{\\max}}={VMAX[mp][ci]:g}$ ({rg})", fontsize=7, pad=2, color=INK) + fig.supxlabel('number of agents', fontsize=8, y=0.005) + fig.supylabel({'single': 'single-plan success', 'best64': 'success, best of 64'}[kind], fontsize=8, x=0.005) + fig.subplots_adjust(left=0.07, right=0.995, top=0.945, bottom=0.045, wspace=0.08, hspace=0.42) + h, l = axes[0, 1].get_legend_handles_labels(); h = [h[-1]] + h[:-1]; l = [l[-1]] + l[:-1] + fig.legend(h, l, loc='upper center', ncol=min(len(l), 6), frameon=False, fontsize=7, bbox_to_anchor=(0.5, 1.0), handlelength=1.8, columnspacing=0.9) + _save(fig, out); print('wrote', out) + + +# ---- appendix: velocity axis, read back from the generated tables +def parse_tables(path): + """Rows of hardsuite_tables.tex: N & Method & (SR CS Coll DA Time) x 3 caps; bolding and deviations stripped, + SR and CS back to fractions.""" + s = open(path).read() + out = [] + for t in re.findall(r'\\begin\{table\}.*?\\end\{table\}', s, re.S): + lab = re.search(r'\\label\{tab:hard-([a-z]+)\}', t).group(1) + caps = re.findall(r'\\multicolumn\{5\}\{c\}\{(Low|Medium|High) \(\$v_\{\\max\}=([0-9.]+)\$\)\}', t) + regimes = [(r.lower(), float(v)) for r, v in caps] + cur_n = None + for line in t.split(r'\midrule', 1)[1].split('\\\\'): + line = line.replace(r'\midrule', '').replace(r'\bottomrule', '').replace(r'\end{tabular}', '').strip() + if not line or '&' not in line: continue + cells = [c.strip() for c in line.split('&')] + if len(cells) != 17: continue + if cells[0]: cur_n = int(cells[0]) + meth = re.sub(r'\\textbf\{(.*?)\}', r'\1', cells[1]).strip() + meth = re.sub(r'\$\^\{\\dagger\}\$', '', meth).replace('\\ ', ' ').strip() + + def num(x, pct=False): + x = re.sub(r'\$_\{\\pm[^}]*\}\$', '', x); x = re.sub(r'\\textbf\{(.*?)\}', r'\1', x).strip() + try: v = float(x) + except ValueError: return None + return v / 100.0 if pct else v + vals = cells[2:] + for ri, (rname, vcap) in enumerate(regimes): + sr, cs, coll, da, tm = vals[ri * 5:(ri + 1) * 5] + out.append(dict(map=lab, N=cur_n, method=meth, regime=rname, vcap=vcap, SR=num(sr, True), CS=num(cs, True), + Coll=num(coll), DA=num(da), T=num(tm))) + return out + + +VSTYLE = {'CD': dict(color='#8c564b', marker='P', ls='-'), 'PCD-LB': dict(color='#ff7f0e', marker='v', ls=':'), + 'PCD-SHD': dict(color='#2ca02c', marker='s', ls='--'), 'Final Proj.': dict(color='#e377c2', marker='D', ls='-.'), + 'DiRecT': dict(color='#d62728', marker='o', ls='-'), 'MDOC': dict(color='#9467bd', marker='h', ls=(0, (4, 1.5))), + OURS: dict(color=BLUE, marker='o', ls='-')} + + +def velocity(tables, out, n=9): + """Success and constraint safety against the velocity cap at N = n, two panels per map. Methods with no + velocity mechanism (MMD-CBS, Diffusion Policy) are omitted.""" + rows = parse_tables(tables) + NO_AXIS = {'MMD-CBS', 'Diff. Policy', 'Diffusion Policy'} + ORDER = [OURS, 'CD', 'PCD-LB', 'PCD-SHD', 'MDOC', 'Final Proj.', 'DiRecT'] + METRICS = [('SR', 'success'), ('CS', 'safety')] + series = {} + for r in rows: + if r['N'] != n or r['method'] in NO_AXIS: continue + series.setdefault((r['map'], r['method']), []).append((r['vcap'], r['SR'], r['CS'])) + for k in series: series[k].sort(key=lambda t: t[0]) + PER_ROW = 2; ncol = PER_ROW * len(METRICS); nrow = (len(SUITE_MAPS) + PER_ROW - 1) // PER_ROW + fig, axes = plt.subplots(nrow, ncol, figsize=(TEXTWIDTH, 1.42 * nrow + 0.45), squeeze=False, sharey=True) + plt.subplots_adjust(left=0.06, right=0.995, top=0.945, bottom=0.07, hspace=0.62, wspace=0.12) + drawn = [] + for mi, (key, disp) in enumerate(SUITE_MAPS): + ri, slot = divmod(mi, PER_ROW) + for mj, (metric, mlabel) in enumerate(METRICS): + ax = axes[ri][slot * len(METRICS) + mj] + for meth in ORDER: + pts = series.get((key, meth)) + if not pts: continue + keep = [(p[0], p[1 + mj]) for p in pts if p[1 + mj] is not None] + if len(keep) < 2: continue + st = VSTYLE.get(meth, {}); ours = meth == OURS + ax.plot([k[0] for k in keep], [k[1] for k in keep], color=st.get('color'), marker=st.get('marker', 'o'), + ls=st.get('ls', '-'), lw=1.6 if ours else 0.8, ms=2.8 if ours else 2.2, mew=0.5, + mfc=st.get('color') if ours else 'white', zorder=6 if ours else 4, label=meth) + if meth not in drawn: drawn.append(meth) + ax.set_ylim(-0.04, 1.04); ax.set_yticks([0, 0.5, 1.0]) + ax.tick_params(labelsize=6, length=2.2, pad=1.5) + ax.grid(axis='y', color=GRID, lw=0.5); ax.set_axisbelow(True) + ax.set_title('%s, %s' % (disp, mlabel), fontsize=7, pad=2.5, color=INK) + ax.set_xlabel(r'$v_{\max}$', fontsize=6.5, labelpad=1) + handles = [plt.Line2D([], [], color=VSTYLE[m]['color'], marker=VSTYLE[m]['marker'], ls=VSTYLE[m]['ls'], + lw=1.6 if m == OURS else 0.8, ms=2.8, mew=0.5, mfc=VSTYLE[m]['color'] if m == OURS else 'white', label=m) + for m in ORDER if m in drawn] + fig.supylabel('success / safety', fontsize=7, x=0.005) + fig.legend(handles=handles, loc='upper center', ncol=len(handles), fontsize=7, frameon=False, handlelength=1.6, + handletextpad=0.4, columnspacing=1.2, borderaxespad=0.1, bbox_to_anchor=(0.5, 1.0)) + _save(fig, out); print('wrote', out, f'(N={n}, {len(drawn)} methods)') + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + r = sub.add_parser('robotarium'); r.add_argument('--cells', required=True); r.add_argument('--gpa', required=True); r.add_argument('--out', required=True) + s = sub.add_parser('suite'); s.add_argument('kind', choices=['single', 'best64', 'compact']) + s.add_argument('--baselines', required=True); s.add_argument('--gpa', required=True); s.add_argument('--out', required=True) + v = sub.add_parser('velocity'); v.add_argument('--tables', required=True); v.add_argument('--out', required=True); v.add_argument('--n', type=int, default=9) + a = ap.parse_args() + if a.cmd == 'robotarium': robotarium(a.cells, a.gpa, a.out) + elif a.cmd == 'suite': suite(a.kind, a.baselines, a.gpa, a.out) + else: velocity(a.tables, a.out, a.n) diff --git a/applications/multi_robot/robotarium/instances.py b/applications/multi_robot/robotarium/instances.py new file mode 100644 index 0000000000000000000000000000000000000000..de4dbc67a464d23918e75133a18dcd7c416768fb --- /dev/null +++ b/applications/multi_robot/robotarium/instances.py @@ -0,0 +1,185 @@ +"""Start-goal instances and map geometry for the multi-robot experiments. + +Two settings: + +* the simulated suite (appendix, app:exp-multirobot): 8 maps x N in {3,...,21}, 20 shared start-goal + configurations per (map, N), 3 velocity caps per map. One file per (map, N) in data/suite/, written by + `export_suite` from the benchmark's shared-instance file and its own environment classes. +* the Robotarium setting (Sec. 4.2): 5 maps fitted to the 3.2 x 2.0 m arena, N in {3, 6, 9, 12}, 10 instances + per (map, N) (nine sampled, one antipodal ring), robot radius 0.13 m. data/robotarium/instances.npz (keys + 'rbt/N/{starts,goals}', world metres), maps.json and geometry.json (axis-aligned boxes in metres). + +Map keys follow the benchmark's code names; the paper's names are in PAPER_NAME. +""" +import json +import os + +import numpy as np + +DATA = os.environ.get('GLOBMP_MR_DATA', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')) + +PAPER_NAME = {'empty': 'Empty', 'basic': 'Basic', 'dense': 'Dense', 'corridor': 'Conveyor', 'room': 'Highways', + 'shelf': 'Drop-Region', 'corridorbay': 'Corridor-Bay', 'crossroom': 'Cross-Room'} + +# ---- simulated suite +SUITE_MAPS = ['empty', 'basic', 'dense', 'corridor', 'room', 'shelf', 'corridorbay', 'crossroom'] +SUITE_NS = [3, 6, 9, 12, 15, 18, 21] +REGIMES = ['low', 'medium', 'high'] +OBS_SCALE = {'empty': 1.0, 'basic': 2.2, 'dense': 1.0, 'corridor': 1.5, 'room': 1.45, 'shelf': 1.6, + 'corridorbay': 1.15, 'crossroom': 1.1} +ENV_CLASS = {'empty': 'EnvEmptyNoWait2D', 'basic': 'EnvEmptyNoWait2D', 'dense': 'EnvEmptyNoWait2D', + 'corridor': 'EnvConveyor2D', 'room': 'EnvHighways2D', 'shelf': 'EnvDropRegion2D', + 'corridorbay': 'EnvCorridorSMD2D', 'crossroom': 'EnvCrossRoom2D'} +# velocity caps (low, medium, high); must equal VCAP in tables.py +VCAP = {'empty': (0.483, 0.571, 0.675), 'basic': (0.477, 0.567, 0.675), 'dense': (0.565, 0.617, 0.675), + 'room': (0.647, 0.783, 0.878), 'corridor': (1.21, 1.46, 1.76), 'corridorbay': (1.21, 1.46, 1.76), + 'shelf': (0.928, 1.13, 1.34), 'crossroom': (0.928, 1.13, 1.34)} + + +def suite_robot_radius(mp): + return 0.1 if mp == 'empty' else 0.05 + + +def load_suite(mp, N, inst_dir=None): + """dict with centers (K,2), radii (K,), ws (2,2) [lo; hi], box_c, box_h, starts (I,N,2), goals (I,N,2), + horizon (64), robot_r and the three velocity caps vmax_low / vmax_med / vmax_high.""" + f = os.path.join(inst_dir or os.path.join(DATA, 'suite'), f'{mp}_N{N}.npz') + D = np.load(f, allow_pickle=True) + return {k: D[k] for k in D.files} + + +# ---- Robotarium setting +RBT_MAPS = ['empty', 'corridor', 'shelf', 'corridorbay', 'crossroom'] +RBT_NS = [3, 6, 9, 12] +RBT_ROBOT_R = 0.13 +RBT_ARENA = np.array([[-1.6, -1.0], [1.6, 1.0]]) + + +def load_robotarium(mp, N, data_dir=None): + """dict with starts, goals (10, N, 2) in metres, boxes (centers (K,2), half sizes (K,2)), ws, robot_r.""" + d = data_dir or os.path.join(DATA, 'robotarium') + I = np.load(os.path.join(d, 'instances.npz')) + geo = json.load(open(os.path.join(d, 'geometry.json')))['maps'][mp] + BC = np.array([o['center'] for o in geo['obstacles']], float).reshape(-1, 2) + BH = np.array([o['size'] for o in geo['obstacles']], float).reshape(-1, 2) / 2.0 + return dict(starts=I[f'{mp}rbt/N{N}/starts'], goals=I[f'{mp}rbt/N{N}/goals'], box_c=BC, box_h=BH, + ws=RBT_ARENA.copy(), robot_r=RBT_ROBOT_R, obs_scale=geo['obs_scale'], + wide_corridor=bool(geo.get('wide_corridor', False)), env_class=geo['env_class']) + + +# ---- geometry helpers +def boxes_to_circles(centers, half_sizes, halo=0.02, max_circles_per_box=400): + """Cover each axis-aligned box by discs whose union contains it, so avoiding every disc implies avoiding + the box. The union overshoots the box by (sqrt(2)-1) times the cell half-size, so the box is subdivided + until that halo is at most `halo`.""" + out = [] + C = np.asarray(centers, dtype=float).reshape(-1, 2) + Hs = np.asarray(half_sizes, dtype=float).reshape(-1, 2) + for c, h in zip(C, Hs): + hx, hy = float(h[0]), float(h[1]) + if hx <= 1e-9 or hy <= 1e-9: + continue + cell = halo / (np.sqrt(2.0) - 1.0) + nx = max(1, int(np.ceil(hx / cell))) + ny = max(1, int(np.ceil(hy / cell))) + while nx * ny > max_circles_per_box: + nx = max(1, nx - 1); ny = max(1, ny - 1) + rx, ry = hx / nx, hy / ny + r = float(np.hypot(rx, ry)) + xs = (np.arange(nx) + 0.5) * 2 * rx - hx + ys = (np.arange(ny) + 0.5) * 2 * ry - hy + for xq in xs: + for yq in ys: + out.append((c + np.array([xq, yq]), r)) + return out + + +def box_discs(BC, BH, halo, halo_wall=0.003, aspect=3.0): + """Discs whose union contains each box (the Robotarium projector's obstacle model). Square-ish boxes: the + grid cover above with overshoot <= `halo`. Thin walls (aspect >= 3): ONE row of discs of radius about the + wall's half-thickness, spaced so the long faces overshoot by <= `halo_wall` (a 2 cm halo on a wall would + narrow the 0.29 m Conveyor channel below the 0.26 m a robot needs).""" + C, R = [], [] + for c, h in zip(np.asarray(BC, float).reshape(-1, 2), np.asarray(BH, float).reshape(-1, 2)): + L, t = (0, 1) if h[0] >= h[1] else (1, 0) # long axis, thin axis + if h[L] >= aspect * h[t]: + rl = np.sqrt((h[t] + halo_wall) ** 2 - h[t] ** 2) # half-spacing along the wall + nl = int(np.ceil(h[L] / rl)); rl = h[L] / nl + rad = float(np.hypot(rl, h[t])) + for k in range(nl): + p = np.array(c, float); p[L] += -h[L] + (2 * k + 1) * rl + C.append(p); R.append(rad) + else: + for cc, rr in boxes_to_circles(c[None], h[None], halo=halo): + C.append(np.asarray(cc, float)); R.append(float(rr)) + return np.array(C).reshape(-1, 2), np.array(R).reshape(-1) + + +def benchmark_geometry(task, halo): + """Obstacle discs and workspace in world coordinates, read off the benchmark environment of `task`: + sphere fields as they are, box fields covered by boxes_to_circles(halo). Returns (circles, ws, BC, BH).""" + env = task.env + lim = env.limits.cpu().numpy() + ws = np.stack([lim[0, :2], lim[1, :2]]) + C, Hs, Sph = [], [], [] + for obj in (env.obj_fixed_list or []): + for fld in (getattr(obj, 'fields', None) or []): + cen = getattr(fld, 'centers', None) + if cen is None or len(cen) == 0: + continue + cen = np.asarray(cen.cpu(), dtype=float)[:, :2] + sz = getattr(fld, 'sizes', None) + if sz is not None: # box field: sizes are full extents + C.append(cen); Hs.append(np.asarray(sz.cpu(), dtype=float)[:, :2] / 2.0) + else: + rad = np.asarray(getattr(fld, 'radii', getattr(fld, 'radius')).cpu(), dtype=float).reshape(-1) + Sph += [(c, float(r)) for c, r in zip(cen, rad)] + circles = list(Sph) + if C: + circles += boxes_to_circles(np.concatenate(C), np.concatenate(Hs), halo=halo) + BC = np.concatenate(C) if C else np.zeros((0, 2)); BH = np.concatenate(Hs) if Hs else np.zeros((0, 2)) + return circles, ws, BC, BH + + +def export_suite(shared_npz, trained_models_dir, out_dir, maps=None): + """Write data/suite/_N.npz from the benchmark's shared instances (the same configurations every + method plans). Runs in the benchmark's environment (needs mmd and torch_robotics on the path). The disc + cover of the box obstacles uses a halo of half the endpoints' clearance, clipped to [0.004, 0.030].""" + from evaluate import build_task + os.makedirs(out_dir, exist_ok=True) + S = np.load(shared_npz, allow_pickle=True) + for mp in (maps or SUITE_MAPS): + os.environ['OBS_SCALE'] = str(OBS_SCALE[mp]) # read by the benchmark at environment construction + rr = suite_robot_radius(mp) + t = build_task(ENV_CLASS[mp] + '-RobotPlanarDisk', trained_models_dir) + P0 = np.concatenate([S[f'{mp}/N3/starts'].reshape(-1, 2), S[f'{mp}/N3/goals'].reshape(-1, 2)]) + c_fine, _, _, _ = benchmark_geometry(t, 0.002) + if c_fine: + Cf = np.array([c for c, _ in c_fine]); Rf = np.array([r for _, r in c_fine]) + clear = float((np.linalg.norm(P0[:, None, :] - Cf[None], axis=2) - (Rf[None] + rr)).min()) + else: + clear = 1.0 + halo = float(min(0.030, max(0.004, 0.5 * clear))) + circ, ws, BC, BH = benchmark_geometry(t, halo) + C = np.array([c for c, _ in circ]) if circ else np.zeros((0, 2)) + Rd = np.array([r for _, r in circ]) if circ else np.zeros((0,)) + for n in SUITE_NS: + k = f'{mp}/N{n}' + if k + '/starts' not in S.files: + print('# missing', k, flush=True); continue + np.savez(os.path.join(out_dir, f'{mp}_N{n}.npz'), centers=C, radii=Rd, ws=ws, box_c=BC, box_h=BH, + starts=S[k + '/starts'], goals=S[k + '/goals'], agents=n, horizon=64, + robot_r=rr, obs_scale=OBS_SCALE[mp], vmax_low=VCAP[mp][0], vmax_med=VCAP[mp][1], + vmax_high=VCAP[mp][2], map=mp, env_class=ENV_CLASS[mp]) + print(f'# {mp}: {len(circ)} discs, {len(BC)} boxes, r={rr}, halo={halo:.4f}, endpoint clearance {clear:+.4f}', flush=True) + + +if __name__ == '__main__': + import argparse + ap = argparse.ArgumentParser(description='Export the simulated-suite instances from the benchmark (benchmark env).') + ap.add_argument('--shared-npz', required=True, help="the benchmark's shared instance file (instances_shared.npz)") + ap.add_argument('--trained-models', required=True, help="the benchmark's data_trained_models directory") + ap.add_argument('--out', default=os.path.join(DATA, 'suite')) + ap.add_argument('--maps', nargs='*') + a = ap.parse_args() + export_suite(a.shared_npz, a.trained_models, a.out, a.maps) diff --git a/applications/multi_robot/robotarium/projector.py b/applications/multi_robot/robotarium/projector.py new file mode 100644 index 0000000000000000000000000000000000000000..9bce202b3103b8944af7b9743f5c0e1f43d71fb0 --- /dev/null +++ b/applications/multi_robot/robotarium/projector.py @@ -0,0 +1,535 @@ +"""GlobMP as the projector inside a diffusion sampler (the BaseProjector interface of DiRecT). + +This is the configuration that produced the GlobMP Robotarium results (figures/robotarium_gpa_sr_one.json): +the sampler's single-agent diffusion models propose a batch of team plans, and at every projection call the +proposal of each candidate team is the piecewise-linear reference of a tracking cost. GlobMP restores the +team (Algorithm 1) and descends on the tracking cost (Algorithm 2) under continuous-time blocks: pairwise +separation 2r, a uniform speed bound, the workspace box and the obstacle discs. The waypoints handed back +are samples of a curve that satisfies every block at every t. + +Batch layout (DiRecT): B = n_agents * G samples, agent-major, so sample a*G+j is robot a in team j. +Index 0 of each plan is pinned by params['pos_init']. + +Schedule over the denoising steps (GPA_NCALLS calls per instance): intermediate calls only steer (short +restoration, no descent, partially restored teams are handed back); the last call is the full solve and +returns certified teams only. A team whose piecewise-linear motion already satisfies every constraint +exactly is left alone. With GPA_PRIORITIZED=1, if no team certifies jointly at the last call, the teams are +certified one robot at a time (each robot against the robots already placed, as moving obstacles), with +wall, timing and detour repairs of the reference; with GPA_FIRST_CERT=1 the first certified team ends the +call, since the method returns one plan. + +Every knob is an environment variable (read inside the sampler process); the values used for the paper are +in run/run_cell.sh and the README. +""" +import os +import time + +import numpy as np +import torch + +from globmp import Problem, init_waypoints, spline as bern + +DT = torch.float64 + + +def seg_min_dist(A, B): + """Exact minimum distance between two robots moving linearly between waypoints. A, B: (..., H, 2).""" + P = A[..., :-1, :] - B[..., :-1, :] + D = (A[..., 1:, :] - A[..., :-1, :]) - (B[..., 1:, :] - B[..., :-1, :]) + dd = (D * D).sum(-1) + s = np.clip(np.where(dd > 1e-18, -(P * D).sum(-1) / np.maximum(dd, 1e-18), 0.0), 0.0, 1.0) + Q = P + s[..., None] * D + return np.sqrt((Q * Q).sum(-1)).min(-1) + + +def _static_route(w, discs, clr, lo, hi, res=0.01, lam=4.0): + """Wall-free route for ONE robot from w[0] to w[-1] on a grid over [lo, hi]: A* with step cost = length + + lam * distance to the robot's own reference path, so the route follows the reference where the reference + is feasible. A cell is free if every obstacle disc is at least `clr` away. Returns the reference re-timed + onto the route (same progress-versus-time profile), or None if no route exists.""" + import heapq + nx = int(np.ceil((hi[0] - lo[0]) / res)) + 1; ny = int(np.ceil((hi[1] - lo[1]) / res)) + 1 + xs = lo[0] + res * np.arange(nx); ys = lo[1] + res * np.arange(ny) + X, Y = np.meshgrid(xs, ys, indexing='ij'); Pg = np.stack([X, Y], -1) + free = np.ones((nx, ny), bool) + for c, r in discs: + free &= np.linalg.norm(Pg - np.asarray(c, float), axis=-1) >= float(r) + clr + dref = np.full((nx, ny), np.inf) + for q in w[::2]: dref = np.minimum(dref, np.linalg.norm(Pg - q, axis=-1)) + cell = lambda p: (int(np.clip(round((p[0] - lo[0]) / res), 0, nx - 1)), int(np.clip(round((p[1] - lo[1]) / res), 0, ny - 1))) + s, g = cell(w[0]), cell(w[-1]) + free[s] = free[g] = True # the benchmark's endpoints are feasible + nb = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] + dist = {s: 0.0}; prev = {}; pq = [(0.0, s)]; gx, gy = g + while pq: + f_, u = heapq.heappop(pq) + if u == g: break + du = dist[u] + for dx_, dy_ in nb: + v = (u[0] + dx_, u[1] + dy_) + if not (0 <= v[0] < nx and 0 <= v[1] < ny) or not free[v]: continue + step = res * (1.4142 if dx_ and dy_ else 1.0) + nd = du + step * (1.0 + lam * dref[v]) + if nd < dist.get(v, np.inf): + dist[v] = nd; prev[v] = u + heapq.heappush(pq, (nd + res * np.hypot(v[0] - gx, v[1] - gy), v)) + if g not in dist: return None + path = [g] + while path[-1] != s: path.append(prev[path[-1]]) + path = np.array([[xs[i], ys[j]] for i, j in path[::-1]]); path[0] = w[0]; path[-1] = w[-1] + arc = lambda P_: np.r_[0, np.cumsum(np.linalg.norm(np.diff(P_, axis=0), axis=1))] + ar, ap = arc(w), arc(path) + frac = ar / max(ar[-1], 1e-12) + return np.stack([np.interp(frac * ap[-1], ap, path[:, k]) for k in range(2)], 1) + + +class GPAProjector: + """Implements BaseProjector.project(x, params) -> x_proj.""" + + def __init__(self, n_agents, robot_radius, ws_bounds=None, obstacles=None, + degree=None, segments=None, ell_j=2, ell=0, elev=None, margin=0.0, + alg1_cap=None, alg2_blocks=None, alg2_steps=3, max_teams=None, + team_budget=None, verbose=False): + env = lambda k, d, f=int: f(os.environ.get(k) or d) + self.n_agents = int(n_agents) + self.robot_radius = float(robot_radius) + self.ws_bounds = None if ws_bounds is None else np.asarray(ws_bounds, dtype=float) + self.obstacles = obstacles or [] + self.d = env('GPA_DEG', degree or 5) + self.M = env('GPA_M', segments or 21) + self.De = env('GPA_ELEV', elev or 30) + self.ell_j, self.ell = int(ell_j), int(ell) + self.margin = env('GPA_MARGIN', margin or 0.005, float) + self.alg1_cap = env('GPA_CAP', alg1_cap or 3000) + self.alg2_blocks = env('GPA_BLOCKS', alg2_blocks if alg2_blocks is not None else 4) + self.alg2_steps = int(alg2_steps) + self.max_teams = env('GPA_MAXTEAMS', max_teams or 10 ** 6) + self.team_budget = env('GPA_TEAMBUDGET', team_budget or 20, float) + self.reach = env('GPA_REACH', 0.25, float) + self.verbose = verbose or bool(int(os.environ.get('GPA_VERBOSE', '0'))) + self.smoothness_weight = 0.0 # interface parity with the other projectors, unused + self.stats = dict(calls=0, teams_seen=0, teams_feasible=0, teams_solved=0, + teams_ok=0, teams_failed=0, time=0.0) + + def _problem(self, n, w0, w1, M): + return Problem(n, self.d, 4, self.ell, M, w0, w1, ell_j=self.ell_j, delta=self.margin, mode='orthant', De=self.De) + + # ---------------- one team ---------------- + def _keep_right(self, Wf): + """Steer the reference before a solve. Keep-right (GPA_RIGHTHAND): wherever a robot has an ONCOMING + robot within GPA_RH_RANGE robot radii, shift its reference to its own right, weighted by proximity, so + head-on pairs pass side by side. Circulation (GPA_CIRC): a robot whose heading would pass the + workspace centre against the team's net rotation is nudged to the side that carries its direction of + travel (GPA_CIRC_MODE=reflect: moved toward its mirror position instead). GPA_SHIFT_SMOOTH spreads each + shift over that many waypoints; GPA_RH_WALLSAFE drops shifts that would approach a wall. Endpoints stay + fixed; not applied at the final call.""" + w = float(os.environ.get('GPA_RIGHTHAND', '0')); wc = float(os.environ.get('GPA_CIRC', '0')) + if (w <= 0 and wc <= 0) or getattr(self, 'final_call', False): + return Wf + L1, n = Wf.shape; na = n // 2 + P = Wf.reshape(L1, na, 2) + V = np.gradient(P, axis=0) + sp = np.linalg.norm(V, axis=-1, keepdims=True) + T = V / np.maximum(sp, 1e-9) + Rt = np.stack([T[..., 1], -T[..., 0]], -1) # right-hand normal + R0 = float(os.environ.get('GPA_RH_RANGE', '4')) * self.robot_radius + D = np.linalg.norm(P[:, :, None] - P[:, None], axis=-1) # (L1, na, na) + onc = (np.einsum('tai,tbi->tab', T, T) < -0.3) & (D < R0) & (D > 0) + wt = (np.clip(1 - D / R0, 0, 1) * onc).sum(-1) # (L1, na) + wt *= (sp[..., 0] > 1e-6) + shift = w * np.tanh(wt)[..., None] * Rt + if wc > 0: + c = np.zeros(2) if self.ws_bounds is None else 0.5 * (np.asarray(self.ws_bounds[0]) + np.asarray(self.ws_bounds[1])) + # sign of the team's net rotation about the centre, from each robot's start->goal displacement + # (ties, e.g. antipodal swaps, default to counter-clockwise) + r0 = P[0] - c[None]; dv = P[-1] - P[0] + Lz = float((r0[:, 0] * dv[:, 1] - r0[:, 1] * dv[:, 0]).sum()) + scale = float((np.linalg.norm(r0, axis=1) * np.linalg.norm(dv, axis=1)).sum()) + 1e-12 + sgn = -1.0 if Lz < -0.05 * scale else 1.0 + rel = c[None, None] - P + d = sgn * (T[..., 0] * rel[..., 1] - T[..., 1] * rel[..., 0]) # > 0: moving the team's way round + Rt = sgn * Rt + if os.environ.get('GPA_CIRC_MODE', 'push') == 'reflect': + # one-way lanes: a robot heading the wrong way for its side of the centre (d < 0) is moved toward + # its mirror position across the centre line (distance 2|d|, capped); right-way robots stay put + cap = float(os.environ.get('GPA_CIRC_CAP', '0.35')) + shift = shift + wc * np.minimum(2 * np.clip(-d, 0, None), cap)[..., None] * Rt * (sp > 1e-6) + else: + dmin = float(os.environ.get('GPA_CIRC_D', '4')) * self.robot_radius + shift = shift + wc * np.clip((dmin - d) / dmin, 0, 1)[..., None] * Rt * (sp > 1e-6) + sw = int(os.environ.get('GPA_SHIFT_SMOOTH', '0')) + if sw > 1: + # spread every lane change over about sw waypoints (moving average along time), so a shift never + # opens a sideways jump that breaks the speed bound + k = np.ones(sw) / sw; pad = sw // 2 + Sp = np.pad(shift, ((pad, sw - 1 - pad), (0, 0), (0, 0)), mode='edge') + shift = np.stack([np.stack([np.convolve(Sp[:, a, c], k, mode='valid') for c in range(2)], -1) + for a in range(shift.shape[1])], 1) + shift[0] = 0; shift[-1] = 0 + if self.obstacles and os.environ.get('GPA_RH_WALLSAFE', '0') == '1': + # keep a shift only where the shifted point stays clear of every obstacle disc by r + margin + Cc = np.array([c for c, _ in self.obstacles], float); Rc = np.array([rr for _, rr in self.obstacles], float) + Q = P + shift + clr = (np.linalg.norm(Q[..., None, :] - Cc[None, None], axis=-1) - Rc[None, None]).min(-1) + need = self.robot_radius + max(self.margin, 0.0) + float(os.environ.get('GPA_RH_CLEAR', '0.02')) + shift = shift * (clr >= need)[..., None] + return (P + shift).reshape(L1, n) + + def _certify_prioritized(self, Wf, dx_max): + """Final-call certification one robot at a time: each robot is restored alone against the walls, the + arena, the speed bound and every robot already certified (their spline curves as moving obstacles, + exact continuous-time separation 2r). Order: fewest conflicts in the reference first; robots that do + not fit are retried once after the rest, then the ordering restarts with the unplaced robots first. + The team is certified iff every robot is. Same blocks and tolerances as the joint solve.""" + L = Wf.shape[0] - 1; n = Wf.shape[1]; na = n // 2; M = min(self.M, L); r2 = 2.0 * self.robot_radius + P = Wf.reshape(L + 1, na, 2) + conf = np.zeros(na) + for a in range(na): + for b in range(na): + if a != b: conf[a] += float(seg_min_dist(P[None, :, a], P[None, :, b])[0] < r2) + order = list(np.argsort(conf, kind='stable')); t_end = time.time() + self.team_budget + t_cap = time.time() + float(os.environ.get('GPA_PRIO_TEAM_MAX', '900')) # safety cap per team + safety = float(os.environ.get('GPA_PRIO_SAFETY', '600')) + for rnd in range(int(os.environ.get('GPA_PRIO_RESTARTS', '3'))): + placed = {} # a -> (segments (M,2,d+1), curve (L+1,2)) + for attempt in range(2): + pending = [a for a in order if a not in placed] + if not pending: break + for a in pending: + if time.time() > t_cap: return None + w = P[:, a]; w2 = None + + def build(refs): + # one robot: walls near ANY of the given reference paths, speed, arena (hard), and every + # already certified robot as a moving obstacle (exact continuous-time 2r) + keep = [(c, r) for c, r in self.obstacles + if min(np.linalg.norm(q - np.asarray(c, float)[None], axis=1).min() for q in refs) + <= float(r) + self.robot_radius + self.margin + self.reach] + p = self._problem(2, w[0], w[-1], M) + if dx_max and dx_max > 0: p.add_speed(float(dx_max) * L / M) + for c, r in keep: p.add_sphere(np.asarray(c, float), float(r) + self.robot_radius) + for _, (Sb, _c) in placed.items(): p.add_moving(Sb, r2) + if self.ws_bounds is not None: + p.add_box(self.ws_bounds[0] + self.robot_radius - self.margin + 1e-6, self.ws_bounds[1] - self.robot_radius + self.margin - 1e-6) + p.set_tracking_fine(refs[-1]) + tv = p.tolvec() + if self.ws_bounds is not None: tv[torch.tensor([t[0] == 'box' for t in p.types])] = 1e-12 + return p, tv + + def fits(p_, Z_): + # the robots execute (and the benchmark scores) the straight lines between waypoints: + # accept only if that motion clears walls, arena, speed and every placed robot exactly + S = np.stack([v[1] for v in placed.values()] + [self._curve(p_, Z_, L, M).numpy()]) + return bool(self._pl_slack(S[:, None], dx_max)[0] >= 0) + + p, tv = build([w]) + Z = init_waypoints(p, w[np.round(np.linspace(0, L, M + 1)).astype(int)]) + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=time.time() + safety); ok = ok and fits(p, Z) + if not ok and os.environ.get('GPA_REPAIR', '1') == '1' and self.ws_bounds is not None: + # wall repair: the reference crosses walls; re-route to the nearest wall-free route, + # keep its timing, certify that instead + w2 = _static_route(w, self.obstacles, self.robot_radius + self.margin + 0.002, + self.ws_bounds[0] + self.robot_radius, self.ws_bounds[1] - self.robot_radius) + if w2 is not None: + p, tv = build([w, w2]) + Z = init_waypoints(p, w2[np.round(np.linspace(0, L, M + 1)).astype(int)]) + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=time.time() + safety); ok = ok and fits(p, Z) + if not ok and os.environ.get('GPA_RETIME', '1') == '1': + # timing repair: the remaining conflicts are with robots already placed; keep the route + # and try re-timings of it (start later or arrive earlier) within the speed bound + base = w2 if w2 is not None else w + arc = np.r_[0, np.cumsum(np.linalg.norm(np.diff(base, axis=0), axis=1))]; tt = np.linspace(0, 1, L + 1) + frac0 = np.interp(np.arange(L + 1), np.arange(L + 1), arc / max(arc[-1], 1e-12)) + for kind, k in [('delay', .1), ('early', .1), ('delay', .2), ('early', .2), ('delay', .3), ('delay', .4)]: + if kind == 'delay': u_ = np.clip((tt - k) / (1 - k), 0, 1) + else: u_ = np.clip(tt / (1 - k), 0, 1) + fr = np.interp(u_, tt, frac0) * arc[-1] + wk = np.stack([np.interp(fr, arc, base[:, j]) for j in range(2)], 1) + if np.linalg.norm(np.diff(wk, axis=0), axis=1).max() > float(dx_max) * 0.98: continue + p, tv = build([w, wk]) + Z = init_waypoints(p, wk[np.round(np.linspace(0, L, M + 1)).astype(int)]) + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=time.time() + safety); ok = ok and fits(p, Z) + if ok: break + if not ok and os.environ.get('GPA_DETOUR', '1') == '1' and self.ws_bounds is not None and placed: + # detour repair: blocked by robots already placed (e.g. head-on in a one-lane passage); + # re-route around the walls AND the whole paths of the placed robots it conflicts with + wr = w2 if w2 is not None else w + cl = [cb for _, (Sb, cb) in placed.items() if seg_min_dist(wr[None], cb[None])[0] < r2 + self.margin] + ext = [(q, self.robot_radius) for cb in cl for q in cb + if min(np.linalg.norm(q - w[0]), np.linalg.norm(q - w[-1])) > r2 + self.margin] + w3 = None + if ext: + w3 = _static_route(w, list(self.obstacles) + ext, self.robot_radius + self.margin + 0.002, + self.ws_bounds[0] + self.robot_radius + self.margin, self.ws_bounds[1] - self.robot_radius - self.margin) + if w3 is not None and np.linalg.norm(np.diff(w3, axis=0), axis=1).max() > float(dx_max) * 0.98: + # longer route: traverse it at constant speed instead of the reference's profile + a3 = np.r_[0, np.cumsum(np.linalg.norm(np.diff(w3, axis=0), axis=1))] + w3 = np.stack([np.interp(np.linspace(0, a3[-1], L + 1), a3, w3[:, j]) for j in range(2)], 1) + if a3[-1] / L > float(dx_max) * 0.9: w3 = None # keep slack for the spline + if w3 is not None: + p, tv = build([w, w3]) + Z = init_waypoints(p, w3[np.round(np.linspace(0, L, M + 1)).astype(int)]) + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=time.time() + safety); ok = ok and fits(p, Z) + if ok: placed[a] = (p.segs(Z).detach().clone(), self._curve(p, Z, L, M).numpy()) + if len(placed) == na: break + order = [a for a in order if a not in placed] + [a for a in order if a in placed] + if len(placed) < na: return None + return torch.as_tensor(np.concatenate([placed[a][1] for a in range(na)], axis=1)) + + def _pl_slack(self, Wt, dx_max): + """Exact slack of the piecewise-linear motion through the waypoints (what the robots execute and the + benchmark scores): robot spacing 2r, speed, walls and the arena (a segment stays inside the box iff + its endpoints do). Wt: (na, G, L+1, 2) -> (G,); feasible iff >= 0.""" + na, G = Wt.shape[0], Wt.shape[1] + need = 2.0 * self.robot_radius + slack = np.full(G, np.inf) + for a in range(na): + for b in range(a + 1, na): + slack = np.minimum(slack, seg_min_dist(Wt[a], Wt[b]) - need) + if dx_max: + step = np.linalg.norm(np.diff(Wt, axis=2), axis=-1).max(-1).max(0) # (G,) + slack = np.minimum(slack, dx_max * (1 + 1e-9) - step) + if self.obstacles: + Cc = np.array([c for c, _ in self.obstacles], float); Rc = np.array([r for _, r in self.obstacles], float) + P0, D_ = Wt[..., :-1, :], np.diff(Wt, axis=2) # (na,G,L,2) + w = Cc[None, None, None] - P0[..., None, :] # (na,G,L,K,2) + dd = (D_ * D_).sum(-1)[..., None] + tt = np.clip((w * D_[..., None, :]).sum(-1) / np.maximum(dd, 1e-18), 0, 1) + dist = np.linalg.norm(w - tt[..., None] * D_[..., None, :], axis=-1) # (na,G,L,K) + oslack = (dist - Rc - self.robot_radius).min(-1).min(-1).min(0) # (G,) + slack = np.minimum(slack, oslack) + if self.ws_bounds is not None: + lo, hi = np.asarray(self.ws_bounds[0], float), np.asarray(self.ws_bounds[1], float) + e = np.minimum(Wt - lo, hi - Wt).min(-1).min(-1).min(0) - self.robot_radius + slack = np.minimum(slack, e) + return slack + + def _pl_ok(self, Wf, dx_max): + """Wf: (L+1, n) team plan -> True iff its piecewise-linear motion is feasible (see _pl_slack).""" + Wf = np.asarray(Wf, float); na = Wf.shape[1] // 2 + return bool(self._pl_slack(Wf.reshape(Wf.shape[0], na, 2).transpose(1, 0, 2)[:, None], dx_max)[0] >= 0) + + def _solve_team(self, Wf, dx_max): + """Wf: (L+1, n) stacked waypoints of one team. Returns (L+1, n) or None.""" + if getattr(self, 'prio', False): + return self._certify_prioritized(Wf, dx_max) + Wf = self._keep_right(Wf) + L = Wf.shape[0] - 1 + n = Wf.shape[1] + na = n // 2 + M = min(self.M, L) + t0 = time.time() + # only the obstacle discs a robot could reach: the solve stays near the reference, and the returned + # plan is checked against the FULL geometry (_pl_ok, and the benchmark's own scorer) + obs_near = [] + for a in range(na): + P_ = Wf[:, 2 * a:2 * a + 2] + keep = [] + for c, r in self.obstacles: + d = np.linalg.norm(P_ - np.asarray(c, dtype=float)[None, :], axis=1).min() + if d <= float(r) + self.robot_radius + self.margin + self.reach: + keep.append((c, r)) + obs_near.append(keep) + p = self._problem(n, Wf[0], Wf[-1], M) + for a in range(na): + ra = [2 * a, 2 * a + 1] + for b in range(a + 1, na): + p.add_sep(ra, [2 * b, 2 * b + 1], 2.0 * self.robot_radius) + if dx_max and dx_max > 0: + p.add_speed(float(dx_max) * L / M, rows=ra) # uniform speed bound == every chord <= dx_max + for c, r in obs_near[a]: + p.add_sphere(np.asarray(c, dtype=float), float(r) + self.robot_radius, rows=ra) + if self.ws_bounds is not None: + # the box is shrunk by delta (= margin) inside the problem: hand it bounds + r - delta so the target is + # the robot disc inside the arena, and make the box blocks hard + p.add_box(np.tile(self.ws_bounds[0] + self.robot_radius - self.margin + 1e-6, na), + np.tile(self.ws_bounds[1] - self.robot_radius + self.margin - 1e-6, na)) + p.set_tracking_fine(Wf) + tv = p.tolvec() + if self.ws_bounds is not None: + tv[torch.tensor([t[0] == 'box' for t in p.types])] = 1e-12 + Z = init_waypoints(p, Wf[np.round(np.linspace(0, L, M + 1)).astype(int)]) + dl = t0 + self.team_budget + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=dl) + if not ok: + # steering call: hand back the partially restored iterate; the final call returns certified plans only + return self._curve(p, Z, L, M) if getattr(self, 'partial', False) else None + if self.alg2_blocks > 0: + Z, eta, _ = p.solve(Z, tv, blocks=self.alg2_blocks, steps=self.alg2_steps, eta=eta, deadline=dl) + if not bool((p.resid(p.pi_all(Z))[0] <= tv).all()): + Z, eta, ok = p.project(Z, tv, cap=self.alg1_cap, deadline=dl) + if not ok: + return self._curve(p, Z, L, M) if getattr(self, 'partial', False) else None + return self._curve(p, Z, L, M) + + @staticmethod + def _curve(p, Z, L, M): + """The spline at the L+1 waypoint times -> (L+1, n).""" + tt = torch.linspace(0, 1, L + 1, dtype=DT) + seg = torch.clamp((tt * M).floor().long(), 0, M - 1) + u = tt * M - seg + bu = bern.bernstein_basis_eval(p.d, u) # (d+1, L+1) + S = p.segs(Z) # (M, n, d+1) + return torch.einsum('inp,pi->in', S[seg], bu) # (L+1, n) + + # ---------------- BaseProjector API ---------------- + def project(self, x, params=None): + dev, dt_ = x.device, x.dtype + B, Hm1, _ = x.shape + na = self.n_agents + G = B // na + pos_init = params['pos_init'].detach().to('cpu', DT) + dx_max = float(params['dx_max']) if params.get('dx_max', None) is not None else None + xc = x.detach().to('cpu', DT) + out = xc.clone() + self.stats['calls'] += 1 + t_call = time.time() + + idx = np.arange(B).reshape(na, G) # idx[a, j] = a*G + j + W = torch.cat([pos_init.unsqueeze(1), xc], 1).numpy() # (B, L+1, 2) + Wt = W[idx] # (na, G, L+1, 2) + slack = self._pl_slack(Wt, dx_max) + bad = np.where(slack < 0)[0] + self.stats['teams_seen'] += G + self.stats['teams_feasible'] += int(G - len(bad)) + order = bad[np.argsort(-slack[bad])] # least infeasible first + order = order[:self.max_teams] + # the projector object lives across instances (n-inits > 1): every GPA_NCALLS-th call is the final one + ncalls = int(os.environ.get('GPA_NCALLS', '13')) + final = self.stats['calls'] % ncalls == 0 + saved = (self.team_budget, self.alg1_cap, self.alg2_blocks) + self.partial = (not final) and os.environ.get('GPA_PARTIAL', '1') == '1' + self.final_call = final + if not final: + self.team_budget = float(os.environ.get('GPA_INTER_BUDGET', '4')) + self.alg1_cap = int(os.environ.get('GPA_INTER_CAP', '400')) + self.alg2_blocks = 0 + jobs = [(int(j), np.ascontiguousarray(Wt[:, j].transpose(1, 0, 2).reshape(Hm1 + 1, 2 * na))) + for j in order] + self.stats['teams_solved'] += len(jobs) + if os.environ.get('GPA_BATCH', '0') == '1' and jobs: + results = _run_teams_batched(self, jobs, dx_max) + else: + results = _run_teams(self, jobs, dx_max) + self.team_budget, self.alg1_cap, self.alg2_blocks = saved + for j, Wp in results: + if Wp is None: + self.stats['teams_failed'] += 1 + continue + self.stats['teams_ok'] += 1 + Wp = torch.as_tensor(Wp).reshape(Hm1 + 1, na, 2).permute(1, 0, 2) # (na, L+1, 2) + out[idx[:, j]] = Wp[:, 1:, :].to(DT) + self.stats['time'] += time.time() - t_call + if final and os.environ.get('GPA_CERT_LOG'): + # GlobMP's own certificate for this instance: teams the exact screen already passed plus teams the + # final solve certified. The returned plan is the first certified candidate. + cert = sorted(set(int(j) for j in range(G) if j not in set(bad.tolist())) | {int(j) for j, Wp in results if Wp is not None}) + import json as _json + with open(os.environ['GPA_CERT_LOG'], 'a') as fh: + fh.write(_json.dumps(dict(call=int(self.stats['calls']), certified=cert)) + '\n') + if self.verbose: + print(f" [gpa] call {self.stats['calls']}: {G} teams, {G - len(bad)} already feasible, " + f"{len(order)} solved, {self.stats['teams_failed']} failed, " + f"{time.time() - t_call:.1f}s", flush=True) + return out.to(dev, dt_) + + +_POOL_SELF = None +_POOL = None + + +def _pool_init(proj): + global _POOL_SELF + torch.set_num_threads(1) + _POOL_SELF = proj + + +def _team_worker(arg): + j, Wf, dx_max, budget = arg + if budget is not None: + _POOL_SELF.team_budget, _POOL_SELF.alg1_cap, _POOL_SELF.alg2_blocks, _POOL_SELF.partial, _POOL_SELF.final_call, _POOL_SELF.prio = budget + try: + Wp = _POOL_SELF._solve_team(Wf, dx_max) + if Wp is not None and getattr(_POOL_SELF, 'final_call', False) and not _POOL_SELF._pl_ok(Wp.numpy(), dx_max): + Wp = None # the returned plan must also be exact on its waypoint motion + return j, (None if Wp is None else Wp.numpy()) + except Exception: + if os.environ.get('GPA_VERBOSE', '0') == '1': + import traceback; traceback.print_exc() + return j, None + + +def _run_teams(proj, jobs, dx_max): + """Solve one call's teams on a persistent pool of SPAWNED workers (GPA_WORKERS; CPU only). fork is + unusable here (the sampler has already run autograd, which torch refuses in a forked child); a spawned + child would re-run the driver script as __mp_main__, so the pool is created with __main__.__file__ + hidden and the workers only import this module.""" + global _POOL, _POOL_SELF + nw = int(os.environ.get('GPA_WORKERS', '16')) + budget = (proj.team_budget, proj.alg1_cap, proj.alg2_blocks, getattr(proj, 'partial', False), getattr(proj, 'final_call', False), getattr(proj, 'prio', False)) + if nw <= 1 or len(jobs) <= 1: + _POOL_SELF = proj + return [_team_worker((j, W, dx_max, budget)) for j, W in jobs] + if _POOL is None: + import multiprocessing as mp, sys as _s + main = _s.modules.get('__main__'); saved = getattr(main, '__file__', None) + if saved is not None: + del main.__file__ + try: + _POOL = mp.get_context('spawn').Pool(nw, initializer=_pool_init, initargs=(proj,)) + finally: + if saved is not None: + main.__file__ = saved + args = [(j, W, dx_max, budget) for j, W in jobs] + if getattr(proj, 'prio', False) and os.environ.get('GPA_FIRST_CERT', '1') == '1': + # the method returns one plan: take the first team that certifies and drop the rest + out = [] + for j, Wp in _POOL.imap_unordered(_team_worker, args, chunksize=1): + out.append((j, Wp)) + if Wp is not None: + _POOL.terminate(); _POOL = None + break + return out + return _POOL.map(_team_worker, args, chunksize=1) + + +def _run_teams_batched(proj, jobs, dx_max): + """All teams of one call as ONE batched problem (batch.py); same constraints, steering and + certified-only final output as the per-team path.""" + from batch import solve_teams + W = np.stack([proj._keep_right(Wf) for _, Wf in jobs]) # (G, L+1, n) + dev = os.environ.get('GPA_DEVICE', 'cuda') + if dev == 'cpu': + torch.set_num_threads(int(os.environ.get('GPA_BATCH_THREADS', '4'))) + out, ok = solve_teams(W, proj.n_agents, proj.robot_radius, proj.obstacles, proj.ws_bounds, dx_max, + d=proj.d, M=proj.M, delta=proj.margin, budget=proj.team_budget, cap=proj.alg1_cap, + alg2_blocks=proj.alg2_blocks, partial=getattr(proj, 'partial', False), device=dev) + part = getattr(proj, 'partial', False) + ok = np.asarray(ok, bool).copy() + if getattr(proj, 'final_call', False): + for g in np.where(ok)[0]: + ok[g] = proj._pl_ok(np.asarray(out[g]), dx_max) # exact on the executed waypoint motion too + res = [(j, (out[g] if (ok[g] or part) else None)) for g, (j, _) in enumerate(jobs)] + if getattr(proj, 'final_call', False) and os.environ.get('GPA_PRIORITIZED', '0') == '1': + failed = [(j, Wf) for g, (j, Wf) in enumerate(jobs) if not ok[g]] + first_only = os.environ.get('GPA_FIRST_CERT', '1') == '1' + if failed and not (first_only and bool(np.any(ok))): + # prioritized certification on the CPU pool, fewest-conflict candidates first; with GPA_FIRST_CERT + # it stops at the first certified team (the returned plan is the first certified candidate) + na_ = proj.n_agents + def conflicts(Wf): + Pw = Wf.reshape(Wf.shape[0], na_, 2); r2 = 2 * proj.robot_radius + return sum(float(seg_min_dist(Pw[None, :, a], Pw[None, :, b])[0] < r2) for a in range(na_) for b in range(a + 1, na_)) + failed.sort(key=lambda jw: conflicts(jw[1])) + nw = max(1, int(os.environ.get('GPA_WORKERS', '16'))) + proj.prio = True; pr = {} + try: + if first_only: + pr.update(dict(_run_teams(proj, failed, dx_max))) + else: + for k in range(0, len(failed), nw): + pr.update(dict(_run_teams(proj, failed[k:k + nw], dx_max))) + finally: + proj.prio = False + res = [(j, (pr[j] if (Wp is None and pr.get(j) is not None) else Wp)) for j, Wp in res] + return res diff --git a/applications/multi_robot/robotarium/robotarium_baselines/.gitignore b/applications/multi_robot/robotarium/robotarium_baselines/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5a6cfae87cdcaa9fab84632c599d77f9589dcb33 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/.gitignore @@ -0,0 +1,6 @@ +config/paths.env +runs/ +**/data_trajectories/ +**/data_trained_models/ +**/results/ +**/__pycache__/ diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/.gitignore b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e7bcf7dad7aa0f192242b203b04526ad88527922 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/.gitignore @@ -0,0 +1,43 @@ +# Caches +__pycache__/ +*.pyc +*.pyo + +# Python packaging +*.egg-info/ +build/ +dist/ + +# Experiments, logs, and run outputs +results/ +logs/ +scripts/inference/results/ +scripts/inference/data_trained_models/ + +# Downloaded / generated data +data_trajectories/ +data_trained_models/ +*.tar.xz + +# Mujoco +MUJOCO_LOG.TXT + +# Notebooks +*.ipynb + +# Claude +.claude/ + +# Slurm +*.out +*.err + +# OS +.DS_Store + +# IDE +.vscode/ +.idea/ + +# Wandb +wandb/ diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..4ffdc21cb0fa2e7b904cda67eb71632d2da6e534 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2022 Julen Urain +Copyright (c) 2023 Columbia Artificial Intelligence and Robotics Lab +Copyright (c) 2024 Zeyu Feng and Hao Luan +Copyright (c) 2024 Yorai Shaoul, Itamar Mishani, and Shivam Vats +Copyright (c) 2025 Hao Luan and Yi Xian Goh +Copyright (c) 2025 Wei Xiao +Copyright (c) 2026 Laboratory for Information and Decision Systems, Azizan Group, MIT +Copyright (c) 2026 Paolo Giaretta, Zeyang Li, Navid Azizan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/README.md b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/README.md new file mode 100644 index 0000000000000000000000000000000000000000..976d18cf4ee7c1d98b7f162f17c476280398e024 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/README.md @@ -0,0 +1,209 @@ +# Safe multi-robot motion planning in MMD + +This folder presents the benchmarks for _DiRecT_, _PCD_, and relevant baselines on the multi-robot motion planning task. The codebase is forked from the [**Projected Coupled Diffusion (PCD)**](https://github.com/EdmundLuan/pcd) [1], which itself extends [MMD](https://github.com/yoraish/mmd) [2]: the planner stack, projection operators, coupling costs, and most of the inference / analysis scripts already exist in the PCD parent. What is contributed here is the **DiRecT** planner on top. + +![Multi-robot grid visualization](assets/mrmp-grid.gif) + +**Table of Contents** +- [Installation](#installation) +- [Get Datasets](#get-datasets) +- [Generating missing Drop Region dataset](#generating-missing-drop-region-dataset) +- [Planning Inference](#planning-inference) +- [Analyzing Results](#analyzing-results) +- [What we modified/added](#what-we-modifiedadded) +- [Optimizers](#optimizers) +- [Code structure](#code-structure) +- [References](#references) + +--- + +## Installation + +Follow the setup steps from the PCD parent ([EdmundLuan/pcd](https://github.com/EdmundLuan/pcd)) as below. In summary: + +### Requirements +- [miniconda](https://docs.conda.io/projects/miniconda/en/latest/index.html) + +### Steps + +1. Create a conda environment and activate it (the env is named `direct-mmd` in `environment.yml`; this installs Python, PyTorch, JAX, and all pip dependencies): + ```sh + conda env create -f environment.yml + conda activate direct-mmd + ``` +2. Install the local (patched) versions of [2, 3]: + ```sh + cd deps/torch_robotics && pip install -e . && cd ../.. + cd deps/experiment_launcher && pip install -e . && cd ../.. + cd deps/motion_planning_baselines && pip install -e . && cd ../.. + ``` +3. Install the `mmd` package: + ```sh + pip install -e . + ``` + +## Get Datasets + +Download the sample datasets and trained models provided by the PCD parent: + +```sh +conda activate direct-mmd + +gdown --id 1Onw0s1pDsMLDfJVOAqmNme4eVVoAkkjz +tar -xJvf data_trajectories.tar.xz + +gdown --id 1WO3tpvg-HU0m9RyDvGyfDamo7roBYMud +tar -xJvf data_trained_models.tar.xz +``` + +> If the gdown IDs above have expired, the current dataset locations are tracked in the PCD parent repo: https://github.com/EdmundLuan/pcd. + +## Generating missing Drop Region dataset + +The gdown bundle ships the **DropRegion trained model** but not its **trajectory dataset**. The dataset is still required at inference time — only for the normalizer stats (min/max for `[-1, 1]` scaling), not for retraining. + +> [!WARNING] +> To run any DropRegion experiment you must first generate this dataset, otherwise inference will fail to load the normalizer. Before running, edit the SLURM partition, GPU resources, and other system-specific settings in the script to match your cluster. Generation may take a while depending on your hardware. + +```sh +conda activate direct-mmd +python scripts/generate_data/launch_generate_trajectories.py +``` + +The launcher has no CLI; edit `env_id` at the top of the file to switch environments (default `'EnvDropRegion2D'`). Output goes to `logs//...` — move or symlink it into `data_trajectories/EnvDropRegion2D-RobotPlanarDisk/` to match the layout of the other environments. + +--- + +## Planning Inference + +All single-agent methods (PCD, DiRecT, baselines) go through `scripts/inference/launch_pcdiff.py`; MMD-CBS uses its own `launch_mmd.py`. Pick a config from `configs/` (`pcdiff_*.yaml` for PCD / baselines, `direct_*.yaml` for DiRecT) and one of the six commands below. + +Before the first run, create the results directory: +```sh +mkdir -p results/000event_logs +``` + +```sh +conda activate direct-mmd + +# Unconstrained — vanilla diffusion, no projection +python scripts/inference/launch_pcdiff.py --config pcdiff_empty.yaml -g vanilla -p none -m unconstrained + +# PCD-SHD — coupling guidance + ADMM velocity projection, squared-hinge cost +python scripts/inference/launch_pcdiff.py --config pcdiff_empty.yaml -g coupling -p max_vel_admm -c hinge_sqr_l2 -r -m pcd_shd + +# PCD-LB — coupling guidance + ADMM velocity projection, log-barrier cost +python scripts/inference/launch_pcdiff.py --config pcdiff_empty.yaml -g coupling -p max_vel_admm -c sum_log_l2 -r -m pcd_lb + +# Final projection — unguided diffusion, project only at the last sampling step +python scripts/inference/launch_pcdiff.py --config direct_empty.yaml -g final -p scp_admm -m final_projection + +# DiRecT — unguided diffusion + projection every step (use -p scp_admm or -p ipopt) +python scripts/inference/launch_pcdiff.py --config direct_empty.yaml -g base -p scp_admm -m direct + +# MMD-CBS — multi-agent baseline (no CLI; edit MultiAgentPlanningExperimentConfig in the file) +python scripts/inference/launch_mmd.py +``` + +**Optional CLI overrides** (apply to any method above): +- `-n ` — number of initial states (default 100), e.g. `-n 2` for a quick test. +- `-v ` — velocity cap for projection. Repeatable for sweeps, e.g. `-v 0.675 -v 0.703`. +- `-a ` — number of agents (default 4). + +Swap `pcdiff_empty.yaml` / `direct_empty.yaml` for the `highway`, `conveyor`, or `dropregion` variants to reproduce the other environments. To launch the full sweep across all methods × environments × agent counts in one go, use `python scripts/inference/run_benchmark.py --help`, or one of the shell wrappers in `scripts/inference/` (`run_full_sweep.sh`, `run_single_method.sh`, `run_all_methods.sh`, `sbatch_benchmark.sh`). + +Results land under `results/Random/...` (PCD / DiRecT under `agent___N/method___.../cost___...`, MMD-CBS under `/num_agents___N/planner___CBS/...`); event logs are written to `results/000event_logs/`. + +--- + +## Analyzing Results + +PCD/DiRecT and MMD-CBS produce different output layouts, so they have separate analyzers: + +```sh +# PCD / DiRecT results (any run produced by launch_pcdiff.py) +python scripts/analyze/analyzing_pcd.py --config analysis_config.yaml + +# MMD-CBS results (auto-discover + post-process + analyze) +python scripts/analyze/run_all_mmd_postprocessing.py +``` + +Point the analyzer at the runs you want by editing `log_paths` in `configs/analysis_config.yaml` (PCD/DiRecT) or `configs/analyze_mmd.yaml` (MMD-CBS). + +--- + +## What we modified/added + +**Inherited from PCD (and from MMD before it):** +- `mmd/models/projection/` — ADMM-based state-space projection operators. +- `mmd/coupling_costs/` — pair-wise inter-robot cost functions. +- `mmd/models/diffusion_models/sample_functions.py::projected_coupling_ddpm_sample_fn` — projection-aware DDPM sampling hook. +- `mmd/models/diffusion_models/guides.py` — `GuideManager` framework for gradient-based coupling guidance. +- `mmd/planners/single_agent/` — `MPD`, `MPDEnsemble`, `PCD` planners (upstream MMD + PCD). +- `mmd/planners/multi_agent/` — `CBS`, `PrioritizedPlanning` multi-agent planners (upstream MMD). +- `mmd/common/` — conflict bookkeeping (`conflict_conversion.py`, `multi_agent_utils.py`, `constraints.py`, `experiences.py`). +- `configs/pcdiff_*.yaml`, `launch_pcdiff.py` / `launch_mmd.py`, analysis scripts under `scripts/analyze/`, and data-generation scripts under `scripts/generate_data/`. + +**Added by this fork (DiRecT):** +- `mmd/planners/single_agent/direct.py` — new `DiRecT` (unguided diffusion + projection) planner class. +- `mmd/planners/single_agent/` — new `IpoptProjector` and `JaxScpAdmmProjector` projector classes. +- `mmd/models/diffusion_models/` — additional sampling-loop wiring to call the new projectors from the `-g base/final` branches. +- `mmd/config/mmd_params.py` — new entries for the DiRecT hyperparameters. +- New DiRecT branches in `scripts/inference/launch_pcdiff.py` — the `-g {base, final}` guiders and the `-p {ipopt, scp_admm}` projectors. +- New configs `configs/direct_{empty,highway,conveyor,dropregion}.yaml`. +- New benchmark methods `direct` and `final_projection` in `scripts/inference/run_benchmark.py`, plus shell helpers (`run_all_methods.sh`, `run_full_sweep.sh`, `run_single_method.sh`, and SLURM wrappers). +- Local patches to `deps/torch_robotics/` and other vendored deps for JAX SCP-ADMM and IPOPT projector compatibility. + +## Optimizers + +The planning stack exposes three families of hard-projection optimizers, picked at the command line via `-p, --projector`: + +- **ADMM velocity projection** *(from the PCD parent)* — implemented in `mmd/models/projection/projectors.py` as `ADMMProjectionOperator` and the GPU-accelerated `ADMMProjectionOperatorCUDAGraph`. These solve the convex problem of projecting a candidate trajectory onto the bounded-velocity set (`‖x_{i+1} − x_i‖₂ ≤ v_max·Δt`) by alternating an x-update (linear solve with cached LU / Cholesky factorization), a z-update (Euclidean L2-ball projection), and a u-update (dual-variable ascent), with primal/dual residual convergence checks. Selected via `-p max_vel_admm`. +- **JAX Sequential Convex Programming + ADMM** *(DiRecT)* — implemented in `JaxScpAdmmProjector` (`mmd/planners/single_agent/`). It linearizes harder constraints (obstacles / dynamics) via SCP and runs ADMM on each linearized subproblem in JAX, JIT-compiled for batched trajectories. Selected via `-p scp_admm`; this is the default projector for DiRecT and for the `final_projection` method. +- **IPOPT interior-point** *(DiRecT)* — implemented in `IpoptProjector` as a general-purpose alternative when ADMM-based projectors are not flexible enough for a given constraint set. Selected via `-p ipopt`. + +--- + +## Code structure + +Only DiRecT-added and implementation-critical files are listed; everything else inherited from PCD/MMD is omitted for brevity. + +``` +multi_robot/ +├── configs/ +│ ├── direct_{empty,highway,conveyor,dropregion}.yaml # NEW: DiRecT configs +│ └── pcdiff_{empty,highway,conveyor,dropregion}.yaml # PCD / CD configs (inherited) +├── mmd/ +│ ├── planners/single_agent/ +│ │ └── direct.py # NEW: DiRecT, IpoptProjector, JaxScpAdmmProjector +│ ├── models/projection/projectors.py # ADMM projection operators (inherited) +│ └── coupling_costs/ # inter-robot coupling costs (inherited) +├── scripts/ +│ ├── inference/ +│ │ ├── launch_pcdiff.py # main entrypoint (PCD / DiRecT / baselines) +│ │ ├── launch_mmd.py # MMD-CBS entrypoint (edit-the-file) +│ │ └── run_benchmark.py # full sweep across methods × envs × #agents +│ ├── generate_data/launch_generate_trajectories.py # generate DropRegion (or other) datasets +│ └── analyze/{analyzing_pcd.py, run_all_mmd_postprocessing.py} +├── deps/torch_robotics/ # local fork (patched for SCP-ADMM / IPOPT) +├── environment.yml # conda env: direct-mmd +└── setup.py # `pip install -e .` → package `mmd` +``` + +## ✏️ Citation +If you find our code or paper useful for your research, please consider citing our work: +``` +@misc{giaretta2026directsafediffusionbasedplanning, + title={DiRecT: Safe Diffusion-Based Planning via Receding-Horizon Denoising}, + author={Paolo Giaretta and Zeyang Li and Navid Azizan}, + year={2026}, + eprint={2606.15359}, + archivePrefix={arXiv}, + primaryClass={cs.LG}, + url={https://arxiv.org/abs/2606.15359}, +} +``` + +## References +- [1] Hao Luan. Projected Coupled Diffusion (PCD) for multi-robot motion planning. Implementation extending MMD. https://github.com/EdmundLuan/pcd +- [2] Y. Shaoul, I. Mishani, S. Vats, J. Li, M. Likhachev. Multi-Robot Motion Planning with Diffusion Models. ICLR 2025. https://github.com/yoraish/mmd diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analysis_config.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analysis_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a1ec7b3c5e84cea23f0c1650547bbd351900d252 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analysis_config.yaml @@ -0,0 +1,63 @@ +# ------------------- +# ANALYSIS CONFIG +# ------------------- + +# List of data directories to process. The script will loop through each directory. +# Add the paths to your own benchmark logs here, e.g.: +# - "results/000event_logs/benchmark-YYYYMMDD_HHMMSS-my_tag.log" +log_paths: # [] + # Examples (uncomment / adapt to your runs): + - "results/000event_logs/eval_runs-260519_152524-unconstrained.log" + # - "results/000event_logs/benchmark-260423_232958-bench_j1of6_conveyor_v1.21.log" + + +# Switches for plotting and saving figures/data. +switches: + # plot_trajectories: true + plot_trajectories: false + plot_velocity_stats: true + save_plots: true + save_stats_json: true + +# General constants and parameters for the analysis. +constants: + EPS: 1.0e-6 + VEL_TOL: 5.0e-5 # Tolerance for velocity checks + ## --------------- Highway ------------------- + # DEFAULT_MAX_VEL: 0.647 # Default maximum velocity threshold (80% of max action) + # DEFAULT_MAX_VEL: 0.781 # (90% of max action) + # DEFAULT_MAX_VEL: 0.878 # (95% of max action) + ## --------------- Empty ------------------- + ##[0.675, 0.692, 0.703] + # DEFAULT_MAX_VEL: 0.675 # (75% of max action) + # DEFAULT_MAX_VEL: 0.692 # (90% of max action) + # DEFAULT_MAX_VEL: 0.703 # (95% of max action) + ## --------------- Conveyor ------------------- + ## [1.08, 1.46, 1.76] + # DEFAULT_MAX_VEL: 1.21 # (85% of max action) + # DEFAULT_MAX_VEL: 1.46 # (90% of max action) + # DEFAULT_MAX_VEL: 1.76 # (95% of max action) + ## --------------- DropRegion ------------------- + ## [0.928, 1.13, 1.34] + DEFAULT_MAX_VEL: 0.928 # (80% of max action) + # DEFAULT_MAX_VEL: 1.13 # (90% of max action) + # DEFAULT_MAX_VEL: 1.34 # (95% of max action) + + + VEL_HIST_QUANTILES: [0.75, 0.8, 0.85, 0.9, 0.95, 0.99] + +# Plotting-specific parameters. +plotting: + # Number of initial states figures to plot + num_inits_plot: 10 + # Number of trajectory tuples to plot + num_trj_tups_plot: 64 + # Trajectory plotting settings + traj_plot_xlim: [-1, 1] + traj_plot_ylim: [-1, 1] + traj_plot_h_end: 64 + # Velocity histogram settings + vel_hist_bins: 100 + +data: + default_trained_models_dir: "default_trained_models_dir" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analyze_mmd.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analyze_mmd.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a3194eb4795bc7d6e146aeda5d46a63144380aab --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/analyze_mmd.yaml @@ -0,0 +1,59 @@ +# ------------------- +# ANALYSIS CONFIG +# ------------------- + +# List of data directories to process. The script will loop through each directory. +log_paths: + ##---------------------- Highway ---------------------- + # - "results/EnvHighways2DRobotPlanarDiskRandom/dummy_logs.log" + ### -------------- Empty ------------------- + # - "results/EnvEmpty2DRobotPlanarDiskRandom/dummy_logs-04142026_202235.log" + ### ----------------- Conveyor ------------------- + # - "results/EnvConveyor2DRobotPlanarDiskRandom/dummy_logs-04142026_204123.log" + +switches: + plot_trajectories: false + plot_velocity_stats: false + save_plots: false + save_stats_json: true + +constants: + EPS: 1.0e-6 + VEL_TOL: 5.0e-5 # Tolerance for velocity checks + ## --------------- Highway ------------------- + # DEFAULT_MAX_VEL: 0.647 # Default maximum velocity threshold (80% of max action) + # DEFAULT_MAX_VEL: 0.781 # (90% of max action) + # DEFAULT_MAX_VEL: 0.878 # (95% of max action) + ## --------------- Empty ------------------- + ##[0.675, 0.692, 0.703] + # DEFAULT_MAX_VEL: 0.675 # (75% of max action) + # DEFAULT_MAX_VEL: 0.692 # (90% of max action) + # DEFAULT_MAX_VEL: 0.703 # (95% of max action) + ## --------------- Conveyor ------------------- + ## [1.21, 1.46, 1.76] + # DEFAULT_MAX_VEL: 1.21 # (85% of max action) + # DEFAULT_MAX_VEL: 1.46 # (90% of max action) + DEFAULT_MAX_VEL: 1.76 # (95% of max action) + ## --------------- DropRegion ------------------- + ## [0.928, 1.13, 1.34] + # DEFAULT_MAX_VEL: 0.928 # (85% of max action) + # DEFAULT_MAX_VEL: 1.13 # (90% of max action) + # DEFAULT_MAX_VEL: 1.34 # (95% of max action) + + VEL_HIST_QUANTILES: [0.75, 0.8, 0.85, 0.9, 0.95, 0.99] + +# Plotting-specific parameters. +plotting: + # Number of initial states figures to plot + num_inits_plot: 10 + # Number of trajectory tuples to plot + num_trj_tups_plot: 64 + # Trajectory plotting settings + traj_plot_xlim: [-1, 1] + traj_plot_ylim: [-1, 1] + traj_plot_h_end: 64 + # Velocity histogram settings + vel_hist_bins: 100 + +data: + default_trained_models_dir: "default_trained_models_dir" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_conveyor.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_conveyor.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bcc8fb7f35454a7208081770a1c1822d16b0666d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_conveyor.yaml @@ -0,0 +1,79 @@ +experiment: + name: "single_tile_conveyor" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvConveyor2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvConveyor2D" + margin: 0.2 + obstacle_margin: 0.2 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "DiRecT" + planner_alg: "base" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + projector_type: "JaxScpADMM" + start_projection_guidance: 0.5 # guidance start + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 + smoothness_weight: 0.0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_dropregion.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_dropregion.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9e7ca5c52a350640d8f97f36552c069bb1cd0e23 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_dropregion.yaml @@ -0,0 +1,79 @@ +experiment: + name: "single_tile_dropregion" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvDropRegion2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvDropRegion2D" + margin: 0.2 + obstacle_margin: 0.2 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "DiRecT" + planner_alg: "base" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + projector_type: "JaxScpADMM" + start_projection_guidance: 0.5 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 + smoothness_weight: 0.0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_empty.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_empty.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5fffd8bdccbbed7ec6fbcf1e01d96fca1686b95a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_empty.yaml @@ -0,0 +1,81 @@ + +experiment: + name: "single_tile_empty" + num_agents: 8 + n_inits: 10 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvEmpty2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "DiRecT" + planner_alg: "base" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 # Number of samples **per agent** + debug: true + timeit: false # Enable timing for performance measurement + # --- Guidance and Diffusion Parameters --- + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + projector_type: "JaxScpADMM" + start_projection_guidance: 0.5 # guidance start + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 10.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.675 + smoothness_weight: 0.0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_highway.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_highway.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f8999228507b3c987ec6ab09f6c1e173194ca6a4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/direct_highway.yaml @@ -0,0 +1,80 @@ +experiment: + name: "single_tile_highway" + num_agents: 2 + n_inits: 3 + runtime_limit: 30 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvHighways2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvHighways2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "DiRecT" + planner_alg: "base" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + projector_type: "JaxScpADMM" + start_projection_guidance: 0.5 # 0.25 older but overwritte by run_benchmark.py + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 + smoothness_weight: 0.0 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_conveyor.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_conveyor.yaml new file mode 100644 index 0000000000000000000000000000000000000000..84473762be173b886a02b1982375a1b8ef2bdfc6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_conveyor.yaml @@ -0,0 +1,90 @@ +# config.yaml +# +# This YAML file centralizes all configurations for the multi-robot planning experiment. +# To run an experiment, execute: +# python script_name.py --config path/to/this/config.yaml + +# General experiment settings +experiment: + name: "single_tile_conveyor" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # in seconds + seed: 18 + time_str: "auto" # "auto" will generate from current time, or provide a string like "07132025_100000" + render_animation: true + +# System and directory settings +system: + device: "cuda:0" + base_dir: "auto" # "auto" resolves to the project root, or provide a full path + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # "auto" resolves to the results directory, or provide a full path + +# Robot-specific parameters +robot: + planar_disk_radius: 0.05 + +# Environment and scenario setup +environment: + type: "single_tile" + global_model_ids: [['EnvConveyor2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvConveyor2D" + margin: 0.2 + obstacle_margin: 0.2 + +# Planner configurations +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + # Low-level single-agent trajectory generation planner (the diffusion model) + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 # Number of samples **per agent** + debug: true + timeit: false # Enable timing for performance measurement + # --- Guidance and Diffusion Parameters --- + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" # Key for the cost function used in guidance + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 # Coefficient for the collision cost radius + # --- Cost Weights for Guidance --- + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_dropregion.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_dropregion.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e51277ec877c37d18bd88103c2095c7f109c321b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_dropregion.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_dropregion" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvDropRegion2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvDropRegion2D" + margin: 0.2 + obstacle_margin: 0.2 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_empty.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_empty.yaml new file mode 100644 index 0000000000000000000000000000000000000000..05b8108386e6442ff8d019996d9e1eff871f886b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_empty.yaml @@ -0,0 +1,77 @@ +experiment: + name: "single_tile_empty" + num_agents: 8 + n_inits: 10 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvEmpty2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 # Number of samples **per agent** + debug: true + timeit: false # Enable timing for performance measurement + # --- Guidance and Diffusion Parameters --- + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 10.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.675 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_highway.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_highway.yaml new file mode 100644 index 0000000000000000000000000000000000000000..610ffd592a16dab6f4fbd5ad90ad7e781cdbb7b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/configs/pcdiff_highway.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_highway" + num_agents: 2 + n_inits: 3 + runtime_limit: 30 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvHighways2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvHighways2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..950d9bf2182a4f5b9754dfc456063601bc968068 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Common + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/MANIFEST.in b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..746d308e20663c0a7f9dab6035b76677a4fca06e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/MANIFEST.in @@ -0,0 +1,2 @@ +include requirements.txt +include setup.py diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/Makefile b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..057f03b082ecdc7bf2c56a1bad2482411cab0210 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/Makefile @@ -0,0 +1,14 @@ +package: + python setup.py sdist + +install: package + pip install $(shell ls dist/*.tar.gz) + +all: clean install + +upload: + python -m twine upload dist/* + +clean: + rm -rf dist + rm -rf build diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/README.md b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a8dbbf1736f4787b541f801fe25848ca72c10764 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/README.md @@ -0,0 +1,111 @@ +# Experiment Launcher + +Launch experiments locally or on a cluster running SLURM in a single file. + +## Description + +The ``experiment_launcher`` package provides a simple way to run multiple experiments using SLURM or Joblib with minimum +effort - you just have to set the `local` parameter to `True` to run locally, +or to `False` to run with a cluster using SLURM. + +It is particularly useful to run multiple seeds and/or test multiple configurations of hyperparameters such as learning rates, batch size. + +## Installation + +You can do a minimal installation of ``experiment_launcher`` with: +``` +pip install -e . +``` + +## How to Use + +### Basic Usage + +The best way to understand experiment launcher is to look at the basic example in [examples/basic](examples/basic). + +- The file [examples/basic/test.py](examples/basic/test.py) contains a single experiment +- The file [examples/basic/launch_test.py](examples/basic/launch_test.py) contains the launcher file configurations and call + +**Single experiment** +- [examples/basic/test.py](examples/basic/test.py) consists of: + - The function `experiment` is the entry point of your experiment + - It takes as arguments your experiment settings (e.g., the number of layers in a neural + network, the learning rate, ...) + - The arguments need to be assigned a type and default value in the function definition + - Current accepted types are `int`, `float`, `str`, `bool`, `list` + - The arguments `seed` and `results_dir` **must** always be included + - Python kwargs can also be added as `**kwargs` (accepted types are the same as above) + - This function **must** be decorated with a decorator, e.g. `@single_experiment`. + This will take care of creating proper results directories. + + - The `if __name__ == '__main__'` block + - This **must** contain one single line: `run_experiment(experiment)` +- You can test your code by running + ```bash + cd examples/basic + python test.py + ``` + +**Launch file** +- [examples/basic/launch_test.py](examples/basic/launch_test.py) consists of: + - Creating an instance of the `Launcher` object, that contains the SLURM or Joblib (if run locally) parameters. These are some of the important parameters. For more consult the class definition. + - `exp_name` is the experiment name, under which a results directory will be created + - `exp_file` is the path to the python file where the `experiment` is implemented (without the extension `.py`) + - `project_name` is the project name in the cluster + - `n_seeds` is the number of random seeds for each single experiment configuration + - `n_exps_in_parallel` is the number of experiments to be run in parallel. This is useful to run multiple jobs in a single GPU in the cluster + - `n_cores` is the number of cores for each experiment. + Note that if `n_exps_in_parallel > 1`, then `n_exps_in_parallel` jobs will share `n_cores`. + This parameter does not apply if running locally. For that you should use the linux comamnd [taskset](https://man7.org/linux/man-pages/man1/taskset.1.html) + - `memory_per_core` is amount of memory in MB requested for each core in SLURM. If you specify this too low, SLURM might crash. + - `partition` is the SLURM partition, which is cluster dependent + - `gres` are special resources asked for a SLURM experiment + - `conda_env` if you are using a conda environment, specify its name here + - Adding experiments with `launcher.add_experiment` + - Use `launcher.add_experiment` to create an experiment for a particular configuration (e.g., different learning rates) + - Parameters that are followed by _**two trailing underscores**_ will be used to create experiment directories + - Results directories are going to be created by default as, e.g. with `param__` and `param2__` + - `./logs/exp_name_DATE/param1__value/param2__value/SEED/` + - **Be careful** when specifying the parameters and double underscores. + The launcher will throw an error if two experiments lead to the same results directory (which would be overwritten). + - To see this problem, uncomment the line `# dict(floating_arg=11.0, string_arg='test')` in [examples/basic/launch_test.py](examples/basic/launch_test.py) + - Parameters will with trailing underscores have priority over their counterparts without underscores. E.g., `env='some_env'` will be overwritten by `env__='my_env'` + - Running the experiments with `launcher.run(LOCAL)` + - This runs your experiment either locally (LOCAL: `True`) or in the cluster (LOCAL: `False`) + + +**Running the experiment** +- To run the launcher simply call + ```bash + cd examples/basic + python launch_test.py + ``` +- Log files will be placed in + - `./logs` if running locally or in the IAS cluster + - `/work/scratch/$USERNAME` if ran in the `Lichtenberg-Hochleistungsrechner of the TU Darmstadt` + + +### Integration with Weights and Biases + +The experiment launcher provides an easy way to integrate with Weights and Biases. +- In the experiment file add + - `**kwargs` in the `experiment` function definition +- In the launcher file, create wandb options and pass them to the `launcher.add_experiment()` + ```python + wandb_options = dict( + wandb_enabled=False, # If True, runs and logs to wandb. + wandb_entity='joaocorreiacarvalho', + wandb_project='experiment_launcher_test', + wandb_group='group_test' + ) + ``` + + +### Running experiments with parameters in configuration files +- If you have many parameters that you need to change, a good idea can be to use configuration files. +- If you want to specify your parameters in a configuration file, look at the example under `examples/config_files` + + +## Notes +- For reproducibility, the seeds are created sequentially from `0` to `n_exps-1`. + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..6cc1aeb0640a7d85b44aadab9a01eede04b99e0b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/launch_test.py @@ -0,0 +1,75 @@ +from itertools import product + +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 3 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + gres=GRES, + conda_env=CONDA_ENV, + use_timestamp=True, + compact_dirs=False +) + +envs = { + 'env_00': {'env_param': 'aa'}, + 'env_01': {'env_param': 'bb'} +} +a_l = [1, 2, 3] +boolean_param_l = [True, False] +some_default_param = 'b' + +# These arguments are kwargs of the experiment function +unknown_args_list = [ + dict(integer_arg=10), + # dict(floating_arg=11.0, string_arg='test') +] + +for env in envs: + d = envs[env] + for a, boolean_param in product(a_l, boolean_param_l): + for unknown_args in unknown_args_list: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + env__=env, + a__=a, + boolean_param__=boolean_param, + + env='some_env', # This value will be overwritten by env__ + + **d, + some_default_param=some_default_param, + + **unknown_args, + debug=False, + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/test.py new file mode 100644 index 0000000000000000000000000000000000000000..2572c6f2b7e2ad1673007744e53745a0cf8650fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/basic/test.py @@ -0,0 +1,47 @@ +import os + +import wandb + +from experiment_launcher import run_experiment, single_experiment + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment +def experiment( + ####################################### + env: str = 'env', # You need to specify the argument type if you use the automatic parser. + env_param: str = 'aa', + a: int = 1, + boolean_param: bool = True, + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + print(f'kwargs: {kwargs}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}, env {env}, ' \ + f'env_param {env_param}, a {a}, ' \ + f'boolean_param {boolean_param}, some_default_param {some_default_param}' + print(out_str) + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config00.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config00.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d3d16b27db8814633bb5feef1d67d81e77f38ba9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config00.yaml @@ -0,0 +1,7 @@ +network: mynetwork +layers: 3 +activations: + - relu + - relu + - tanh +lr: 1e-3 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config01.yaml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config01.yaml new file mode 100644 index 0000000000000000000000000000000000000000..514fa3b70ac516a9b638ddd3c868f3f57789ea1d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/configs/config01.yaml @@ -0,0 +1,7 @@ +network: myothernetwork +layers: 3 +activations: + - relu + - relu + - tanh +lr: 1e-3 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..53839b855e0864f35f66cf7a91740a2cc7eb8a64 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/launch_test.py @@ -0,0 +1,65 @@ +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 3 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + conda_env=CONDA_ENV, + gres=GRES, + use_timestamp=True, + compact_dirs=False +) + +config_files_l = [ + 'configs/config00.yaml', + 'configs/config01.yaml', +] + +# Optional arguments for Weights and Biases +wandb_options = dict( + wandb_mode='disabled', # "online", "offline" or "disabled" + wandb_entity='joaocorreiacarvalho', + wandb_project='test_experiment_launcher_config_files' +) + +for i, config_file in enumerate(config_files_l): + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + config__=f'config-{str(i).zfill(len(config_files_l))}', + + config_file_path=config_file, + + debug=False, + + **wandb_options, + wandb_group=f'test_group-el-{config_file}' + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/test.py new file mode 100644 index 0000000000000000000000000000000000000000..5633ec4015b363c7ce08f8351bd5ff33f05387fd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/config_files/test.py @@ -0,0 +1,49 @@ +import os + +import wandb +import yaml + +from experiment_launcher import run_experiment, single_experiment_yaml + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment_yaml +def experiment( + ####################################### + config_file_path: str = './configs/config00.yaml', + + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + with open(config_file_path, 'r') as f: + configs = yaml.load(f, yaml.Loader) + + print('Config file content:') + print(configs) + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}' + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + wandb.log({'seed': seed}, step=1) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..bc957be14bae9364f33f96ce5a1183e7256eaa99 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py @@ -0,0 +1,60 @@ +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = True + +N_SEEDS = 10 + +if LOCAL: + N_EXPS_IN_PARALLEL = 2 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'rtx2,rtx' if USE_CUDA else 'amd2,amd' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + conda_env=CONDA_ENV, + gres=GRES, + use_timestamp=True, + compact_dirs=False +) + +tensor_sizes_l = [1000000 + i for i in range(5)] + +# Optional arguments for Weights and Biases +wandb_options = dict( + wandb_mode='disabled', # "online", "offline" or "disabled" + wandb_entity='joaocorreiacarvalho', + wandb_project='test_experiment_launcher_config_files' +) + +for tensor_size in tensor_sizes_l: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + tensor_size__=tensor_size, + + debug=False, + + **wandb_options, + wandb_group=f'test_group-el-{tensor_size}', + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py new file mode 100644 index 0000000000000000000000000000000000000000..98a3dcc41d60696e98e121edbefbe34d83bc0948 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py @@ -0,0 +1,54 @@ +import os +import time + +import torch.cuda +import wandb + +from experiment_launcher import run_experiment, single_experiment_yaml + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment_yaml +def experiment( + ####################################### + env: str = 'env-name', # You need to specify the argument type if you use the automatic parser. + tensor_size: int = 10000, + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + tensor1 = torch.ones(tensor_size, device='cuda' if torch.cuda.is_available() else 'cpu') + tensor2 = tensor1.clone() + + print(f'Env: {env}') + print(f'Tensor shape: {tensor1.shape}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}' + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + wandb.log({'seed': seed}, step=1) + + end_time = time.time() + 0.01 + while time.time() < end_time: + tensor = tensor1 * tensor2 + print('Finished experiment.') + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..b8db2f41244087ad94780fc68731bbbb1e082d5d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/launch_test.py @@ -0,0 +1,98 @@ +import time +from itertools import product + +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 103 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + + +# E.g. SLURM only allows arrays with N_SEEDS_MAX_SLURM size +N_SEEDS_MAX_SLURM = 100 +start_seed = 0 +if LOCAL: + n_seeds_split = [N_SEEDS] +else: + if N_SEEDS <= N_SEEDS_MAX_SLURM: + n_seeds_split = [N_SEEDS] + elif N_SEEDS % N_SEEDS_MAX_SLURM == 0: + n_seeds_split = [N_SEEDS_MAX_SLURM for _ in range(N_SEEDS // N_SEEDS_MAX_SLURM)] + else: + n_seeds_split = [N_SEEDS_MAX_SLURM for _ in range(N_SEEDS // N_SEEDS_MAX_SLURM)] + [N_SEEDS % N_SEEDS_MAX_SLURM] + +for _n_seeds in n_seeds_split: + print(f"---------> Running seeds {start_seed}-{start_seed + _n_seeds}") + + time.sleep(1) + launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=_n_seeds, + start_seed=start_seed, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + gres=GRES, + conda_env=CONDA_ENV, + use_timestamp=True, + compact_dirs=False + ) + + envs = { + 'env_00': {'env_param': 'aa'}, + 'env_01': {'env_param': 'bb'} + } + a_l = [1, 2, 3] + boolean_param_l = [True, False] + some_default_param = 'b' + + # These arguments are kwargs of the experiment function + unknown_args_list = [ + dict(integer_arg=10), + # dict(floating_arg=11.0, string_arg='test') + ] + + for env in envs: + d = envs[env] + for a, boolean_param in product(a_l, boolean_param_l): + for unknown_args in unknown_args_list: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + env__=env, + a__=a, + boolean_param__=boolean_param, + + env='some_env', # This value will be overwritten by env__ + + **d, + some_default_param=some_default_param, + + **unknown_args, + debug=False, + ) + + launcher.run(LOCAL, TEST) + + ################################################################################################################ + start_seed += _n_seeds diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/test.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/test.py new file mode 100644 index 0000000000000000000000000000000000000000..2572c6f2b7e2ad1673007744e53745a0cf8650fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/examples/start_seed/test.py @@ -0,0 +1,47 @@ +import os + +import wandb + +from experiment_launcher import run_experiment, single_experiment + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment +def experiment( + ####################################### + env: str = 'env', # You need to specify the argument type if you use the automatic parser. + env_param: str = 'aa', + a: int = 1, + boolean_param: bool = True, + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + print(f'kwargs: {kwargs}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}, env {env}, ' \ + f'env_param {env_param}, a {a}, ' \ + f'boolean_param {boolean_param}, some_default_param {some_default_param}' + print(out_str) + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5fee850c832de1ddc9c2519b804303afa6982b48 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/__init__.py @@ -0,0 +1,8 @@ +try: + from .launcher import Launcher, run_experiment + from .decorators import single_experiment, single_experiment_yaml, single_experiment_flat + from .utils import is_local +except ImportError: + pass + +__version__ = '2.3' diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/decorators.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..41b314a9c5f7bd1d8f304675aa273f9d34f2b733 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/decorators.py @@ -0,0 +1,49 @@ +import logging +import os +from functools import wraps, partial + +from experiment_launcher.utils import save_args, start_wandb, create_results_dir + + +def wrapper_single_experiment(exp_func, save_args_yaml=False, use_logging=False, + make_dirs_with_seed=True, print_exp_args=False): + @wraps(exp_func) + def wrapper( + # Function arguments + *args, + **kwargs + ): + # Make results directory + create_results_dir(kwargs, make_dirs_with_seed) + + # Setup logging + if use_logging: + logging.basicConfig(level=logging.INFO, + filename=os.path.join(kwargs['results_dir'], "logfile"), + # stream=sys.stdout, + filemode="a+", + format="%(asctime)-15s %(levelname)-8s %(message)s") + + # Save arguments + save_args(kwargs['results_dir'], kwargs, + git_repo_path='./', save_args_as_yaml=save_args_yaml, print_exp_args=print_exp_args) + + # Start WandB + wandb_activated = True if 'wandb_mode' in kwargs else False + if wandb_activated: + wandb_run = start_wandb(**kwargs) + + # Run the experiment + exp_func(*args, **kwargs) + + # Clean up + if wandb_activated: + wandb_run.finish() + + return wrapper + + +single_experiment = partial(wrapper_single_experiment) +single_experiment_flat = partial(wrapper_single_experiment, make_dirs_with_seed=False) +single_experiment_yaml = partial(wrapper_single_experiment, save_args_yaml=True, use_logging=True, print_exp_args=False) +single_experiment_flat_yaml = partial(wrapper_single_experiment, make_dirs_with_seed=False, save_args_yaml=True) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/exceptions.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..28ecd0ff0cdc48aed94a9db352ff08e3c97f3346 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/exceptions.py @@ -0,0 +1,10 @@ +class ResultsDirException(Exception): + """ + Raised when the results directory already exists + """ + def __init__(self, exp, results_dir='./logs'): + message = f"\n" \ + f"When trying to add the experiment: {exp}\n" \ + f"Results directory {results_dir} already exists.\n" \ + f"Make sure that varying parameters have a trailing double underscore." + super().__init__(message) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/launcher.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/launcher.py new file mode 100644 index 0000000000000000000000000000000000000000..5b2a3832b8aa8587e31e6419c5e92dcb2ffcc02f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/launcher.py @@ -0,0 +1,495 @@ +import argparse +import datetime +import inspect +import os +import traceback +from copy import copy, deepcopy +from distutils.util import strtobool +from importlib import import_module + +import numpy as np +from joblib import Parallel, delayed + +from experiment_launcher.exceptions import ResultsDirException + + +class Launcher(object): + """ + Creates and starts jobs with Joblib or SLURM. + + """ + + def __init__(self, exp_name, exp_file, n_seeds, start_seed=0, n_cores=1, memory_per_core=2000, + days=0, hours=24, minutes=0, seconds=0, + project_name=None, base_dir=None, + n_exps_in_parallel=1, + conda_env=None, gres=None, constraint=None, partition=None, + begin=None, use_timestamp=True, compact_dirs=False): + """ + Constructor. + + Args: + exp_name (str): name of the experiment + exp_file (str): name of the python module running a single experiment (relative path) + n_seeds (int): number of seeds for each experiment configuration + start_seed (int): first seed + n_cores (int): number of cpu cores + memory_per_core (int): maximum memory per core (slurm will kill the job if this is reached) + days (int): number of days the experiment can last (in slurm) + hours (int): number of hours the experiment can last (in slurm) + minutes (int): number of minutes the experiment can last (in slurm) + seconds (int): number of seconds the experiment can last (in slurm) + project_name (str): name of the project for slurm. This is important if you have + different projects (e.g. in the hhlr cluster) + base_dir (str): path to directory to save the results (in hhlr results are saved to /work/scratch/$USER) + n_exps_in_parallel (int): number of experiment configurations to run in parallel. + If running in the cluster, and the gpu is selected, then it is the number of jobs in each slurm file + (e.g. for multiple experiments in the same gpu) + conda_env (str): name of the conda environment to run the experiments in + gres (str): request cluster resources. E.g. to add a GPU in the IAS cluster specify gres='gpu:rtx2080:1' + constraint (str): constraint for the slurm job. E.g. to add a GPU in the IAS cluster: constraint='rtx2080' + partition (str, None): the partition to use in case of slurm execution. If None, no partition is specified. + begin (str): start the slurm experiment at a given time (see --begin in slurm docs) + use_timestamp (bool): add a timestamp to the experiment name + compact_dirs (bool): If true, only the parameter value is used for the directory name. + + """ + self._exp_name = exp_name + self._exp_file = exp_file + self._start_seed = start_seed + self._n_seeds = n_seeds + self._n_cores = n_cores + self._memory_per_core = memory_per_core + self._duration = Launcher._to_duration(days, hours, minutes, seconds) + self._project_name = project_name + self._n_exps_in_parallel = n_exps_in_parallel + self._conda_env = conda_env + self._gres = gres + self._constraint = constraint + self._partition = partition + self._begin = begin + + self._experiment_list = list() + + if use_timestamp: + self._exp_name += datetime.datetime.now().strftime('_%Y-%m-%d_%H-%M-%S') + + base_dir = './logs' if base_dir is None else base_dir + self._exp_dir_local = os.path.join(base_dir, self._exp_name) + + # tracks the results directories + self._results_dir_l = [] + + self._exp_dir_slurm = self._exp_dir_local + if os.getenv("USER"): + scratch_dir = os.path.join('/work', 'scratch', os.getenv('USER')) + if os.path.isdir(scratch_dir): + self._exp_dir_slurm = os.path.join(scratch_dir, self._exp_name) + os.makedirs(self._exp_dir_slurm) + + # directories for slurm sbatch files and logs + self._exp_dir_slurm_files = os.path.join(self._exp_dir_slurm, "slurm_files") + self._exp_dir_slurm_logs = os.path.join(self._exp_dir_slurm, "slurm_logs") + + self._compact_dirs = compact_dirs + + def add_experiment(self, **kwargs): + self._experiment_list.append(deepcopy(kwargs)) + + def run(self, local, test=False, sequential=False): + self._check_experiments_results_directories() + if local: + if sequential: + self._run_sequential(test) + else: + self._run_local(test) + else: + self._run_slurm(test) + + self._experiment_list = list() + + def generate_slurm(self, command_line_list=None): + project_name_option = '' + partition_option = '' + begin_option = '' + gres_option = '' + constraint_option = '' + + if self._project_name: + project_name_option = '#SBATCH -A ' + self._project_name + '\n' + if self._partition: + partition_option += f'#SBATCH -p {self._partition}\n' + if self._begin: + begin_option += f'#SBATCH --begin={self._begin}\n' + if self._gres: + print(self._gres) + gres_option += '#SBATCH --gres=' + str(self._gres) + '\n' + if self._constraint: + print(self._constraint) + constraint_option += '#SBATCH --constraint=' + str(self._constraint) + '\n' + + conda_code = '' + if self._conda_env: + if os.path.exists(f'{os.getenv("HOME")}/miniconda3'): + conda_code += f'eval \"$({os.getenv("HOME")}/miniconda3/bin/conda shell.bash hook)\"\n' + elif os.path.exists(f'{os.getenv("HOME")}/anaconda3'): + conda_code += f'eval \"$({os.getenv("HOME")}/anaconda/bin/conda shell.bash hook)\"\n' + else: + raise Exception('You do not have a /home/USER/miniconda3 or /home/USER/anaconda3 directories') + conda_code += f'conda activate {self._conda_env}\n\n' + python_code = f'python {self._exp_file_path} \\' + else: + python_code = f'python3 {self._exp_file_path} \\' + + experiment_args = '\t\t' + experiment_args += r'${@: 2}' + experiment_args += ' \\' + + seed_code = f'\t\t--seed $(({self._start_seed} + $SLURM_ARRAY_TASK_ID)) \\' + result_dir_code = '\t\t--results_dir $1' + + code = f"""\ +#!/usr/bin/env bash + +############################################################################### +# SLURM Configurations + +# Optional parameters +{project_name_option}{partition_option}{begin_option}{gres_option}{constraint_option} +# Mandatory parameters +#SBATCH -J {self._exp_name} +#SBATCH -a 0-{self._n_seeds - 1} +#SBATCH -t {self._duration} +#SBATCH --ntasks 1 +#SBATCH --cpus-per-task {self._n_cores} +#SBATCH --mem-per-cpu={self._memory_per_core} +#SBATCH -o {self._exp_dir_slurm_logs}/%A_%a.out +#SBATCH -e {self._exp_dir_slurm_logs}/%A_%a.err + +############################################################################### +# Your PROGRAM call starts here +echo "Starting Job $SLURM_JOB_ID, Index $SLURM_ARRAY_TASK_ID" + +# Program specific arguments +{conda_code} + +""" + code += f"""\ +# Program specific arguments + +echo "Running scripts in parallel..." +echo "########################################################################" + +""" + for command_line in command_line_list: + code += f"""\ + +{python_code} +{seed_code} +\t\t{command_line} & + +""" + + code += f"""\ + +wait # This will wait until both scripts finish +echo "########################################################################" +echo "...done." +""" + return code + + def save_slurm(self, command_line_list=None, idx: str = None): + code = self.generate_slurm(command_line_list) + + label = f"_{idx}" if idx is not None else "" + script_name = f'slurm_{self._exp_name}{label}.sh' + full_path = os.path.join(self._exp_dir_slurm_files, script_name) + + with open(full_path, "w") as file: + file.write(code) + + return full_path + + def _run_slurm(self, test): + # Create slurm directories for sbatch and log files + os.makedirs(self._exp_dir_slurm_files) + os.makedirs(self._exp_dir_slurm_logs) + + # Generate and save slurm files + slurm_files_path_l = [] + experiment_list_chunked = [] + for i in range(0, len(self._experiment_list), self._n_exps_in_parallel): + experiment_list_chunked.append(self._experiment_list[i:i + self._n_exps_in_parallel]) + + for i, exps in enumerate(experiment_list_chunked): + command_line_l = [] + for exp in exps: + exp_new_without_underscore = self.remove_last_underscores_dict(exp) + command_line_arguments = self._convert_to_command_line(exp_new_without_underscore) + results_dir = self._generate_results_dir(self._exp_dir_slurm, exp) + command_line_l.append(f'--results_dir {results_dir} {command_line_arguments}') + slurm_files_path_l.append(self.save_slurm(command_line_l, + str(i).zfill(len(str(len(experiment_list_chunked)))))) + + # Launch slurm files in parallel + for slurm_file_path in slurm_files_path_l: + command = f"sbatch {slurm_file_path}" + if test: + print(command) + else: + os.system(command) + + def _run_local(self, test): + if not test: + os.makedirs(self._exp_dir_local, exist_ok=True) + + module = import_module(self._exp_file) + experiment = module.experiment + + if test: + self._test_experiment_local() + else: + def experiment_wrapper(params): + try: + experiment(**params) + except Exception: + print("Experiment failed with parameters:") + print(params) + traceback.print_exc() + + params_dict = get_experiment_default_params(experiment) + + Parallel(n_jobs=self._n_exps_in_parallel)(delayed(experiment_wrapper)(deepcopy(params)) + for params in self._generate_exp_params(params_dict)) + + def _run_sequential(self, test): + if not test: + os.makedirs(self._exp_dir_local, exist_ok=True) + + module = import_module(self._exp_file) + experiment = module.experiment + + if test: + self._test_experiment_local() + else: + default_params_dict = get_experiment_default_params(experiment) + + for params in self._generate_exp_params(default_params_dict): + try: + experiment(**params) + except Exception: + print("Experiment failed with parameters:") + print(params) + traceback.print_exc() + + def _check_experiments_results_directories(self): + """ + Check if the results directory produced for each experiment clash. + """ + for exp in self._experiment_list: + results_dir = self._generate_results_dir(self._exp_dir_local, exp) + # Check if the results directory already exists. + if results_dir in self._results_dir_l: + # Terminate to prevent overwriting the results directory. + raise ResultsDirException(exp, results_dir) + self._results_dir_l.append(results_dir) + + def _test_experiment_local(self): + for exp, results_dir in zip(self._experiment_list, self._results_dir_l): + for i in range(self._start_seed, self._n_seeds): + params = str(exp).replace('{', '(').replace('}', '').replace(': ', '=').replace('\'', '') + if params: + params += ', ' + print('experiment' + params + 'seed=' + str(i) + ', results_dir=' + results_dir + ')') + + def _generate_results_dir(self, results_dir, exp, n=6): + for key, value in exp.items(): + if key.endswith('__'): + if self._compact_dirs: + subfolder = str(value) + else: + subfolder = key + '_' + str(value).replace(' ', '') + subfolder = subfolder.replace('/', '-') # avoids creating subfolders if there is a slash in the name + results_dir = os.path.join(results_dir, subfolder) + return results_dir + + def _generate_exp_params(self, default_params_dict): + seeds = np.arange(self._start_seed, self._start_seed + self._n_seeds) + for exp in self._experiment_list: + params_dict = deepcopy(default_params_dict) + exp_new_without_underscore = self.remove_last_underscores_dict(exp) + params_dict.update(exp_new_without_underscore) + params_dict['results_dir'] = self._generate_results_dir(self._exp_dir_local, exp) + for seed in seeds: + params_dict['seed'] = int(seed) + yield params_dict + + @staticmethod + def remove_last_underscores_dict(exp_dict): + exp_dict_new = copy(exp_dict) + for key, value in exp_dict.items(): + if key.endswith('__'): + exp_dict_new[key[:-2]] = value + del exp_dict_new[key] + return exp_dict_new + + @staticmethod + def _convert_to_command_line(exp): + command_line = '' + for key, value in exp.items(): + new_command = '--' + key + ' ' + + if isinstance(value, list): + new_command += ' '.join(map(str, value)) + ' ' + else: + new_command += str(value) + ' ' + + command_line += new_command + + # remove last space + command_line = command_line[:-1] + + return command_line + + @staticmethod + def _to_duration(days, hours, minutes, seconds): + h = "0" + str(hours) if hours < 10 else str(hours) + m = "0" + str(minutes) if minutes < 10 else str(minutes) + s = "0" + str(seconds) if seconds < 10 else str(seconds) + + return str(days) + '-' + h + ":" + m + ":" + s + + @property + def exp_name(self): + return self._exp_name + + def log_dir(self, local=True): + if local: + return self._exp_dir_local + else: + return self._exp_dir_slurm + + @property + def _exp_file_path(self): + module = import_module(self._exp_file) + return module.__file__ + + +def get_experiment_default_params(func): + signature = inspect.signature(func) + defaults = {} + for k, v in signature.parameters.items(): + if v.default is not inspect.Parameter.empty: + defaults[k] = v.default + return defaults + + +def translate_experiment_params_to_argparse(parser, func): + annotation_to_argparse = { + 'str': str, + 'int': int, + 'float': float, + 'bool': bool, + 'list': None, + 'tuple': None, + } + arg_experiments = parser.add_argument_group('Experiment') + signature = inspect.signature(func) + for k, v in signature.parameters.items(): + if k not in ['seed', 'results_dir']: + if v.default is not inspect.Parameter.empty: + if v.annotation.__name__ in annotation_to_argparse: + if v.annotation.__name__ == 'bool': + arg_experiments.add_argument(f"--{str(k)}", type=lambda x: bool(strtobool(x)), + nargs='?', const=v.default, default=v.default) + elif v.annotation.__name__ == 'list': + arg_experiments.add_argument(f"--{str(k)}", nargs='+') + else: + arg_experiments.add_argument(f"--{str(k)}", type=annotation_to_argparse[v.annotation.__name__]) + else: + raise NotImplementedError(f'{v.annotation.__name__} not found in annotation_to_argparse.') + return parser + + +def add_launcher_base_args(parser): + arg_default = parser.add_argument_group('Default') + arg_default.add_argument('--seed', type=int) + arg_default.add_argument('--results_dir', type=str) + return parser + + +def has_kwargs(func): + signature = inspect.signature(func) + for k, v in signature.parameters.items(): + if v.kind == v.VAR_KEYWORD: + return True + + return False + + +def string_to_primitive(string): + try: + return int(string) + except ValueError: + try: + return float(string) + except ValueError: + try: + # boolean + return bool(strtobool(string)) + except ValueError: + return string + + +def parse_unknown_args(unknown): + kwargs = dict() + + key_idxs = [i for i, arg in enumerate(unknown) if arg.startswith('--')] + + if len(key_idxs) > 0: + key_n_args = [key_idxs[i+1] - 1 - key_idxs[i] for i in range(len(key_idxs)-1)] + key_n_args.append(len(unknown) - 1 - key_idxs[-1]) + + for i, idx in enumerate(key_idxs): + key = unknown[idx][2:] + n_args = key_n_args[i] + if n_args > 1: + values = list() + + for v in unknown[idx+1:idx + 1 + n_args]: + values.append(string_to_primitive(v)) + + kwargs[key] = values + + elif n_args == 1: + kwargs[key] = string_to_primitive(unknown[idx+1]) + + return kwargs + + +def parse_args(func): + parser = argparse.ArgumentParser() + + parser = translate_experiment_params_to_argparse(parser, func) + + parser = add_launcher_base_args(parser) + parser.set_defaults(**get_experiment_default_params(func)) + + if has_kwargs(func): + args, unknown = parser.parse_known_args() + kwargs = parse_unknown_args(unknown) + + args = vars(args) + args.update(kwargs) + + return args + else: + args = parser.parse_args() + return vars(args) + + +def run_experiment(func, args=None): + if not args: + args = parse_args(func) + + func(**args) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b875c8aa8e3e25e2c31af8e74906fe5e4e56aeab --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/experiment_launcher/utils.py @@ -0,0 +1,117 @@ +import datetime +import json +import os +import random +from random import randint +from shutil import which + +import git +import wandb +import yaml +from git import InvalidGitRepositoryError + + +def save_args(results_dir, args, git_repo_path=None, seed=None, save_args_as_yaml=False, print_exp_args=False): + try: + repo = git.Repo(git_repo_path, search_parent_directories=True) + args['git_hash'] = repo.head.object.hexsha + args['git_url'] = repo.remotes.origin.url + except InvalidGitRepositoryError: + args['git_hash'] = '' + args['git_url'] = '' + except ValueError: + args['git_hash'] = '' + args['git_url'] = '' + + if save_args_as_yaml: + filename = 'args.yaml' if seed is None else f'args-{seed}.yaml' + with open(os.path.join(results_dir, filename), 'w') as f: + yaml.dump(args, f, Dumper=yaml.Dumper) + else: + filename = 'args.json' if seed is None else f'args-{seed}.json' + with open(os.path.join(results_dir, filename), 'w') as f: + json.dump(args, f, indent=2) + + del args['git_hash'] + del args['git_url'] + + if print_exp_args: + print('------------------------------------------------------------------------------------') + print('--------> Experiment arguments') + print(json.dumps(args, indent=2)) + print('------------------------------------------------------------------------------------') + + +def is_local(): + return which('sbatch') is None + + +def start_wandb( + wandb_mode='disabled', + wandb_entity='experiment_launcher', + wandb_project='test_experiment_launcher', + wandb_group=None, + wandb_run_name=None, + **kwargs +): + # https://github.com/wandb/wandb/issues/3911#issuecomment-1409769887 + # workaround to make sure that wandb does not crash + os.environ["WANDB__SERVICE_WAIT"] = "600" + + modes = ['online', 'offline', 'disabled'] + assert wandb_mode in modes, f"wandb_mode must be in {modes}" + + if wandb_mode == 'disabled': + return wandb.init(mode="disabled", reinit=True) + + init = { + "mode": wandb_mode, + "entity": wandb_entity, + "project": wandb_project, + "group": wandb_group, + "name": wandb_run_name, + "reinit": True, + "notes": datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S'), + } + + return wandb.init(**init) + + +def fix_random_seed(seed): + random.seed(seed) + + try: + import numpy as np + import torch + except ImportError: + pass + + try: + np.random.seed(seed) + except NameError: + pass + + try: + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + # https://pytorch.org/docs/stable/notes/randomness.html#cuda-convolution-benchmarking + # torch.backends.cudnn.benchmark = False + except NameError: + pass + + +def random_int_with_n_digits(n): + range_start = 10 ** (n - 1) + range_end = (10 ** n) - 1 + return randint(range_start, range_end) + + +def create_results_dir(kwargs, make_dirs_with_seed=True): + seed = kwargs['seed'] + results_dir = kwargs['results_dir'] + assert results_dir is not None and seed is not None, "results_dir and seed must exist" + results_dir_new = results_dir + if make_dirs_with_seed: + results_dir_new = os.path.join(results_dir_new, str(seed)) + os.makedirs(results_dir_new, exist_ok=True) + kwargs['results_dir'] = results_dir_new diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..812d40970d18d83b7d67ed5e0a6a882441207b01 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/requirements.txt @@ -0,0 +1,5 @@ +joblib +gitpython +numpy +pyyaml +wandb diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..2807cd487f4fd29efa763a0b7033a5e694885ee7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/experiment_launcher/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup, find_packages +from codecs import open +from os import path + +from experiment_launcher import __version__ + +here = path.abspath(path.dirname(__file__)) + +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + +long_description = 'experiment_launcher is a Python library used to run experiments.' \ + ' Supports Joblib and slurm jobs.' + +setup( + name='experiment-launcher', + version=__version__, + description='A Python toolkit for launching experiments.', + long_description=long_description, + url='https://git.ias.informatik.tu-darmstadt.de/common/experiment_launcher', + author="Davide Tateo, Joao Carvalho", + author_email='davide@robot-learning.de, joao@robot-learning.de', + license='MIT', + packages=[package for package in find_packages() + if package.startswith('experiment_launcher')], + zip_safe=False, + install_requires=requires_list, + extras_require={}, + classifiers=["Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ] +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..15c5762c24a7258873ac7e30e9dfa372126ac7fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2024 Yorai Shaoul, slightly modified original code written by An Thai Le, Joao Carvalho. +Original license below: + +Copyright (c) 2023 An Thai Le, Joao Carvalho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/README.md b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6b8c0ceb606acb75b8301f964778d50d33cfa500 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/README.md @@ -0,0 +1,54 @@ +The original repository is this one: https://github.com/anindex/motion_planning_baselines. +Their original README follows. + +# Motion Planning Baselines in PyTorch + +This library implements various Motion Planning methods. + +**NOTE**: `mp_baslines` is under heavy development and highly experimental. + +## Installation + +Simply activate your conda/Python environment and run + +```bash +pip install -e . +``` + +## Examples + +```bash +python examples/pointmass_dense_2d_CHOMP.py +python examples/pointmass_dense_2d_GPMP.py +python examples/pointmass_grid_circles_2d_Stoch-GPMP.py +python examples/pointmass_grid_circles_2d_STOMP.py +python examples/pointmass_grid_circles_2d_MPPI.py +python examples/pointmass_dense_2d_RRT_multiprocess.py +``` + +## Contact + +If you have any questions or find any bugs, please let us know: + +- [An Le](https://www.ias.informatik.tu-darmstadt.de/Team/AnThaiLe), [an@robot-learning.de](an@robot-learning.de) +- [Joao Carvalho](https://www.ias.informatik.tu-darmstadt.de/Team/JoaoCarvalho), [joao@robot-learning.de](joao@robot-learning.de) + +## Citation + +If you found this repository useful, please consider citing these references: + +```bibtex +@inproceedings{le2023accelerating, + title={Accelerating Motion Planning via Optimal Transport}, + author={Le, An T. and Chalvatzaki, Georgia and Biess, Armin and Peters, Jan}, + booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, + year={2023} +} + +@article{carvalho2023motion, + title={Motion planning diffusion: Learning and planning of robot motions with diffusion models}, + author={Carvalho, Joao and Le, An T and Baierl, Mark and Koert, Dorothea and Peters, Jan}, + journal={arXiv preprint arXiv:2308.01557}, + year={2023} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8d7e0a0b0f6464196d6c4f43043ada67ef00426d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/base.py @@ -0,0 +1,226 @@ +import abc +from abc import ABC, abstractmethod +from typing import Tuple +import torch + +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from torch_robotics.trajectory.utils import finite_difference_vector + + +class MPPlanner(ABC): + """Base class for all planners.""" + + def __init__( + self, + name: str = None, + tensor_args: dict = None, + **kwargs + ): + self.name = name + self.tensor_args = tensor_args + self._kwargs = kwargs + + @abstractmethod + def optimize(self, opt_iters: int = 1, **observation) -> Tuple[bool, torch.Tensor]: + """Plan a path from start to goal. + + Args: + opt_iters: Number of optimization iters. + observation: dict of observations. + + Returns: + success: True if a path was found. + path: Path from start to goal. + """ + pass + + def __call__(self, opt_iters: int = 1, **observation) -> Tuple[bool, torch.Tensor]: + """Plan a path from start to goal. + + Args: + start: Start position. + goal: Goal position. + + Returns: + success: True if a path was found. + path: Path from start to goal. + """ + return self.optimize(opt_iters, **observation) + + def __repr__(self): + return f"{self.name}({self._kwargs})" + + @abc.abstractmethod + def render(self, ax, **kwargs): + raise NotImplementedError + + +class OptimizationPlanner(MPPlanner): + + def __init__( + self, + name: str = 'OptimizationPlanner', + n_dof: int = None, + n_support_points: int = None, + n_interpolated_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + cost=None, + initial_particle_means=None, + multi_goal_states: torch.Tensor = None, + sigma_start_init: float = 0.001, + sigma_goal_init: float = 0.001, + sigma_gp_init: float = 10., + pos_only: bool = False, + tensor_args: dict = None, **kwargs + ): + super().__init__(name, tensor_args, **kwargs) + self.n_dof = n_dof + self.dim = 2 * self.n_dof + self.n_support_points = n_support_points + self.n_interpolated_points = n_interpolated_points + self.num_particles_per_goal = num_particles_per_goal + self.opt_iters = opt_iters + self.dt = dt + self.pos_only = pos_only + + self.start_state = start_state + self.multi_goal_states = multi_goal_states + if multi_goal_states is None: # NOTE(an): if there is no goal, we assume xere is at least one solution + self.num_goals = 1 + else: + assert multi_goal_states.ndim == 2 + self.num_goals = multi_goal_states.shape[0] + self.num_particles = self.num_goals * self.num_particles_per_goal + self.cost = cost + self.initial_particle_means = initial_particle_means + self._particle_means = None + if self.pos_only: + self.d_state_opt = self.n_dof + self.start_state = self.start_state + else: + self.d_state_opt = 2 * self.n_dof + self.start_state = torch.cat([self.start_state, torch.zeros_like(self.start_state)], dim=-1) + if self.multi_goal_states is not None: + self.multi_goal_states = torch.cat([self.multi_goal_states, torch.zeros_like(self.multi_goal_states)], dim=-1) + + self.sigma_start_init = sigma_start_init + self.sigma_goal_init = sigma_goal_init + self.sigma_gp_init = sigma_gp_init + + def get_GP_prior( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + tensor_args=None, + ): + if tensor_args is None: + tensor_args = self.tensor_args + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + self.dim, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # NOTE(sasha) Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=tensor_args, + ) + + def const_vel_trajectories( + self, + start_state, + multi_goal_states, + ): + traj_dim = (multi_goal_states.shape[0], self.n_support_points, self.dim) + state_traj = torch.zeros(traj_dim, **self.tensor_args) + mean_vel = (multi_goal_states[:, :self.n_dof] - start_state[:, :self.n_dof]) / (self.n_support_points * self.dt) + for i in range(self.n_support_points): + state_traj[:, i, :self.n_dof] = start_state[:, :self.n_dof] * (self.n_support_points - i - 1) / (self.n_support_points - 1) \ + + multi_goal_states[:, :self.n_dof] * i / (self.n_support_points - 1) + state_traj[:, :, self.n_dof:] = mean_vel.unsqueeze(1).repeat(1, self.n_support_points, 1) + return state_traj + + def get_random_trajs(self): + # force torch.float64 + tensor_args = dict(dtype=torch.float64, device=self.tensor_args['device']) + # set zero velocity for GP prior + start_state = torch.cat((self.start_state, torch.zeros_like(self.start_state)), dim=-1).to(**tensor_args) + if self.multi_goal_states is not None: + multi_goal_states = torch.cat((self.multi_goal_states, torch.zeros_like(self.multi_goal_states)), dim=-1).to(**tensor_args) + else: + multi_goal_states = None + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.n_dof * 2, + self.sigma_start_init, + start_state, + tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + tensor_args, + ) + + self.multi_goal_prior_init = [] + if multi_goal_states is not None: + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.n_dof * 2, + self.sigma_goal_init, + multi_goal_states[i], + tensor_args, + ) + ) + self._traj_dist = self.get_GP_prior( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if multi_goal_states is not None else None, + start_state, + goal_states=multi_goal_states, + tensor_args=tensor_args, + ) + particles = self._traj_dist.sample(self.num_particles_per_goal).to(**tensor_args) + self.traj_dim = particles.shape + del self._traj_dist # free memory + return particles.flatten(0, 1).to(**self.tensor_args) + + def _get_traj(self): + """ + Get position-velocity trajectory from control distribution. + """ + trajs = self._particle_means.clone() + if self.pos_only: + # Linear velocity by central finite differencing + vels = finite_difference_vector(trajs, dt=self.dt) + trajs = torch.cat((trajs, vels), dim=1) + return trajs + + def get_traj(self): + return self._get_traj() + + def _get_costs(self, state_trajectories, **observation): + if self.cost is None: + costs = torch.zeros(self.num_particles, ) + else: + costs = self.cost(state_trajectories, **observation) + return costs + + def render(self, ax, **kwargs): + raise NotImplementedError diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/chomp.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/chomp.py new file mode 100644 index 0000000000000000000000000000000000000000..aec0bea39863ccd10e423ce10a7d8513db0dc72c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/chomp.py @@ -0,0 +1,169 @@ +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import batched_weighted_dot_prod + + +class CHOMP(OptimizationPlanner): + # https://www.ri.cmu.edu/pub_files/2009/5/icra09-chomp.pdf + + def __init__( + self, + n_dof: int, + n_support_points: int, + num_particles_per_goal: int, + opt_iters: int, + dt: float, + start_state: torch.Tensor, + cost=None, + weight_prior_cost=0.1, + initial_particle_means=None, + step_size: float = 1., + grad_clip: float = .01, + multi_goal_states: torch.Tensor = None, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=10., + pos_only: bool = True, + **kwargs + ): + super(CHOMP, self).__init__(name='CHOMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + cost=cost, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=pos_only, + **kwargs) + + # CHOMP params + self.lr = step_size + self.grad_clip = grad_clip + + self._particle_means = None + # Precision matrix, shape: [ctrl_dim, n_support_points, n_support_points] + self.Sigma_inv = self._get_R_mat(dt=self.dt, n_support_points=self.n_support_points, tensor_args=self.tensor_args) + self.Sigma = torch.inverse(self.Sigma_inv) + self.reset(initial_particle_means=initial_particle_means) + + # Weight prior + self.weight_prior_cost = weight_prior_cost + + def _get_R_mat2(self): + """ + STOMP time-correlated Precision matrix. + Central finite difference velocity. + """ + upper_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=1) + lower_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=-1,) + diag = -2 * torch.eye(self.n_support_points) + A_mat = upper_diag + diag + lower_diag + A_mat = torch.cat( + (torch.zeros(1, self.n_support_points), + A_mat, + torch.zeros(1, self.n_support_points)), + dim=0, + ) + A_mat[0, 0] = 1. + A_mat[-1, -1] = 1. + A_mat = A_mat * 1./self.dt**2 + R_mat = A_mat.t() @ A_mat + return R_mat.to(**self.tensor_args) + + @classmethod + def _get_R_mat( + cls, + dt=0.01, + n_support_points=64, + tensor_args=None, + **kwargs + ): + """ + CHOMP time-correlated Precision matrix. + Backward finite difference velocity. + """ + lower_diag = -torch.diag(torch.ones(n_support_points - 1), diagonal=-1) + diag = 1 * torch.eye(n_support_points) + K_mat = diag + lower_diag + K_mat = torch.cat((K_mat, torch.zeros(1, n_support_points)), dim=0) + K_mat[-1, -1] = -1. + K_mat = K_mat * 1. / dt ** 2 + R_mat = K_mat.t() @ K_mat + + return R_mat.to(**tensor_args) + + def reset( + self, + initial_particle_means=None, + ): + # Straight line position-trajectory from start to goal with const vel + if initial_particle_means is not None: + self._particle_means = initial_particle_means.clone() + else: + self._particle_means = self.get_random_trajs() + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + + self._run_optimization(opt_iters, **observation) + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + if opt_iters is None: + opt_iters = self.opt_iters + for opt_step in range(opt_iters): + self._particle_means.requires_grad_(True) + costs = self._eval(self._particle_means, **observation) + + # Get grad + costs.sum().backward(retain_graph=True) + # For stabilizing and preventing high gradients + self._particle_means.grad.data.clamp_(-self.grad_clip, self.grad_clip) + # zeroing grad at start and goal + self._particle_means.grad.data[..., 0, :] = 0. + self._particle_means.grad.data[..., -1, :] = 0. + + # Update trajectory + self._particle_means.data.add_(-self.lr * self._particle_means.grad.data) + self._particle_means.grad.detach_() + self._particle_means.grad.zero_() + + self._particle_means = self._particle_means.detach() + + def _eval(self, x, **observation): + """ + Evaluate costs. + """ + if x.ndim == 2: + x = x.unsqueeze(0) + + # Evaluate (collision, ...) costs + costs = self._get_costs(x, **observation) + + # Add smoothness term (prior) + R_mat = self.Sigma_inv + smooth_cost = batched_weighted_dot_prod(x, R_mat, x).sum() + + costs += self.weight_prior_cost * smooth_cost + + return costs diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..0277dd7126c217a0a90b1eac7a9ca4d3521b365a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py @@ -0,0 +1,745 @@ +from abc import ABC, abstractmethod + +import einops +import numpy as np +import torch +from typing import List, Optional + +from mp_baselines.planners.chomp import CHOMP +from mp_baselines.planners.costs.factors.field_factor import FieldFactor +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor +from torch_robotics.torch_planning_objectives.fields.distance_fields import interpolate_points_v1 +from torch_robotics.torch_utils.torch_utils import batched_weighted_dot_prod +from torch_robotics.trajectory.utils import finite_difference_vector + + +class Cost(ABC): + def __init__(self, robot, n_support_points, tensor_args=None, **kwargs): + self.robot = robot + self.n_dof = robot.q_dim + self.dim = 2 * self.n_dof # position + velocity + self.n_support_points = n_support_points + + self.tensor_args = tensor_args + + def set_cost_factors(self): + pass + + def __call__(self, trajs, **kwargs): + return self.eval(trajs, **kwargs) + + @abstractmethod + def eval(self, trajs, **kwargs): + pass + + @abstractmethod + def get_linear_system(self, trajs, **kwargs): + pass + + def get_q_pos_vel_and_fk_map(self, trajs, **kwargs): + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: + N, B, H, D = trajs.shape # n_goals (or steps), batch of trajectories, length, dim + trajs = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + + q_pos = self.robot.get_position(trajs) + q_vel = self.robot.get_velocity(trajs) + H_positions = self.robot.fk_map_collision( + q_pos) # I, taskspaces, x_dim+1, x_dim+1 (homogeneous transformation matrices) + return trajs, q_pos, q_vel, H_positions + + +class CostComposite(Cost): + + def __init__( + self, + robot, + n_support_points, + cost_list, + weights_cost_l=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.cost_l = cost_list + self.weight_cost_l = weights_cost_l if weights_cost_l is not None else [1.0] * len(cost_list) + + def eval(self, trajs, trajs_interpolated=None, return_invidual_costs_and_weights=False, **kwargs): + trajs, q_pos, q_vel, H_positions = self.get_q_pos_vel_and_fk_map(trajs) + + if not return_invidual_costs_and_weights: + cost_total = 0 + for cost, weight_cost in zip(self.cost_l, self.weight_cost_l): + if trajs_interpolated is not None: + # Compute only collision costs with interpolated trajectories. + # Other costs are computed with non-interpolated trajectories, e.g. smoothness + if isinstance(cost, CostCollision): + trajs_tmp = trajs_interpolated + else: + trajs_tmp = trajs + else: + trajs_tmp = trajs + cost_tmp = weight_cost * cost(trajs_tmp, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, **kwargs) + cost_total += cost_tmp + return cost_total + else: + cost_l = [] + for cost in self.cost_l: + if trajs_interpolated is not None: + # Compute only collision costs with interpolated trajectories. + # Other costs are computed with non-interpolated trajectories, e.g. smoothness + if isinstance(cost, CostCollision): + trajs_tmp = trajs_interpolated + else: + trajs_tmp = trajs + else: + trajs_tmp = trajs + + cost_tmp = cost(trajs_tmp, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, **kwargs) + cost_l.append(cost_tmp) + + if return_invidual_costs_and_weights: + return cost_l, self.weight_cost_l + + def get_linear_system(self, trajs, n_interpolated_points=None, **kwargs): + trajs.requires_grad = True + # TODO - join fk map into one call for trajs and trajs_interp + trajs, q_pos, q_vel, H_positions = self.get_q_pos_vel_and_fk_map(trajs) + + # Upsample trajectory for finer collision checking + # Interpolate in joint space + # TODO - change from linear interpolation to GP interpolation + if n_interpolated_points is None: + trajs_interp, q_pos_interp, q_vel_interp, H_positions_interp = None, None, None, None + else: + trajs_interp = interpolate_points_v1(trajs, n_interpolated_points) + trajs_interp, q_pos_interp, q_vel_interp, H_positions_interp = self.get_q_pos_vel_and_fk_map(trajs_interp) + + batch_size = trajs.shape[0] + As, bs, Ks = [], [], [] + optim_dim = 0 + for cost, weight_cost in zip(self.cost_l, self.weight_cost_l): + A, b, K = cost.get_linear_system( + trajs, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, + trajs_interp=trajs_interp, q_pos_interp=q_pos_interp, q_vel_interp=q_vel_interp, + H_positions_interp=H_positions_interp, + **kwargs) + if A is None or b is None or K is None: + continue + optim_dim += A.shape[1] + As.append(A.detach()) + bs.append(b.detach()) + Ks.append(K.detach()) + + A = torch.cat(As, dim=1) + b = torch.cat(bs, dim=1) + K = torch.zeros(batch_size, optim_dim, optim_dim, **self.tensor_args) + offset = 0 + for i in range(len(Ks)): + dim = Ks[i].shape[1] + K[:, offset:offset + dim, offset:offset + dim] = Ks[i] + offset += dim + return A, b, K + + +class CostCollision(Cost): + + def __init__( + self, + robot, + n_support_points, + field=None, + sigma_coll=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.obst_factor = None + self.field = field + self.sigma_coll = sigma_coll + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.obst_factor = FieldFactor( + self.n_dof, + self.sigma_coll, + # [45, 50] # Can consider also only a subset of the trajectory. + [1, None] # Take the whole trajectory except for the first point. + ) + + def eval(self, trajs, q_pos=None, q_vel=None, H_positions=None, **observation): + costs = 0 + if self.field is not None: + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + H_pos = H_positions + err_obst = self.obst_factor.get_error( + trajs, + self.field, + q_pos=q_pos, + q_vel=q_vel, + H_pos=H_pos, + calc_jacobian=False, # TODO: NOTE(an): no need for grads in StochGPMP + obstacle_spheres=observation.get('obstacle_spheres', None) + ) + w_mat = self.obst_factor.K + obst_costs = w_mat * err_obst.sum(1) + costs = obst_costs + + return costs + + def get_linear_system(self, trajs, q_pos=None, q_vel=None, H_positions=None, + trajs_interp=None, q_pos_interp=None, q_vel_interp=None, H_positions_interp=None, + **observation): + A, b, K = None, None, None + if self.field is not None: + batch_size = trajs.shape[0] + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + H_pos = H_positions + + # Get H_obst wrt to the interpolated trajectory. This is computed inside get_error + + err_obst, H_obst = self.obst_factor.get_error( + trajs, + self.field, + q_pos=q_pos, + q_vel=q_vel, + H_pos=H_pos, + trajs_interp=trajs_interp, + q_pos_interp=q_pos_interp, + q_vel_interp=q_vel_interp, + H_pos_interp=H_positions_interp, + calc_jacobian=True, + obstacle_spheres=observation.get('obstacle_spheres', None) + ) + + A = torch.zeros(batch_size, self.n_support_points - 1, self.dim * self.n_support_points, **self.tensor_args) + A[:, :, :H_obst.shape[-1]] = H_obst + # shift each row by self.dim + idxs = torch.arange(A.shape[-1], **self.tensor_args).repeat(A.shape[-2], 1) + idxs = (idxs - torch.arange(self.dim, (idxs.shape[0] + 1) * self.dim, self.dim, **self.tensor_args).view(-1, 1)) % idxs.shape[-1] + idxs = idxs.to(torch.int64) + A = torch.gather(A, -1, idxs.repeat(batch_size, 1, 1)) + + # old code not vectorized + # https://github.com/anindex/stoch_gpmp/blob/main/stoch_gpmp/costs/cost_functions.py#L275 + + b = err_obst.unsqueeze(-1) + K = self.obst_factor.K * torch.eye((self.n_support_points - 1), **self.tensor_args).repeat(batch_size, 1, 1) + + return A, b, K + + +# class CostConstraint(Cost): +# """ +# Currently implementing a "vertex constraint." +# Avoid this configuration at this time (interval). +# """ +# def __init__( +# self, +# robot, +# n_support_points, +# q: torch.Tensor, # Shape: (n_dof,) +# traj_range: tuple, +# radius: float, +# sigma=None, +# **kwargs +# ): +# super().__init__(robot, n_support_points, **kwargs) +# self.q = q.to(**self.tensor_args) +# self.traj_range = traj_range +# self.radius = radius +# self.sigma = sigma +# +# def eval(self, trajs, **observation): +# costs = 0 +# if self.q is not None: +# q_pos = self.robot.get_position(trajs) +# q_pos = q_pos[:, self.traj_range[0]:self.traj_range[1], :] +# dist_constraint = torch.norm(q_pos - self.q, dim=-1) +# mask = dist_constraint > self.radius +# dist_constraint = torch.where(mask, torch.zeros_like(dist_constraint), dist_constraint) +# costs = self.radius - dist_constraint +# costs = costs.sum(-1) +# return costs +# +# def get_linear_system(self, trajs, **kwargs): +# pass + + +class CostConstraint(Cost): + """ + Implements multiple "vertex constraints" in a fully vectorized manner. + Each constraint is associated with a specific configuration and time interval. + """ + + def __init__( + self, + robot, + n_support_points, + q_l: List[torch.Tensor], # Length n. + traj_range_l: List[tuple], # Length n. A tuple (start, end) for each constraint. Inclusive. + radius_l: List[float], # Length n. The radius for each constraint. + is_soft: bool = False, # Sigma stands for the standard deviation of the cost. + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.qs = torch.stack([q.to(**self.tensor_args) for q in q_l], dim=0) # (n, q_dim) + self.traj_ranges = torch.tensor(traj_range_l, **self.tensor_args) # (n, 2) + self.radii = torch.tensor(radius_l, device=self.qs.device) # (n,) + self.is_soft = is_soft + + def eval(self, trajs, **observation): + q_pos = self.robot.get_position(trajs) # (B, H, q_dim) + # Get the start and end indices for each range. + start_indices = self.traj_ranges[:, 0] # (n,) + end_indices = self.traj_ranges[:, 1] # (n,) + + # Prepare a mask to extract relevant positions in the trajectory for each constraint. + mask = torch.arange(q_pos.shape[1], device=q_pos.device).unsqueeze(0).unsqueeze(0) # (1, 1, H) + mask = (mask >= start_indices.view(-1, 1, 1)) & (mask < end_indices.view(-1, 1, 1)) # (n, 1, H) + + # Repeat and reshape q_pos to match the constraints dimensions. + q_pos = q_pos.unsqueeze(0).expand(self.qs.shape[0], -1, -1, -1) # (n, B, H, q_dim) + q_pos_masked = q_pos * mask.unsqueeze(-1) # (n, B, H, q_dim) + + # Compute the distance between each position in the range and the corresponding q. + dist_constraint = torch.norm(q_pos_masked - self.qs.view(-1, 1, 1, q_pos.shape[-1]), + dim=-1) # (n, B, H) + + # Apply the radius constraints. + mask_radius = dist_constraint > self.radii.view(-1, 1, 1) # (n, B, H) + dist_constraint = torch.where(mask_radius, torch.zeros_like(dist_constraint), + dist_constraint) # Only penalize inside the radius + + # Compute the cost. + costs = (self.radii.view(-1, 1, 1) - dist_constraint).sum(dim=-1) # (n, B) + + # Sum the costs across all constraints. + total_costs = costs.sum() # (1,) + + return total_costs + + def get_linear_system(self, trajs, **kwargs): + pass + + +class CostMaxVelocity(Cost): + def __init__( + self, + robot, + n_support_points, + max_vel=None, + sigma_max_vel=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.max_vel = max_vel + self.sigma_max_vel = sigma_max_vel + + def eval(self, trajs, **observation): + costs = 0 + q_pos_trajs = self.robot.get_position(trajs) + if self.max_vel is not None: + vel = finite_difference_vector(q_pos_trajs, dt=self.robot.dt, method='central') + cost = torch.pow(vel - self.max_vel, 2) + costs = cost.sum(-1) + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostVelocityAndDirectionAlignment(Cost): + def __init__( + self, + robot, + n_support_points, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + + def eval(self, trajs, **observation): + q_direction_trajs = self.robot.get_direction_vector(trajs) + q_spatial = self.robot.get_spatial_component(trajs) + q_spatial_vel = finite_difference_vector(q_spatial, dt=self.robot.dt, method='central') + q_spatial_vel = q_spatial_vel / torch.norm(q_spatial_vel, dim=-1, keepdim=True) + cost = torch.sum(q_direction_trajs * q_spatial_vel, dim=-1) # Just dot. + costs = cost.sum(-1) + return costs + + def get_linear_system(self, trajs, **observation): + batch_size = trajs.shape[0] + dim = trajs.shape[-1] + + # Initialize the linear system components A, b, and K. + A = torch.zeros(batch_size, dim * self.n_support_points, dim * self.n_support_points, **self.tensor_args) + b = torch.zeros(batch_size, dim * self.n_support_points, 1, **self.tensor_args) + K = torch.zeros(batch_size, dim * self.n_support_points, dim * self.n_support_points, **self.tensor_args) + + # Get direction and velocity trajectories + q_direction_trajs = self.robot.get_direction_vector(trajs) + q_spatial = self.robot.get_spatial_component(trajs) + q_spatial_vel = finite_difference_vector(q_spatial, dt=self.robot.dt, method='central') + q_spatial_vel = q_spatial_vel / torch.norm(q_spatial_vel, dim=-1, keepdim=True) + + # Calculate the error as the difference between direction and normalized velocity vectors + error = q_direction_trajs - q_spatial_vel + + # Now, populate A, b, and K matrices. + # A will represent the linearized relationship between the deviation and the state + # b will represent the error between the direction and velocity + # K will represent a weighting matrix for this cost (often identity or diagonal) + + # For simplicity, we assume a diagonal weighting matrix (you can adjust based on problem setup). + K_weight = torch.eye(dim, **self.tensor_args) + K_weight = K_weight.unsqueeze(0).repeat(batch_size, self.n_support_points, 1, 1) + + # Fill in the matrices with appropriate dimensions + for i in range(self.n_support_points): + idx_start = i * dim + idx_end = (i + 1) * dim + + # Populate A matrix for this support point (Jacobian of the error wrt the state) + A[:, idx_start:idx_end, idx_start:idx_end] = K_weight[:, i] + + # Populate b with the error at this support point + b[:, idx_start:idx_end] = error[:, i].unsqueeze(-1) + + # Populate K with the weighting matrix + K[:, idx_start:idx_end, idx_start:idx_end] = K_weight[:, i] + + return A, b, K + + +class CostGP(Cost): + + def __init__( + self, + robot, + n_support_points, + start_state, + dt, + sigma_params, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.start_state = start_state + self.dt = dt + + self.sigma_start = sigma_params['sigma_start'] + self.sigma_gp = sigma_params['sigma_gp'] + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.start_prior = UnaryFactor( + self.dim, + self.sigma_start, + self.start_state, + self.tensor_args, + ) + + self.gp_prior = GPFactor( + self.n_dof, + self.sigma_gp, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + def eval(self, trajs, **observation): + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + # Start cost + err_p = self.start_prior.get_error(trajs[:, [0]], calc_jacobian=False) + w_mat = self.start_prior.K + start_costs = err_p @ w_mat.unsqueeze(0) @ err_p.transpose(1, 2) + start_costs = start_costs.squeeze() + + # GP Trajectory cost + err_gp = self.gp_prior.get_error(trajs, calc_jacobian=False) + w_mat = self.gp_prior.Q_inv[0] # repeated Q_inv + w_mat = w_mat.reshape(1, 1, self.dim, self.dim) + gp_costs = err_gp.transpose(2, 3) @ w_mat @ err_gp + gp_costs = gp_costs.sum(1) + gp_costs = gp_costs.squeeze() + + costs = start_costs + gp_costs + + return costs + + def get_linear_system(self, trajs, **observation): + batch_size = trajs.shape[0] + A = torch.zeros(batch_size, self.dim * self.n_support_points, self.dim * self.n_support_points, + **self.tensor_args) + b = torch.zeros(batch_size, self.dim * self.n_support_points, 1, **self.tensor_args) + K = torch.zeros(batch_size, self.dim * self.n_support_points, self.dim * self.n_support_points, + **self.tensor_args) + + # Start prior factor + err_p, H_p = self.start_prior.get_error(trajs[:, [0]]) + A[:, :self.dim, :self.dim] = H_p + b[:, :self.dim] = err_p + K[:, :self.dim, :self.dim] = self.start_prior.K + + # GP factors + err_gp, H1_gp, H2_gp = self.gp_prior.get_error(trajs) + + A[:, self.dim:, :-self.dim] = torch.block_diag(*H1_gp) + A[:, self.dim:, self.dim:] += torch.block_diag(*H2_gp) + b[:, self.dim:] = einops.rearrange(err_gp, "b h d 1 -> b (h d) 1") + K[:, self.dim:, self.dim:] += torch.block_diag(*self.gp_prior.Q_inv) + + # old code not vectorized + # https://github.com/anindex/stoch_gpmp/blob/main/stoch_gpmp/costs/cost_functions.py#L161 + + return A, b, K + + +class CostGPTrajectory(Cost): + + def __init__( + self, + robot, + n_support_points, + dt, + sigma_gp=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.dt = dt + + self.sigma_gp = sigma_gp + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.gp_prior = GPFactor( + self.n_dof, + self.sigma_gp, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + def eval(self, trajs, **observation): + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + + # GP cost + err_gp = self.gp_prior.get_error(trajs, calc_jacobian=False) + w_mat = self.gp_prior.Q_inv[0] # repeated Q_inv + w_mat = w_mat.reshape(1, 1, self.dim, self.dim) + gp_costs = err_gp.transpose(2, 3) @ w_mat @ err_gp + gp_costs = gp_costs.sum(1).squeeze() + costs = gp_costs + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostGPTrajectoryPositionOnlyWrapper(CostGPTrajectory): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def eval(self, trajs, **observation): + vel = finite_difference_vector(trajs, dt=self.dt, method='central') + trajs_tmp = torch.cat((trajs, vel), dim=-1) + return super().eval(trajs_tmp, **observation) + + +class CostSmoothnessCHOMP(Cost): + + def __init__( + self, + robot, + n_support_points, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.dt = robot.dt + + self.Sigma_inv = CHOMP._get_R_mat(dt=self.dt, n_support_points=n_support_points, **kwargs) + + def eval(self, trajs, **observation): + R_mat = self.Sigma_inv + cost = batched_weighted_dot_prod(trajs, R_mat, trajs) + return cost + + def get_linear_system(self, trajs, **observation): + pass + + +class CostJointLimits(Cost): + + def __init__( + self, + robot, + n_support_points, + eps=np.deg2rad(3), + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + + self.eps = eps + + def eval(self, trajs, **observation): + assert trajs.ndim == 3 + + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + trajs_pos = self.robot.get_position(trajs) + + idxs_lower = torch.argwhere(trajs_pos < self.robot.q_min + self.eps) + cost_lower = torch.pow( + self.robot.q_min[idxs_lower[:, 2]] + self.eps - trajs_pos[ + idxs_lower[:, 0], idxs_lower[:, 1], idxs_lower[:, 2]], + 2 + ).sum(-1) + + idxs_upper = torch.argwhere(trajs_pos > self.robot.q_max - self.eps) + cost_upper = torch.pow( + self.robot.q_max[idxs_upper[:, 2]] - self.eps - trajs_pos[ + idxs_upper[:, 0], idxs_upper[:, 1], idxs_upper[:, 2]], + 2 + ).sum(-1) + + costs = (cost_lower + cost_upper) + + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostGoal(Cost): + + def __init__( + self, + robot, + n_support_points, + field=None, + sigma_goal=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.field = field + self.sigma_goal = sigma_goal + + self.set_cost_factors() + + def set_cost_factors(self): + #========= Cost factors =============== + self.goal_factor = FieldFactor( + self.n_dof, + self.sigma_goal, + [-1, None] # only take last point + ) + + def eval(self, trajs, x_trajs=None, **observation): + costs = 0 + if self.field is not None: + err_obst = self.goal_factor.get_error( + trajs, + self.field, + x_trajs=x_trajs, + calc_jacobian=False, # NOTE(an): no need for grads in StochGPMP + ) + w_mat = self.goal_factor.K + obst_costs = w_mat * err_obst.sum(1) + costs = obst_costs + + return costs + + def get_linear_system(self, trajs, x_trajs=None, **observation): + A, b, K = None, None, None + if self.field is not None: + batch_size = trajs.shape[0] + A = torch.zeros(batch_size, 1, self.dim * self.n_support_points, **self.tensor_args) + err_goal, H_goal = self.goal_factor.get_error( + trajs, + self.field, + x_trajs=x_trajs, + calc_jacobian=True, + ) + A[:, :, -self.dim:(-self.dim + self.n_dof)] = H_goal + b = err_goal.unsqueeze(-1) + K = self.goal_factor.K * torch.eye(1, **self.tensor_args).repeat(batch_size, 1, 1) + return A, b, K + + +class CostGoalPrior(Cost): + + def __init__( + self, + robot, + n_support_points, + multi_goal_states=None, # num_goal x n_dim (pos + vel) + num_particles_per_goal=None, + num_samples=None, + sigma_goal_prior=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.multi_goal_states = multi_goal_states + self.num_goals = multi_goal_states.shape[0] + self.num_particles_per_goal = num_particles_per_goal + self.num_particles = num_particles_per_goal * self.num_goals + self.num_samples = num_samples + self.sigma_goal_prior = sigma_goal_prior + + self.set_cost_factors() + + def set_cost_factors(self): + self.multi_goal_prior = [] + # TODO: remove this for loop + for i in range(self.num_goals): + self.multi_goal_prior.append( + UnaryFactor( + self.dim, + self.sigma_goal_prior, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def eval(self, trajs, **observation): + costs = 0 + if self.multi_goal_states is not None: + x = trajs.reshape(self.num_goals, self.num_particles_per_goal * self.num_samples, self.n_support_points, + self.dim) + costs = torch.zeros(self.num_goals, self.num_particles_per_goal * self.num_samples, **self.tensor_args) + # TODO: remove this for loop + for i in range(self.num_goals): + err_g = self.multi_goal_prior[i].get_error(x[i, :, [-1]], calc_jacobian=False) + w_mat = self.multi_goal_prior[i].K + goal_costs = err_g @ w_mat.unsqueeze(0) @ err_g.transpose(1, 2) + goal_costs = goal_costs.squeeze() + costs[i] += goal_costs + costs = costs.flatten() + return costs + + def get_linear_system(self, trajs, **observation): + A, b, K = None, None, None + if self.multi_goal_states is not None: + npg = self.num_particles_per_goal + batch_size = npg * self.num_goals + x = trajs.reshape(self.num_goals, self.num_particles_per_goal, self.n_support_points, self.dim) + A = torch.zeros(batch_size, self.dim, self.dim * self.n_support_points, **self.tensor_args) + b = torch.zeros(batch_size, self.dim, 1, **self.tensor_args) + K = torch.zeros(batch_size, self.dim, self.dim, **self.tensor_args) + # TODO: remove this for loop + for i in range(self.num_goals): + err_g, H_g = self.multi_goal_prior[i].get_error(x[i, :, [-1]]) + A[i * npg: (i + 1) * npg, :, -self.dim:] = H_g + b[i * npg: (i + 1) * npg] = err_g + K[i * npg: (i + 1) * npg] = self.multi_goal_prior[i].K + + return A, b, K diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..e3e23c9b2c871e659dd4f3ee97dd28f91e9ddadf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py @@ -0,0 +1,68 @@ +import torch + +""" +This file implements the FieldFactor class, which is used to compute the error of a trajectory with respect to a field. + +""" + +class FieldFactor: + + def __init__( + self, + n_dof, + sigma, + traj_range, + ): + self.sigma = sigma + self.n_dof = n_dof + self.traj_range = traj_range + self.K = 1. / (sigma**2) + + """ + Compute the error of the trajectory with respect to the field. The error is the negative log likelihood of the trajectory + """ + def get_error( + self, + q_trajs, + field, + q_pos=None, + q_vel=None, + H_pos=None, + q_trajs_interp=None, + q_pos_interp=None, + q_vel_interp=None, + H_pos_interp=None, + calc_jacobian=True, + **kwargs + ): + batch = q_trajs.shape[0] + + if H_pos is not None: + states = H_pos[:, self.traj_range[0]:self.traj_range[1]] + else: + states = q_trajs[:, self.traj_range[0]:self.traj_range[1], :self.n_dof].reshape(-1, self.n_dof) + q_pos_new = q_pos[:, self.traj_range[0]:self.traj_range[1], :] + length = q_pos_new.shape[-2] + error = field.compute_cost(q_pos_new, states, **kwargs).reshape(batch, length) + # Clip out all errors that are smaller than -0.02. + error = torch.clamp(error, min=-0.02) + + if calc_jacobian: + # compute jacobian wrt to the error of the interpolated trajectory + error_interp = error + if H_pos_interp is not None or q_trajs_interp is not None: + # interpolated trajectory + if H_pos_interp is not None: + states = H_pos_interp[:, self.traj_range[0]:self.traj_range[1]] + else: + states = q_trajs_interp[:, self.traj_range[0]:self.traj_range[1], :self.n_dof].reshape(-1, self.n_dof) + q_pos_new = q_pos_interp[:, self.traj_range[0]:self.traj_range[1], :] + length = q_pos_new.shape[-2] + error_interp = field.compute_cost(q_pos_new, states, **kwargs).reshape(batch, length) + + H = -torch.autograd.grad(error_interp.sum(), q_trajs, retain_graph=True)[0][:, self.traj_range[0]:self.traj_range[1], :self.n_dof] + error = error.detach() + field.zero_grad() + return error, H + else: + return error diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..a47cd1b6fd6b6d09d72427bd418ce434d3bacb15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py @@ -0,0 +1,65 @@ +import torch + + +class GPFactor: + + def __init__( + self, + dim, + sigma, + d_t, + num_factors, + tensor_args=None, + Q_c_inv=None, + ): + self.dim = dim + self.d_t = d_t + self.tensor_args = tensor_args + self.state_dim = self.dim * 2 # position and velocity + self.num_factors = num_factors + self.idx1 = torch.arange(0, self.num_factors, device=tensor_args['device']) + self.idx2 = torch.arange(1, self.num_factors+1, device=tensor_args['device']) + self.phi = self.calc_phi() + if Q_c_inv is None: + Q_c_inv = torch.eye(dim, **tensor_args) / sigma**2 + self.Q_c_inv = torch.zeros(num_factors, dim, dim, **tensor_args) + Q_c_inv + self.Q_inv = self.calc_Q_inv() # shape: [num_factors, state_dim, state_dim] + + ## Pre-compute constant Jacobians + self.H1 = self.phi.unsqueeze(0).repeat(self.num_factors, 1, 1) + self.H2 = -1. * torch.eye(self.state_dim, **self.tensor_args).unsqueeze(0).repeat( + self.num_factors, 1, 1, + ) + + def calc_phi(self): + I = torch.eye(self.dim, **self.tensor_args) + Z = torch.zeros(self.dim, self.dim, **self.tensor_args) + phi_u = torch.cat((I, self.d_t * I), dim=1) + phi_l = torch.cat((Z, I), dim=1) + phi = torch.cat((phi_u, phi_l), dim=0) + return phi + + def calc_Q_inv(self): + m1 = 12. * (self.d_t ** -3.) * self.Q_c_inv + m2 = -6. * (self.d_t ** -2.) * self.Q_c_inv + m3 = 4. * (self.d_t ** -1.) * self.Q_c_inv + + Q_inv_u = torch.cat((m1, m2), dim=-1) + Q_inv_l = torch.cat((m2, m3), dim=-1) + Q_inv = torch.cat((Q_inv_u, Q_inv_l), dim=-2) + return Q_inv + + def get_error(self, x_traj, calc_jacobian=True): + batch, horizon = x_traj.shape[0], x_traj.shape[1] + state_1 = torch.index_select(x_traj, 1, self.idx1).unsqueeze(-1) + state_2 = torch.index_select(x_traj, 1, self.idx2).unsqueeze(-1) + error = state_2 - self.phi @ state_1 + + if calc_jacobian: + H1 = self.H1 + H2 = self.H2 + # H1 = self.H1.unsqueeze(0).repeat(batch, 1, 1, 1) + # H2 = self.H2.unsqueeze(0).repeat(batch, 1, 1, 1) + return error, H1, H2 + else: + return error diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py new file mode 100644 index 0000000000000000000000000000000000000000..e57804bac8d5740e0bca4df71b8688a86b546a38 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py @@ -0,0 +1,259 @@ +""" +Multi-Modal Gaussian trajectory prior. +Original source: https://github.com/sashalambert/mpc_trajopt +""" + +__author__ = "Alexander Lambert" +__license__ = "MIT" + +from copy import copy + +import torch +import torch.distributions as dist + + +class MultiMPPrior: + + def __init__( + self, + num_steps, + dt, + state_dim, + dof, + K_s_inv, + K_gp_inv, + start_state, + means=None, + K_g_inv=None, + goal_states=None, + use_numpy=False, + tensor_args=None, + ): + """ + Motion-Planning prior. + + reference: "Continuous-time Gaussian process motion planning via + probabilistic inference", Mukadam et al. (IJRR 2018) + + Parameters + ---------- + num_steps : int + Planning horizon length (not including start state). + dt : float + Time-step size. + state_dim : int + State state_dimension. + K_s_inv : Tensor + Start-state inverse covariance. Shape: [state_dim, state_dim] + K_gp_inv : Tensor + Gaussian-process single-step inverse covariance i.e. 'Q_inv'. + Assumed constant, meaning homoscedastic noise with constant step-size. + Shape: [2 * state_dim, 2 * state_dim] + start_state : Tensor + Shape: [state_dim] + (Optional) K_g_inv : Tensor + Goal-state inverse covariance. Shape: [state_dim, state_dim] + (Optional) goal_state : Tensor + Shape: [state_dim] + (Optional) dof : int + Degrees of freedom. + """ + + self.state_dim = state_dim + self.dof = dof + self.num_steps = num_steps + self.M = state_dim * (num_steps + 1) + # self.num_steps = num_steps - 1 + # self.M = state_dim * num_steps + self.tensor_args = tensor_args + self.use_numpy = use_numpy + + self.goal_directed = (goal_states is not None) + + if means is None: + self.num_modes = goal_states.shape[0] if self.goal_directed else 1 + means = self.get_const_vel_mean( + start_state, + goal_states, + dt, + num_steps, + dof) + else: + self.num_modes = means.shape[0] + + # Flatten mean trajectories + self.means = means.reshape(self.num_modes, -1) + + # TODO - Add different goal Covariances + # Assume same goal Cov. for now + Sigma_inv = self.get_const_vel_covariance( + dt, + K_s_inv, + K_gp_inv, + K_g_inv, + ) + + self.Sigma_inv = Sigma_inv + self.Sigma_invs = self.Sigma_inv.repeat(self.num_modes, 1, 1) + self.update_dist(self.means, self.Sigma_invs) + + def update_dist( + self, + means, + Sigma_invs, + ): + # Create Multi-variate Normal Distribution + self.dist = dist.MultivariateNormal( + means, + precision_matrix=Sigma_invs, + # covariance_matrix=torch.inverse(Sigma_invs), + ) + + def get_mean(self, reshape=True): + if reshape: + return self.means.clone().detach().reshape( + self.num_modes, self.num_steps + 1, self.state_dim, + ) + else: + self.means.clone().detach() + + def set_mean(self, means_new): + assert means_new.shape == self.means.shape + self.means = means_new.clone().detach() + self.update_dist(self.means, self.Sigma_invs) + + def set_Sigma_invs(self, Sigma_invs_new): + assert Sigma_invs_new.shape == self.Sigma_invs.shape + self.Sigma_invs = Sigma_invs_new.clone().detach() + self.update_dist(self.means, self.Sigma_invs) + + @classmethod + def const_vel_trajectory( + cls, + start_state, + goal_state, + dt, + num_steps, + dof, + set_initial_final_vel_to_zero=True, + tensor_args=None + ): + state_traj = torch.zeros(num_steps + 1, 2 * dof, **tensor_args) + mean_vel = (goal_state[:dof] - start_state[:dof]) / (num_steps * dt) + for i in range(num_steps + 1): + state_traj[i, :dof] = start_state[:dof] * (num_steps - i) * 1. / num_steps \ + + goal_state[:dof] * i * 1./num_steps + if set_initial_final_vel_to_zero: + # initial and final velocities are set to zero + state_traj[1:-1, dof:] = mean_vel.unsqueeze(0) + else: + state_traj[:, dof:] = mean_vel.unsqueeze(0) + return state_traj + + def get_const_vel_mean( + self, + start_state, + goal_states, + dt, + num_steps, + dof, + ): + + # Make mean goal-directed if goal_state is provided. + if self.goal_directed: + means = [] + for i in range(self.num_modes): + means.append(self.const_vel_trajectory( + start_state, + goal_states[i], + dt, + num_steps, + dof, + tensor_args=self.tensor_args + )) + return torch.stack(means, dim=0) + else: + return start_state.repeat(num_steps + 1, 1) + + # def get_const_vel_covariance( + # self, + # dt, + # K_s_inv, + # K_gp_inv, + # K_g_inv, + # precision_matrix=True, + # ): + # # Transition matrix + # Phi = torch.eye(self.state_dim, **self.tensor_args) + # Phi[:self.dof, self.dof:] = torch.eye(self.dof, **self.tensor_args) * dt + # diag_Phis = Phi + # for _ in range(self.num_steps - 1): + # diag_Phis = torch.block_diag(diag_Phis, Phi) + # + # A = torch.eye(self.M, **self.tensor_args) + # A[self.state_dim:, :-self.state_dim] += -1. * diag_Phis + # if self.goal_directed: + # b = torch.zeros(self.state_dim, self.M, **self.tensor_args) + # b[:, -self.state_dim:] = torch.eye(self.state_dim, **self.tensor_args) + # A = torch.cat((A, b)) + # + # Q_inv = K_s_inv + # for _ in range(self.num_steps): + # Q_inv = torch.block_diag(Q_inv, K_gp_inv).to(**self.tensor_args) + # if self.goal_directed: + # Q_inv = torch.block_diag(Q_inv, K_g_inv).to(**self.tensor_args) + # + # K_inv = A.t() @ Q_inv @ A + # if precision_matrix: + # return K_inv + # else: + # return torch.inverse(K_inv) + + + def get_const_vel_covariance( + self, + dt, + K_s_inv, + K_gp_inv, + K_g_inv, + precision_matrix=True, + ): + # To construct the covariance we need to use float64, because the small covariances of the start, GP and goal + # state distributions lead to very large inverse covariances during the construction of these matrices. + + tensor_args_tmp = copy(self.tensor_args) + tensor_args_tmp['dtype'] = torch.float64 + + # Transition matrix + Phi = torch.eye(self.state_dim, **tensor_args_tmp) + Phi[:self.dof, self.dof:] = torch.eye(self.dof, **tensor_args_tmp) * dt + diag_Phis = Phi + for _ in range(self.num_steps - 1): + diag_Phis = torch.block_diag(diag_Phis, Phi) + + A = torch.eye(self.M, **tensor_args_tmp) + A[self.state_dim:, :-self.state_dim] += -1. * diag_Phis + if self.goal_directed: + b = torch.zeros(self.state_dim, self.M, **tensor_args_tmp) + b[:, -self.state_dim:] = torch.eye(self.state_dim, **tensor_args_tmp) + A = torch.cat((A, b)) + + Q_inv = K_s_inv + for _ in range(self.num_steps): + Q_inv = torch.block_diag(Q_inv, K_gp_inv).to(**tensor_args_tmp) + if self.goal_directed: + Q_inv = torch.block_diag(Q_inv, K_g_inv).to(**tensor_args_tmp) + + K_inv = A.t() @ Q_inv @ A + if precision_matrix: + return K_inv.to(**self.tensor_args) + else: + return torch.inverse(K_inv).to(**self.tensor_args) + + def sample(self, num_samples): + return self.dist.sample((num_samples,)).view( + num_samples, self.num_modes, self.num_steps + 1, self.state_dim, + ).transpose(1, 0) + + def log_prob(self, x): + return self.dist.log_prob(x) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..2dd3ca66c6e63e4efa819f3fc240815bdf1ed099 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py @@ -0,0 +1,34 @@ +import torch +""" +A unary factor is a factor that depends on a single variable. +""" + +class UnaryFactor: + + def __init__( + self, + dim, + sigma, + mean=None, + tensor_args=None, + ): + self.sigma = sigma + if mean is None: + self.mean = torch.zeros(dim, **tensor_args) + else: + self.mean = mean + self.tensor_args = tensor_args + self.K = torch.eye(dim, **tensor_args) / sigma**2 # weight matrix + self.dim = dim + + def get_error(self, x, calc_jacobian=True): + error = self.mean - x + + if calc_jacobian: + H = torch.eye(self.dim, **self.tensor_args).unsqueeze(0).repeat(x.shape[0], 1, 1) + return error.view(x.shape[0], self.dim, 1), H + else: + return error + + def set_mean(self, x): + self.mean = x.clone().detach() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/dynamics/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/dynamics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py new file mode 100644 index 0000000000000000000000000000000000000000..b4c0520981b6a81c720d686d036fa1594f82665b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py @@ -0,0 +1,226 @@ +import numpy as np +import torch + + +class PointParticleDynamics: + def __init__( + self, + rollout_steps=None, + control_dim=2, + state_dim=2, + dt=0.01, + discount=1.0, + deterministic=True, + start_state=None, + goal_state=None, + ctrl_min: list = None, + ctrl_max: list = None, + control_type='velocity', + dyn_std=np.zeros(4,), + c_weights=None, + verbose=False, + tensor_args=None, + ): + if tensor_args is None: + tensor_args = {'device': torch.device('cpu'), 'dtype': torch.float32} + self.tensor_args = tensor_args + + self.control_dim = control_dim + if control_type == 'velocity': + self.state_dim = state_dim + elif control_type == 'acceleration': + self.state_dim = state_dim * 2 + else: + raise IOError('control_type "{}" not recognized'.format(control_type)) + if c_weights is None: + self._c_weights = { + 'pos': 10., + 'vel': 10., + 'ctrl': 0., + 'pos_T': 10., + 'vel_T': 0., + } + else: + self._c_weights = c_weights + + assert len(ctrl_min) == self.control_dim + assert len(ctrl_max) == self.control_dim + self.ctrl_min = torch.tensor(ctrl_min).to(**tensor_args) + self.ctrl_max = torch.tensor(ctrl_max).to(**tensor_args) + + self.discount_seq = self._get_discount_seq(discount, rollout_steps) + + if start_state is not None: + self.start_state = torch.from_numpy(start_state).to(**self.tensor_args) + else: + self.start_state = torch.zeros(self.state_dim, **self.tensor_args) + self.state = self.start_state.clone() + if goal_state is not None: + if isinstance(goal_state, np.ndarray): + self.goal_state = torch.from_numpy(goal_state).to(**self.tensor_args) + elif isinstance(goal_state, torch.Tensor): + self.goal_state = goal_state + else: + raise IOError + else: + self.goal_state = torch.zeros(self.state_dim, **self.tensor_args) + self.rollout_steps = rollout_steps + self.dt = dt + self.dyn_std = torch.from_numpy(dyn_std).to(**self.tensor_args) + self.control_type = control_type + self.discount = discount + self.verbose = verbose + self.deterministic = deterministic + + @property + def state(self): + return self._state.clone().detach() + + @state.setter + def state(self, value): + self._state = value + + def reset(self): + self.state = self.start_state.clone() + cost, _ = self.traj_cost( + self.state.reshape(1, 1, 1, -1), + torch.zeros(1, 1, 1, self.control_dim), + ) + return self.state, cost + + def step(self, action): + state = self.state.reshape(1, 1, -1) + action = action.reshape(1, 1, -1) + self.state = self.dynamics(state, action) + + state = state.reshape(1, 1, 1, -1) + action = action.reshape(1, 1, 1, -1) + cost, _ = self.traj_cost(state, action) + + return self.state.squeeze(), cost.squeeze() + + def dynamics( + self, + x, + u_s, + use_crn=False, + ): + num_ctrl_samples, num_state_samples = x.size(0), x.size(1) + ctrl_dim = u_s.size(-1) + # clamp controls + u = u_s.clamp(min=self.ctrl_min, max=self.ctrl_max) + + if self.deterministic: + xdot = torch.cat( + (x[..., self.state_dim:], u), + dim=2, + ) + else: + # Noise in control channel + if use_crn: + noise = self.dyn_std * torch.randn( + num_state_samples, + ctrl_dim, + **self.tensor_args, + ) + else: + noise = self.dyn_std * torch.randn( + num_ctrl_samples, + num_state_samples, + ctrl_dim, + **self.tensor_args, + ) + + u_s = u + noise + xdot = torch.cat( + (x[..., self.state_dim:], u_s), + dim=2, + ) + x_next = x + xdot * self.dt + return x_next + + def render(self, state=None, mode='human'): + pass + + def _get_discount_seq(self, discount, rollout_steps): + # Discount factors + discount_seq = torch.cumprod( + torch.ones(rollout_steps, **self.tensor_args) * discount, + dim=0, + ) + discount_seq /= discount # start with weight one + return discount_seq + + def traj_cost( + self, + X_in, U_in, + **observation + ): + """ + Implements quadratic trajectory cost. + Args + ---- + X_in : Tensor + State trajectories, of shape + [steps+1, num_ctrl_samples, num_state_samples, state dim] + U_in : Tensor + Control trajectories, of shape + [steps+1, num_ctrl_samples, num_state_samples, control dim] + Returns + ------- + cost : Tensor + Trajectory costs, of shape [num_ctrl_samples, num_state_samples] + """ + + rollout_steps, num_ctrl_samples, num_state_samples, state_dim = X_in.shape + + batch_size = num_ctrl_samples * num_state_samples + # New shape: X: [batch, steps+1, particles, state_dim] + X = X_in.view( + rollout_steps, + batch_size, + state_dim, + ).transpose(0, 1) + + U = U_in.view( + rollout_steps, + batch_size, + self.control_dim, + ).transpose(0, 1) + + goal_state = observation.get('goal_state', self.goal_state) + cost = observation.get('cost', None) + + if cost is not None: + full_traj = torch.cat((X, U), dim=-1) + energy_cost = cost.eval(full_traj).sum(-1) # sum over the horizon + else: + energy_cost = 0. + + # Distance to goal + dX = X - goal_state[..., :self.state_dim] + dX_final = dX[:, -1, :] + + # Discounted Costs + discount_seq = self.discount_seq.unsqueeze(0) + pos_cost = (torch.square(dX[..., :self.state_dim]) * self._c_weights['pos']).sum(-1) * discount_seq + vel_cost = (torch.square(dX[..., self.state_dim:self.control_dim]) * self._c_weights['vel']).sum(-1) * discount_seq + control_cost = (torch.square(U) * self._c_weights['ctrl']).sum(-1) * discount_seq + terminal_cost = (torch.square(dX_final) * self._c_weights['pos_T']).sum(-1) * discount_seq[..., -1] + + # Sum along trajectory timesteps + pos_cost = pos_cost.sum(1) + vel_cost = vel_cost.sum(1) + control_cost = control_cost.sum(1) + + if self.verbose: + print('pos_cost: {:5.4f}'.format(pos_cost.mean().detach().cpu().numpy())) + print('vel_cost: {:5.4f}'.format(vel_cost.mean().detach().cpu().numpy())) + print('control_cost: {:5.4f}'.format(control_cost.mean().detach().cpu().numpy())) + print('terminal_cost: {:5.4f}'.format(terminal_cost.mean().detach().cpu().numpy())) + if cost is not None: + print('energy_cost: {:5.4f}'.format(energy_cost.mean().detach().cpu().numpy())) + print('') + + costs = pos_cost + vel_cost + control_cost + terminal_cost + energy_cost + return costs.view(num_ctrl_samples, num_state_samples) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py new file mode 100644 index 0000000000000000000000000000000000000000..8bd86ca933ca7f8a46fb7df83548d920e3e45e29 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py @@ -0,0 +1,510 @@ +# Adapted from https://github.com/anindex/stoch_gpmp +from copy import copy + +import einops +import numpy as np +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from mp_baselines.planners.costs.cost_functions import CostGP, CostGoalPrior, CostCollision, CostComposite +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor + +try: + import cholespy +except ModuleNotFoundError: + pass + +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +def build_gpmp2_cost_composite( + robot=None, + n_support_points=None, + dt=None, + start_state=None, + multi_goal_states=None, + num_particles_per_goal=None, + collision_fields=None, + extra_costs=[], + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_coll=1e-5, + sigma_goal_prior=1e-5, + num_samples: int = 64, + tensor_args=None, + **kwargs, +): + """ + Construct cost composite function for GPMP and StochGPMP + """ + cost_func_list = [] + + # Start state + GP cost + cost_sigmas = dict( + sigma_start=sigma_start, + sigma_gp=sigma_gp, + ) + start_state_zero_vel = torch.cat((start_state, torch.zeros(start_state.nelement(), **tensor_args))) + cost_gp_prior = CostGP( + robot, n_support_points, start_state_zero_vel, dt, + cost_sigmas, + tensor_args=tensor_args + ) + cost_func_list.append(cost_gp_prior) + + # Goal state cost + if multi_goal_states is not None: + multi_goal_states_zero_vel = torch.cat((multi_goal_states, torch.zeros_like(multi_goal_states)), + dim=-1).unsqueeze(0) # add batch dim for interface + cost_goal_prior = CostGoalPrior( + robot, n_support_points, multi_goal_states=multi_goal_states_zero_vel, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + sigma_goal_prior=sigma_goal_prior, + tensor_args=tensor_args + ) + cost_func_list.append(cost_goal_prior) + + # Collision costs + for collision_field in collision_fields: + cost_collision = CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=sigma_coll, + tensor_args=tensor_args + ) + cost_func_list.append(cost_collision) + + # Other costs + if extra_costs: + cost_func_list.append(*extra_costs) + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + tensor_args=tensor_args + ) + return cost_composite + + +class GPMP2(OptimizationPlanner): + + def __init__( + self, + robot=None, + n_dof: int = None, + n_support_points: int = None, + n_interpolated_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + step_size=1., + multi_goal_states=None, + initial_particle_means=None, + sigma_start_init=None, + sigma_start_sample=None, + sigma_goal_init=None, + sigma_goal_sample=None, + sigma_gp_init=None, + solver_params=None, + stop_criteria=None, # or None + **kwargs + ): + super(GPMP2, self).__init__( + name='GPMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=False, + robot=None, + **kwargs + ) + + self.d_state_opt = 2 * self.n_dof + + self.goal_directed = (multi_goal_states is not None) + if not self.goal_directed: + self.num_goals = 1 + else: + assert multi_goal_states.dim() == 2 + self.num_goals = multi_goal_states.shape[0] + + self.step_size = step_size + self.sigma_start_sample = sigma_start_sample + self.sigma_goal_sample = sigma_goal_sample + + self.solver_params = solver_params + + self.N = self.d_state_opt * self.n_support_points + + self._mean = None + self._weights = None + self._dist = None + + self.stop_criteria = stop_criteria + + self.robot = robot + + ############################################## + # Construct cost function + self.cost = build_gpmp2_cost_composite( + robot=robot, + n_support_points=n_support_points, + dt=dt, + start_state=start_state, + multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + **kwargs + ) + + ############################################## + # Initialize particles + self.reset(initial_particle_means=initial_particle_means) + + def reset( + self, + start_state=None, + multi_goal_states=None, + initial_particle_means=None, + ): + if start_state is not None: + self.start_state = start_state.clone() + + if multi_goal_states is not None: + self.multi_goal_states = multi_goal_states.clone() + + self.set_prior_factors() + + # Initialization particles from prior distribution + if initial_particle_means is not None: + self._particle_means = initial_particle_means + else: + self._init_dist = self.get_dist( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if self.goal_directed else None, + self.start_state, + goal_states=self.multi_goal_states, + ) + self._particle_means = self._init_dist.sample(self.num_particles_per_goal).to(**self.tensor_args) + del self._init_dist # freeing memory + self._particle_means = self._particle_means.flatten(0, 1) + + def set_prior_factors(self): + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.d_state_opt, + self.sigma_start_init, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + if self.goal_directed: + self.multi_goal_prior_init = [] + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_init, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + #========= Sampling factors =============== + self.start_prior_sample = UnaryFactor( + self.d_state_opt, + self.sigma_start_sample, + self.start_state, + self.tensor_args, + ) + + if self.goal_directed: + self.multi_goal_prior_sample = [] + for i in range(self.num_goals): + self.multi_goal_prior_sample.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_sample, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def get_dist( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + ): + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + 2 * self.n_dof, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=self.tensor_args + ) + + def optimize( + self, + opt_iters=None, + debug=False, + **observation + ): + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + b, K = self._step(debug=debug, **observation) + + # stop criteria + if self.stop_criteria is not None: + costs = self._get_costs(b, K) + if opt_step == 0: + costs_previous = costs.clone() + continue + if torch.all(torch.abs((costs - costs_previous)/costs) < self.stop_criteria): + break + costs_previous = costs.clone() + + self.costs = self._get_costs(b, K) + + position_seq_mean = self._particle_means[..., :self.n_dof].clone() + velocity_seq_mean = self._particle_means[..., -self.n_dof:].clone() + # costs = self.costs.clone() + + self._recent_state_trajectories = position_seq_mean + self._recent_control_particles = velocity_seq_mean + + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + def _step(self, debug=False, **observation): + with TimerCUDA() as t_grad: + A, b, K = self.cost.get_linear_system( + self._particle_means, + n_interpolated_points=self.n_interpolated_points, + **observation) + # if debug: + # print(f't_grad {t_grad}') + + J_t_J, g = self._get_grad_terms( + A, b, K, + delta=self.solver_params['delta'], + trust_region=self.solver_params.get('trust_region', False), + sparse_computation=self.solver_params.get('sparse_computation', False), + sparse_computation_block_diag=self.solver_params.get('sparse_computation_block_diag', False) + ) + + with TimerCUDA() as t_solve: + d_theta = self.get_torch_solve( + J_t_J, g, + method=self.solver_params['method'], + ) + # if debug: + # print(f't_solve {t_solve}') + + d_theta = d_theta.view( + self.num_particles, + self.n_support_points, + self.d_state_opt, + ) + + self._particle_means = self._particle_means + self.step_size * d_theta + self._particle_means.detach_() + + return b, K + + def _get_grad_terms( + self, + A, b, K, + delta=0., + trust_region=False, + sparse_computation=False, + sparse_computation_block_diag=False + ): + # Levenberg - Marquardt approximation + + B, M, N = A.shape + if not sparse_computation: + # Original implementation with dense matrices + I = torch.eye(self.N, self.N, device=self.tensor_args['device'], dtype=A.dtype) + A_t_K = A.transpose(-2, -1) @ K + A_t_A = A_t_K @ A + + if not trust_region: + J_t_J = A_t_A + delta * I + else: + # J_t_J = A_t_A + delta * I * torch.diagonal(A_t_A, dim1=-2, dim2=-1).unsqueeze(-1) + # Since hessian will be averaged over particles, add diagonal matrix of the mean. + diag_A_t_A = A_t_A.mean(0) * I + J_t_J = A_t_A + delta * diag_A_t_A + g = A_t_K @ b + else: + ################################################ + # Sparse matrix with loop over batch dimension + if not sparse_computation_block_diag: + I_sparse = torch.sparse_coo_tensor( + torch.arange(0, self.N).repeat(2, 1), + torch.ones(self.N), + (self.N, self.N), + device=self.tensor_args['device'] + ) + + J_t_J_sparse_l = [] + g_sparse_l = [] + for A_, K_, b_ in zip(A, K, b): + A_t_K_sparse = torch.sparse.mm(A_.transpose(-2, -1).to_sparse(), K_.to_sparse()) + A_t_A_sparse = torch.sparse.mm(A_t_K_sparse, A_.to_sparse()) + if not trust_region: + J_t_J_sparse = A_t_A_sparse + delta * I_sparse + else: + diag_A_t_A_sparse = A_t_A_sparse.values().mean() * I_sparse + J_t_J_sparse = A_t_A_sparse + delta * diag_A_t_A_sparse + + J_t_J_sparse_l.append(J_t_J_sparse) + g_sparse = torch.sparse.mm(A_t_K_sparse, b_.to_sparse()) + g_sparse_l.append(g_sparse) + + J_t_J = torch.stack([x.to_dense() for x in J_t_J_sparse_l]) + g = torch.stack([x.to_dense() for x in g_sparse_l]) + else: + ################################################ + # Sparse matrix with block diagonal, then extract block diagonals + # TODO - ATTENTION! + # This implementation takes a lot of memory because of the block diagonal construction. + I_sparse = torch.sparse_coo_tensor( + torch.arange(0, B * self.N).repeat(2, 1), + torch.ones(B * self.N), + (B * self.N, B * self.N), + device=self.tensor_args['device'] + ) + A_block_diag_sparse = torch.block_diag(*A).to_sparse() + A_t_block_diag_sparse = torch.block_diag(*A.transpose(-2, -1)).to_sparse() + K_block_diag_sparse = torch.block_diag(*K).to_sparse() + + A_t_K_sparse = torch.sparse.mm(A_t_block_diag_sparse, K_block_diag_sparse) + A_t_A_sparse = torch.sparse.mm(A_t_K_sparse, A_block_diag_sparse) + if not trust_region: + J_t_J_sparse = A_t_A_sparse + delta * I_sparse + else: + raise NotImplementedError + # TODO - compute sparse version of diag_A_t_A_sparse + # diag_A_t_A_sparse = A_t_A.mean(0) * I_sparse + J_t_J_sparse = A_t_A_sparse + delta * diag_A_t_A_sparse + + J_t_J_block_diag = J_t_J_sparse.to_dense() + g_sparse = torch.sparse.mm(A_t_K_sparse, torch.block_diag(*b).to_sparse()) + g_block_diag = g_sparse.to_dense() + + # TODO - remove for loop to extract block diagonals + J_t_J = torch.stack([J_t_J_block_diag[i*N:(i+1)*N, i*N:(i+1)*N] for i in range(B)]) + g = torch.stack([g_block_diag[i * N:(i + 1) * N, i].unsqueeze(-1) for i in range(B)]) + + return J_t_J, g + + def get_torch_solve( + self, + A, b, + method, + ): + if method == 'inverse': + res = torch.linalg.solve(A, b) + elif method == 'cholesky': + # method 1 + # old implementation - recheck torch.allclose(res, torch.linalg.solve(A, b)) + # l = torch.linalg.cholesky(A) + # z = torch.linalg.solve_triangular(l, b, upper=False) + # res = torch.linalg.solve_triangular(l.mT, z, upper=False) + + # method 2 + # z = torch.triangular_solve(b, l, transpose=False, upper=False)[0] + # res = torch.triangular_solve(z, l, transpose=True, upper=False)[0] + + # method 3 + l, _ = torch.linalg.cholesky_ex(A) + res = torch.cholesky_solve(b, l) + + elif method == 'cholesky-sparse': + # TODO - ATTENTION! + # This implementation takes a lot of memory because of the block diagonal construction. + raise NotImplementedError + # if self.tensor_args['dtype'] == torch.float32: + # cholesky_fn = cholespy.CholeskySolverF + # elif self.tensor_args['dtype'] == torch.float64: + # cholesky_fn = cholespy.CholeskySolverD + # else: + # raise NotImplementedError + + # convert batch to block diagonal - https://github.com/rgl-epfl/cholespy/issues/26 + # TODO - creating a big block diagonal matrix leads to memory problems + A_block_diag = torch.block_diag(*A) + b_stacked = einops.rearrange(b, "b d 1 -> (b d) 1") + + A_sparse = A_block_diag.to_sparse(layout=torch.sparse_coo) + + cholesky_fn = cholespy.CholeskySolverF + solver = cholesky_fn( + A_sparse.size()[0], + A_sparse.indices()[0], A_sparse.indices()[1], A_sparse.values(), + cholespy.MatrixType.COO + ) + res_ = torch.zeros_like(b_stacked) + + solver.solve(b_stacked, res_) + + # batchify results + res = einops.rearrange(res_, "(b d) 1 -> b d 1", b=A.shape[0]) + + elif method == 'lstq': + # empirically slower + res = torch.linalg.lstsq(A, b)[0] + else: + raise NotImplementedError + + return res + + def _get_costs(self, errors, w_mat): + costs = errors.transpose(1, 2) @ w_mat.unsqueeze(0) @ errors + return costs.reshape(self.num_particles,) + + def get_recent_samples(self): + vel = self._recent_control_particles.detach().clone() + vel = einops.rearrange(vel, '(m b) h d -> m b h d', m=self.num_goals) + pos = self._recent_state_trajectories.detach().clone() + pos = einops.rearrange(pos, '(m b) h d -> m b h d', m=self.num_goals) + # pos_mean = self._particle_means[..., :self.n_dof].detach().clone() + # vel_mean = self._particle_means[..., -self.n_dof:].detach().clone() + + return ( + pos, + vel, + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..8a1226866f4a755283e4fd68dad87ab52e9a724c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py @@ -0,0 +1,129 @@ +import einops +import torch +from typing import List + +from mp_baselines.planners.base import MPPlanner +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import tensor_linspace_v1 +from torch_robotics.trajectory.utils import smoothen_trajectory, finite_difference_vector, smoothen_trajectory_with_bc + +class HybridPlanner(MPPlanner): + """ + Runs a sampled-based planner to get an initial trajectory in position, followed by an optimization-based planner + to optimize for positions and velocities. + """ + + def __init__( + self, + sample_based_planners: List, + opt_based_planner, + **kwargs + ): + super().__init__( + "HybridSampleAndOptimizationPlanner", + **kwargs + ) + + # A list of sampling based planners. Those already encode the start and goal states. + # Each planner is between different start and goal states. + # If there are more than one planners there, then the paths are stacked (path1, path2, ..., pathN). + self.sample_based_planners = sample_based_planners + self.opt_based_planner = opt_based_planner + + def render(self, ax, **kwargs): + raise NotImplementedError + + def optimize(self, debug=False, print_times=False, return_iterations=False, **kwargs): + with TimerCUDA() as t_hybrid: + ################################################# + # Get initial position solutions with a sample-based planner + # Optimize in parallel + with TimerCUDA() as t_sample_based: + if len(self.sample_based_planners) > 1: + trajs_l = [] + for planner in self.sample_based_planners: + traj_l_planner = planner.optimize(refill_samples_buffer=True, debug=debug, **kwargs) + + # Replace any None entry with a linear interpolation. + traj_l = [] + for traj in traj_l_planner: + if traj is not None: + traj_l.append(traj) + else: + # If no solution was found, create a linear interpolated trajectory between start and + # finish, even if is not collision-free + traj = tensor_linspace_v1( + planner.start_state_pos, planner.goal_state_pos, steps=10).T + traj_l.append(traj) + trajs_l.append(traj_l) + + traj_l = [torch.cat([trajs_l[p][t] for p in range(len(trajs_l))]) for t in range(len(trajs_l[0]))] + + else: + traj_l = self.sample_based_planners[0].optimize(refill_samples_buffer=True, debug=debug, **kwargs) + + if debug or print_times: + print(f'Sample-based Planner -- Optimization time: {t_sample_based.elapsed:.3f} sec') + + ################################################# + # Interpolate initial trajectory to desired trajectory length, smooth and set average velocity + traj_pos_vel_l = [] + for traj in traj_l: + # If no solution was found, create a linear interpolated trajectory between start and finish, even + # if is not collision-free + if traj is None: + traj = tensor_linspace_v1( + self.sample_based_planners[0].start_state_pos, self.sample_based_planners[-1].goal_state_pos, + steps=self.opt_based_planner.n_support_points).T + if 'bc_grad_start' in kwargs and 'bc_grad_goal' in kwargs: + print("=====\nUsing bc_grad_start and bc_grad_goal") + traj_pos, traj_vel = smoothen_trajectory_with_bc( + traj, grad_start=kwargs['bc_grad_start'], grad_goal=kwargs['bc_grad_goal'], + n_support_points=self.opt_based_planner.n_support_points, dt=self.opt_based_planner.dt, + set_average_velocity=True, zero_velocity=False, tensor_args=self.tensor_args + ) + else: + traj_pos, traj_vel = smoothen_trajectory( + traj, n_support_points=self.opt_based_planner.n_support_points, dt=self.opt_based_planner.dt, + set_average_velocity=True, zero_velocity=False, tensor_args=self.tensor_args + ) + + # Reshape for gpmp/sgpmp interface + initial_traj_pos_vel = torch.cat((traj_pos, traj_vel), dim=-1) + + traj_pos_vel_l.append(initial_traj_pos_vel) + + initial_traj_pos_vel = torch.stack(traj_pos_vel_l) + # TODO - now it only accepts 1 goal + initial_traj_pos_vel = einops.rearrange(initial_traj_pos_vel, 'n h d -> 1 n h d') + + ################################################# + # Fine tune with an optimization-based planner + + # set initial position and velocity trajectory + self.opt_based_planner.reset(initial_particle_means=initial_traj_pos_vel) + + # Optimize + trajs_0 = self.opt_based_planner.get_traj() + trajs_iters = torch.empty((self.opt_based_planner.opt_iters + 1, *trajs_0.shape), **self.tensor_args) + trajs_iters[0] = trajs_0 + with TimerCUDA() as t_opt_based: + for i in range(self.opt_based_planner.opt_iters): + trajs = self.opt_based_planner.optimize(opt_iters=1, debug=debug, **kwargs) + trajs_iters[i + 1] = trajs + if debug or print_times: + print(f'Optimization-based Planner -- Optimization time: {t_opt_based.elapsed:.3f} sec') + if debug or print_times: + print(f'Hybrid-based Planner -- Optimization time: {t_hybrid.elapsed:.3f} sec') + + # Show the pre-optimization traj and the post-optimization traj + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots(1, 1) + # ax.plot(initial_traj_pos_vel[0, 0, :, 0].cpu().numpy(), initial_traj_pos_vel[0, 0, :, 1].cpu().numpy(), 'r') + # ax.plot(trajs_iters[-1][0, :, 0].cpu().numpy(), trajs_iters[-1][0, :, 1].cpu().numpy(), 'b') + # plt.show() + + if return_iterations: + return trajs_iters + else: + return trajs_iters[-1] diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..da16dccd5a42f494e1fb287505982e0a8be0e2b0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py @@ -0,0 +1,58 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import torch + +# Project imports. +from mp_baselines.planners.base import MPPlanner + + +class IdentityPlanner(MPPlanner): + + def __init__( + self, + fixed_path: torch.Tensor = None, # Shape (num_points, 2). + tensor_args: dict = None, + **kwargs + ): + assert fixed_path is not None + + super().__init__(name="IdentityPlanner", tensor_args=tensor_args) + + self.start_state_pos = fixed_path[0] + self.goal_state_pos = fixed_path[-1] + self.fixed_path = fixed_path + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + return self.fixed_path + + def render(self, ax, **kwargs): + ax.plot(self.fixed_path[:, 0], self.fixed_path[:, 1], color='r', linewidth=2, marker='o') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/mppi.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/mppi.py new file mode 100644 index 0000000000000000000000000000000000000000..5fcbea27995f98a2b37d307fcf6725c8f430e283 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/mppi.py @@ -0,0 +1,213 @@ +import torch +from mp_baselines.planners.priors.gaussian import get_multivar_gaussian_prior +from mp_baselines.planners.base import MPPlanner + + +class MPPI(MPPlanner): + + def __init__( + self, + system, + num_ctrl_samples, + rollout_steps, + opt_iters, + control_std=None, + initial_mean=None, + step_size=1., + temp=1., + cov_prior_type='indep_ctrl', + tensor_args=None, + **kwargs + ): + super(MPPI, self).__init__(name='MPPI', tensor_args=tensor_args) + self.system = system + self.state_dim = system.state_dim + self.control_dim = system.control_dim + self.rollout_steps = rollout_steps + self.num_ctrl_samples = num_ctrl_samples + self.opt_iters = opt_iters + + self.step_size = step_size + self.temp = temp + self._mean = torch.zeros( + self.rollout_steps, + self.control_dim, + **self.tensor_args, + ) + self.control_std = control_std + self.cov_prior_type = cov_prior_type + self.weights = None + + self.ctrl_dist = get_multivar_gaussian_prior( + control_std, + rollout_steps, + self.control_dim, + Cov_type=cov_prior_type, + mu_init=self._mean, + tensor_args=self.tensor_args, + ) + + Cov_inv = [] + for i in range(self.control_dim): + Cov_inv.append(self.ctrl_dist.Cov[..., i].inverse()) + self.Cov_inv = torch.stack(Cov_inv) + self.best_cost = torch.inf + self.reset(initial_mean=initial_mean) + + def reset(self, initial_mean=None): + if initial_mean is not None: + self._mean = initial_mean.clone() + else: + self._mean = torch.zeros( + self.rollout_steps, + self.control_dim, + **self.tensor_args, + ) + self.update_ctrl_dist() + + def update_ctrl_dist(self): + # Update mean + self.ctrl_dist.update_means(self._mean) + + def update_controller(self, costs, U_sampled): + weights = torch.softmax( + -costs / self.temp, + dim=0, + ) + self.weights = weights.clone() + + weights = weights.reshape(-1, 1, 1) + self._mean.add_( + self.step_size * ( + weights * (U_sampled - self._mean.unsqueeze(0)) + ).sum(0) + ) + + self.update_ctrl_dist() + + def sample_and_eval(self, **observation): + # state_trajectories = torch.empty( + # self.num_ctrl_samples, + # self.rollout_steps, # self.rollout_steps + 1, + # self.state_dim, + # **self.tensor_args, + # ) + # + # # Sample control sequences + # control_samples = self.ctrl_dist.sample(self.num_ctrl_samples) + # + # # Roll-out dynamics + # state_trajectories[:, 0] = observation['state'] + # for i in range(self.rollout_steps - 1): + # state_trajectories[:, i+1] = self.system.dynamics( + # state_trajectories[:, i].unsqueeze(1), + # control_samples[:, i].unsqueeze(1), + # ).squeeze(1) + # self.state_trajectories = state_trajectories.clone() + + + # Sample control sequences + control_samples = self.ctrl_dist.sample(self.num_ctrl_samples) + # Rollout dynamics + state_trajectories = self.get_state_trajectories_rollout( + controls=control_samples, num_ctrl_samples=self.num_ctrl_samples, **observation + ) + self.state_trajectories = state_trajectories.clone() + + # Evaluate costs + self.costs = self.system.traj_cost( + state_trajectories.transpose(0, 1).unsqueeze(2), + control_samples.transpose(0, 1).unsqueeze(2), + **observation + ) + + # Add cost from importance-sampling ratio + for i in range(self.control_dim): + V = control_samples[..., i] + U = self._mean[..., i] + self.costs += self.temp * (V @ self.Cov_inv[i] @ U).reshape(-1, 1) + + return ( + control_samples, + state_trajectories, + self.costs + ) + + def optimize( + self, + opt_iters=None, + **observation + ): + + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + with torch.no_grad(): + (control_samples, + state_trajectories, + costs) = self.sample_and_eval(**observation) + self._save_best() + self.update_controller(costs, control_samples) + self._mean = self._mean.detach() + + self._recent_control_samples = control_samples + self._recent_state_trajectories = state_trajectories + self._recent_weights = self.weights + + return ( + control_samples, + state_trajectories, + costs + ) + + def _save_best(self): + best_cost = torch.min(self.costs) + idx = torch.argmin(self.costs) + if best_cost < self.best_cost: + self.best_cost = best_cost + self.best_traj = self.state_trajectories[idx,...] + + def pop(self): + action = self._mean[0, :].clone().detach() + self.shift() + return action + + def shift(self): + self._mean = self._mean.roll(shifts=-1, dims=-1) + self._mean[-1:] = 0. + + def get_recent_samples(self): + return ( + self._recent_control_samples.detach().clone(), + self._recent_state_trajectories.detach().clone(), + self._recent_weights.detach().clone() + ) + + def get_mean_controls(self): + return self._mean + + def get_state_trajectories_rollout(self, controls=None, num_ctrl_samples=None, **observation): + state_trajectories = torch.empty( + 1 if num_ctrl_samples is None else num_ctrl_samples, + self.rollout_steps, # self.rollout_steps + 1, + self.state_dim, + **self.tensor_args, + ) + + if controls is None: + control_samples = self._mean.unsqueeze(0) + else: + control_samples = controls + + # Roll-out dynamics + state_trajectories[:, 0] = observation['state'] + for i in range(self.rollout_steps - 1): + state_trajectories[:, i+1] = self.system.dynamics( + state_trajectories[:, i].unsqueeze(1), + control_samples[:, i].unsqueeze(1), + ).squeeze(1) + return state_trajectories.clone() + + def render(self, ax, **kwargs): + raise NotImplementedError diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py new file mode 100644 index 0000000000000000000000000000000000000000..132c6de4d27d661a5dbf6570fa0a676779512a87 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py @@ -0,0 +1,29 @@ +import torch.multiprocessing as mp + + +class MultiProcessor: + # Adapated from https://stackoverflow.com/a/45829852 + + def __init__(self, max_processes=-1, start_method='forkserver', **kwargs): + mp.set_start_method(start_method) + self.pool = mp.Pool(processes=mp.cpu_count() if max_processes == -1 else max_processes) + self.async_results = [] + + @staticmethod + def _wrapper(func, args, kwargs): + ret = func(*args, **kwargs) + return ret + + def run(self, func, *args, **kwargs): + args2 = [func, args, kwargs] + async_res = self.pool.apply_async(self._wrapper, args=args2) + self.async_results.append(async_res) + + def wait(self): + self.pool.close() + self.pool.join() + res_l = [] + for async_res in self.async_results: + res = async_res.get() + res_l.append(res) + return res_l diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..7cd219b95a1a5effd94aa0861877578da12ec013 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py @@ -0,0 +1,42 @@ +from copy import copy + +from mp_baselines.planners.multi_processing import MultiProcessor + + +class MultiSampleBasedPlanner: + + def __init__( + self, + planner, + n_trajectories=2, + optimize_sequentially=False, + **kwargs + ): + self.planner = planner + self.n_trajectories = n_trajectories + self.multi_processer = None + if not optimize_sequentially: + self.planners_l = [copy(planner) for _ in range(n_trajectories)] + self.multi_processer = MultiProcessor(**kwargs) + + def optimize(self, **kwargs): + if self.multi_processer is not None: + # optimize in parallel + for p in self.planners_l: # queue up multiple tasks + self.multi_processer.run(p.optimize, **kwargs) + trajs_l = self.multi_processer.wait() # get all results + else: + # optimize sequentially + trajs_l = [] + for _ in range(self.n_trajectories): # queue up multiple tasks + traj = self.planner.optimize(**kwargs) + trajs_l.append(traj) + return trajs_l + + @property + def start_state_pos(self): + return self.planner.start_state_pos + + @property + def goal_state_pos(self): + return self.planner.goal_state_pos diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/priors/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/priors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py new file mode 100644 index 0000000000000000000000000000000000000000..44cda3399f52e58c9eca22c9f56e533d66b9a686 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py @@ -0,0 +1,333 @@ +from abc import ABC, abstractmethod +import torch +import torch.distributions as dist +import numpy as np + + +class GMM: + def __init__( + self, + means, + sigmas, + weights, + ): + """ + Parameters + ---------- + means : + shape: [num_particles, steps, ctrl_dim] + sigmas : + shape: [num_particles, steps, ctrl_dim] + weights : + shape: [num_particles] + """ + self.num_particles, self.rollout_steps, self.ctrl_dim = means.shape + components = dist.Independent(dist.Normal(means, sigmas), 2) + mixture = dist.Categorical(weights) + self.dist = dist.mixture_same_family.MixtureSameFamily(mixture, components) + + def sample(self, num_samples): + return self.dist.sample(num_samples).transpose(0, 1) + + def log_prob(self, x): + return self.dist.log_prob(x) + + +def avg_ctrl_to_goal( + state, + target, + rollout_steps, + dt, + max_ctrl=100, + control_type='velocity', +): + """ Average control from state to target. Control must be + first/second-derivative in state/target space. Ex. velocity control for + cartesian states""" + assert control_type in [ + 'velocity', + 'acceleration', + ] + if control_type == 'velocity': + return ( + (target - state)/ rollout_steps / dt + ).clamp(max=max_ctrl) + else: + state_dim = state.dim() + pos_dim = int(state_dim / 2) + return ( + (target[:pos_dim] - state[:pos_dim]) / rollout_steps / dt**2 + ).clamp(max=max_ctrl) + + +def get_indep_gaussian_prior( + sigma_init, + rollout_steps, + control_dim, + mu_init=None, +): + + mu = torch.zeros( + rollout_steps, + control_dim, + ) + if mu_init is not None: + mu[:, :] = mu_init + + sigma = torch.ones( + rollout_steps, + control_dim, + ) * sigma_init + + return dist.Normal(mu, sigma) + + +def get_multivar_gaussian_prior( + sigma, + rollout_steps, + control_dim, + Cov_type='indep_ctrl', + mu_init=None, + tensor_args=None, +): + """ + :param sigma: standard deviation on controls + :param control_dim: + :param rollout_steps: + :param Cov_type: Covariance prior type, 'indep_ctrl': diagonal Cov, + 'const_ctrl': const. ctrl Cov. + :param mu_init: + :return: distribution with MultivariateNormal for each control dimension + """ + assert Cov_type in [ + 'indep_ctrl', + 'const_ctrl', + ], 'Invalid type for control prior dist.' + + mu = torch.zeros( + rollout_steps, + control_dim, + **tensor_args, + ) + + if mu_init is not None: + mu[:, :] = mu_init + + if Cov_type == 'const_ctrl': + # Const-ctrl covariance + Cov_gen = const_ctrl_Cov + + elif Cov_type == 'indep_ctrl': + # Isotropic covariance + Cov_gen = diag_Cov + else: + raise IOError('Cov_type not recognized.') + + Cov = Cov_gen( + sigma, + rollout_steps, + control_dim, + tensor_args=tensor_args, + ) + + # check_Cov_is_valid(Cov) + return ControlTrajectoryGaussian( + rollout_steps, + control_dim, + mu, + Cov, + tensor_args=tensor_args + ) + + +def diag_Cov( + sigma, + length=None, + ctrl_dim=None, + tensor_args=None, +): + """ + Time-independent diagonal covariance matrix. Assumes independence + across control dimension. + """ + Cov = torch.eye( + length, + **tensor_args, + ).unsqueeze(-1).repeat(1, 1, ctrl_dim) + + if isinstance(sigma, list): + Cov = Cov * torch.Tensor(np.array(sigma)).to(**tensor_args)**2 + else: + Cov = Cov * sigma**2 + + return Cov + + +def const_ctrl_Cov( + sigma, + length=None, + ctrl_dim=None, + tensor_args=None, +): + """ + Constant-control covariance prior. Assumes independence across control + dimension. + """ + + if isinstance(sigma, list): + sigma = torch.from_numpy(np.array(sigma)).to(**tensor_args) + + L = torch.tril( + torch.ones( + length, + length-1, + **tensor_args, + ), diagonal=-1, + ) + LL_t = torch.matmul( + L, L.transpose(0, 1) + ) + + LL_t += torch.ones( + length, + length, + **tensor_args, + ) + + Cov = LL_t.unsqueeze(-1).repeat(1, 1, ctrl_dim) * sigma**2 + return Cov + + +def check_Cov_is_valid(Cov): + """ + Check determinant of Cov to pre-empt potential numerical instability + in Multi-variate Gaussian. + For example, dist.logprob(x) >> 1. + :param Cov: covariance matrix (Tensor) of shape [rollout_length, + rollout_length, control_dim] + """ + Cov_np = Cov.cpu().numpy() + for i in range(Cov.shape[-1]): + det = np.linalg.det(Cov_np[:,:,i]) + if det < 1.e-7: + raise ZeroDivisionError( + 'Covariance-determinant too small, potential for underflow. ' + 'Consider increasing sigma.' + ) + + +class ControlTrajectoryPrior(ABC): + """ + Prior distribution on control trajectories, Assumes independence across + ctrl_dim. + """ + def __init__( + self, + rollout_steps, + ctrl_dim, + tensor_args=None, + ): + self.rollout_steps = rollout_steps + self.ctrl_dim = ctrl_dim + self.list_ctrl_dists = [] # TODO - make this a tensor, or list of tensors of same distributions... + if tensor_args is None: + tensor_args = {'device': 'cpu', 'dtype': torch.float32} + self.tensor_args = tensor_args + + @abstractmethod + def make_dist(self): + """ Construct list of sampling distribution, one for each control + dimension""" + pass + + def log_prob( + self, + samples, + cond_inputs=None + ): + """ + :param samples: control samples of shape ( num_particles, rollout_steps, + ctrl_dim) + :return: log_probs, of shape (num_particles.) + """ + assert samples.dim() == 3 + assert samples.size(1) == self.rollout_steps + assert samples.size(2) == self.ctrl_dim + num_particles = samples.size(0) + + log_probs = torch.zeros( + num_particles, + **self.tensor_args, + ) + + for i in range(self.ctrl_dim): + samp = samples[:, :, i] # [num_particles, rollout_steps] + log_probs += self.list_ctrl_dists[i].log_prob( + samp + ) + + return log_probs + + def update_means(self, means): + # TODO - remove this for loop after making self.list_ctrl_dists a tensor + for i in range(self.ctrl_dim): + self.list_ctrl_dists[i].loc = means[..., i].detach().clone() + + def sample( + self, + num_samples, + cond_inputs=None, + ): + """ + :param num_particles: number of control particles + :param cond_inputs: conditional input (not implemented) + :return: control tensor, of size (rollout_steps, num_particles, + ctrl_dim) + """ + U_s = torch.empty( + num_samples, + self.rollout_steps, + self.ctrl_dim, + **self.tensor_args + ) + # TODO - remove this for loop + for i in range(self.ctrl_dim): + U_s[:, :, i] = self.list_ctrl_dists[i].sample( + (num_samples,) + ).to(**self.tensor_args) + return U_s + + +class ControlTrajectoryGaussian(ControlTrajectoryPrior): + """ + Multivariate Gaussian distribution for each control dimension. + """ + def __init__( + self, + rollout_steps, + ctrl_dim, + mu=None, + Cov=None, + tensor_args=None, + ): + + assert mu.size(0) == rollout_steps + assert mu.size(1) == ctrl_dim + + super().__init__( + rollout_steps, + ctrl_dim, + tensor_args=tensor_args, + ) + self.mu = mu + self.Cov = Cov + self.list_ctrl_dists = [] + self.make_dist() + + def make_dist(self): + for i in range(self.ctrl_dim): + self.list_ctrl_dists.append( + dist.MultivariateNormal( + self.mu[:, i], + covariance_matrix=self.Cov[:,:,i] + )) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py new file mode 100644 index 0000000000000000000000000000000000000000..46317401e44ac70da619148be4858dd2fc626f11 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py @@ -0,0 +1,119 @@ +import abc + +import torch + +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.utils import extend_path + + +class RRTBase(MPPlanner): + + def __init__( + self, + name: str = 'RRTBase', + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + goal_state_pos: torch.Tensor = None, + step_size: float = 0.1, + n_radius: float = 1., + max_time: float = 60., + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + **kwargs + ): + assert start_state_pos is not None and goal_state_pos is not None + + super(RRTBase, self).__init__(name=name, tensor_args=tensor_args) + self.task = task + self.n_iters = n_iters + + # RRT params + self.step_size = step_size + self.n_radius = n_radius + self.max_time = max_time + + self.start_state_pos = start_state_pos + self.goal_state_pos = goal_state_pos + + self.n_pre_samples = n_pre_samples + self.pre_samples = pre_samples + self.last_sample_idx = None + self.n_samples_refill = self.n_pre_samples + + self.reset() + + def reset(self): + # Create pre collision-free samples + n_uniform_samples = self.n_pre_samples - (self.pre_samples.shape[0] if self.pre_samples is not None else 0) + uniform_samples = self.create_uniform_samples(n_uniform_samples) + if self.pre_samples is not None: + self.pre_samples = torch.cat((self.pre_samples, uniform_samples), dim=0) + else: + self.pre_samples = uniform_samples + + def create_uniform_samples(self, n_samples, max_samples=1000, **observation): + return self.task.random_coll_free_q(n_samples, max_samples) + + def remove_last_pre_sample(self): + # https://discuss.pytorch.org/t/how-to-remove-an-element-from-a-1-d-tensor-by-index/23109/3 + if len(self.pre_samples) > 0: + i = self.last_sample_idx + self.pre_samples = torch.cat([self.pre_samples[:i], self.pre_samples[i+1:]]) + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + return self._run_optimization(opt_iters, **observation) + + @abc.abstractmethod + def _run_optimization(self, opt_iters, **observation): + raise NotImplementedError + + def random_collision_free(self, **observation): + """ + Returns: random positions in environments space not in collision + """ + refill_samples_buffer = observation.get('refill_samples_buffer', False) + if len(self.pre_samples) > 0: + qs = self.get_pre_sample() + elif refill_samples_buffer: + self.pre_samples = self.create_uniform_samples(self.n_samples_refill, **observation) + qs = self.get_pre_sample() + else: + qs = self.create_uniform_samples(1, **observation) + + return qs + + def get_pre_sample(self): + idx = torch.randperm(len(self.pre_samples))[0] + qs = self.pre_samples[idx] + self.last_sample_idx = idx + return qs + + def collision_fn(self, qs, **observation): + return self.task.compute_collision(qs).squeeze() + + def sample_fn(self, without_collision=True, **observation): + if without_collision: + return self.random_collision_free(**observation) + else: + return self.task.random_q() + + def distance_fn(self, q1, q2): + return self.task.distance_q(q1, q2) + + def extend_fn(self, q1, q2, max_step=0.03, max_dist=0.1): + return extend_path(self.distance_fn, q1, q2, max_step, max_dist, tensor_args=self.tensor_args) + + def get_nearest_node(self, nodes, nodes_torch, target): + distances = self.distance_fn(nodes_torch, target) + min_idx = torch.argmin(distances) + nearest = nodes[min_idx] + return nearest diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py new file mode 100644 index 0000000000000000000000000000000000000000..1b3be9cab83525d5821eee52fb23f92ac46be010 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py @@ -0,0 +1,205 @@ +import math +import sys +from copy import copy +from operator import itemgetter + +import numpy as np +import torch +import time +import matplotlib.pyplot as plt +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.rrt_base import RRTBase +from mp_baselines.planners.utils import safe_path, purge_duplicates_from_traj, extend_path +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +class TreeNode: + + def __init__(self, config, parent=None): + self.config = config + self.parent = parent + + def retrace(self): + sequence = [] + node = self + while node is not None: + sequence.append(node) + node = node.parent + return sequence[::-1] + + def render(self, ax): + assert ax is not None, "Axis cannot be None" + if self.parent is not None: + if ax.name == '3d': + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot3D([x[0], y[0]], [x[1], y[1]], [x[2], y[2]], color='k', linewidth=0.5) + else: + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot([x[0], y[0]], [x[1], y[1]], color='k', linewidth=0.5) + + def __str__(self): + return self.__class__.__name__ + '(' + str(self.config) + ')' + __repr__ = __str__ + + +def random_swap(nodes1, nodes2): + p = float(len(nodes1)) / (len(nodes1) + len(nodes2)) + swap = (torch.rand(1) < p) + return swap + + +def configs(nodes): + if nodes is None: + return None + return list(map(lambda n: n.config, nodes)) + + +class RRTConnect(RRTBase): + + def __init__( + self, + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + step_size: float = 0.1, + n_radius: float = 1., + max_time: float = 60., + goal_state_pos: torch.Tensor = None, + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + **kwargs + ): + super(RRTConnect, self).__init__( + 'RRTConnect', + task, + n_iters, + start_state_pos, + goal_state_pos, + step_size, + n_radius, + max_time, + tensor_args, + n_pre_samples, + pre_samples + ) + + self.nodes_tree_1 = None + self.nodes_tree_2 = None + + self.nodes_tree_1_torch = None + self.nodes_tree_2_torch = None + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + print_freq = observation.get('print_freq', 150) + debug = observation.get('debug', False) + + if self.collision_fn(self.start_state_pos).squeeze() or self.collision_fn(self.goal_state_pos).squeeze(): + return None + + iteration = -1 + success = False + + self.nodes_tree_1 = [TreeNode(self.start_state_pos)] + self.nodes_tree_2 = [TreeNode(self.goal_state_pos)] + + self.nodes_tree_1_torch = self.nodes_tree_1[0].config + self.nodes_tree_2_torch = self.nodes_tree_2[0].config + + path = None + + with TimerCUDA() as t: + while (t.elapsed < self.max_time) and (iteration < self.n_iters): + iteration += 1 + + if iteration % print_freq == 0 or iteration % (self.n_iters - 1) == 0: + if debug: + self.print_info(iteration, t.elapsed, success) + + # Swap trees + # TODO - implement smart random swap + # swap = random_swap(self.nodes_tree_1, self.nodes_tree_2) + swap = True + if swap: + self.nodes_tree_1, self.nodes_tree_2 = self.nodes_tree_2, self.nodes_tree_1 + self.nodes_tree_1_torch, self.nodes_tree_2_torch = self.nodes_tree_2_torch, self.nodes_tree_1_torch + + # Sample new node + target = self.sample_fn(**observation) + + ############################################################### + # nearest node in Tree1 to the target node + nearest = self.get_nearest_node(self.nodes_tree_1, self.nodes_tree_1_torch, target) + + # create a safe path from the target node to the nearest node + extended = self.extend_fn(nearest.config, target, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + + # add last node in path to Tree1 + if len(path) == 0: + continue + + n1 = TreeNode(path[-1], parent=nearest) + self.nodes_tree_1.append(n1) + self.nodes_tree_1_torch = torch.vstack((self.nodes_tree_1_torch, n1.config)) + + if torch.allclose(path[-1], target): + self.remove_last_pre_sample() + + ############################################################### + # add nearest node in Tree2 to the one recently added to Tree1 + nearest = self.get_nearest_node(self.nodes_tree_2, self.nodes_tree_2_torch, n1.config) + + # create a safe path from the target node to the nearest node + extended = self.extend_fn(nearest.config, n1.config, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + + # add last node in path to TREE2 + if len(path) == 0: + continue + + n2 = TreeNode(path[-1], parent=nearest) + self.nodes_tree_2.append(n2) + self.nodes_tree_2_torch = torch.vstack((self.nodes_tree_2_torch, n2.config)) + + if swap: + self.nodes_tree_1, self.nodes_tree_2 = self.nodes_tree_2, self.nodes_tree_1 + self.nodes_tree_1_torch, self.nodes_tree_2_torch = self.nodes_tree_2_torch, self.nodes_tree_1_torch + + # if the last node in path is the same as the proposed node, the two trees are connected and terminate + if torch.allclose(n1.config, n2.config): + success = True + + if swap: + n1, n2 = n2, n1 + + path1, path2 = n1.retrace(), n2.retrace() + + # if swap: + # path1, path2 = path2, path1 + + path = configs(path1[:-1] + path2[::-1]) + break + + if path is not None: + if len(path) == 1: + return None + self.print_info(iteration, t.elapsed, success) + return purge_duplicates_from_traj(path, eps=1e-6) + return path + + def print_info(self, iteration, elapsed_time, success): + print(f'Iteration: {iteration:5}/{self.n_iters:5} ' + f'| Time: {elapsed_time:.3f} s' + f'| Nodes: {len(self.nodes_tree_1) + len(self.nodes_tree_2)} ' + f'| Success: {success}' + ) + + def render(self, ax, **kwargs): + for node in self.nodes_tree_1: + node.render(ax) + for node in self.nodes_tree_2: + node.render(ax) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py new file mode 100644 index 0000000000000000000000000000000000000000..a0eca1a0bcc4fd83f4f2d3d278aeb1a7dbec5662 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py @@ -0,0 +1,276 @@ +import math +import sys +from copy import copy +from operator import itemgetter + +import numpy as np +import torch +import time +import matplotlib.pyplot as plt +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.rrt_base import RRTBase +from mp_baselines.planners.utils import safe_path, purge_duplicates_from_traj, extend_path +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +class OptimalNode: + + def __init__(self, config, parent=None, d=0, path=[], iteration=None): + self.config = config + self.parent = parent + self.children = set() + self.d = d + self.path = path + if parent is not None: + self.cost = parent.cost + d + self.parent.children.add(self) + else: + self.cost = d + self.solution = False + self.creation = iteration + self.last_rewire = iteration + + def set_solution(self, solution): + if self.solution is solution: + return + self.solution = solution + if self.parent is not None: + self.parent.set_solution(solution) + + def retrace(self): + if self.parent is None: + return self.path + [self.config] + return self.parent.retrace() + self.path + [self.config] + + def rewire(self, parent, d, path, iteration=None): + if self.solution: + self.parent.set_solution(False) + self.parent.children.remove(self) + self.parent = parent + self.parent.children.add(self) + if self.solution: + self.parent.set_solution(True) + self.d = d + self.path = path + self.update() + self.last_rewire = iteration + + def update(self): + self.cost = self.parent.cost + self.d + for n in self.children: + n.update() + + def clear(self): + self.node_handle = None + self.edge_handle = None + + def render(self, ax): + assert ax is not None, "Axis cannot be None" + if self.parent is not None: + if ax.name == '3d': + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot3D([x[0], y[0]], [x[1], y[1]], [x[2], y[2]], color='k', linewidth=0.5) + else: + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot([x[0], y[0]], [x[1], y[1]], color='k', linewidth=0.5) + for child in self.children: + child.render(ax) + + def __str__(self): + return self.__class__.__name__ + '(' + str(self.config) + ')' + __repr__ = __str__ + + +class RRTStar(RRTBase): + + def __init__( + self, + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + n_iters_after_success=None, + max_best_cost_iters: int = 1000, + cost_eps: float = 1e-2, + step_size: float = 0.1, + n_radius: float = 1., + n_knn: int = 0, + max_time: float = 60., + goal_prob: float = .1, + goal_state_pos: torch.Tensor = None, + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + informed=False + ): + super(RRTStar, self).__init__( + 'RRTStar', + task, + n_iters, + start_state_pos, + goal_state_pos, + step_size, + n_radius, + max_time, + tensor_args, + n_pre_samples, + pre_samples + ) + + self.n_iters_after_success = n_iters_after_success + self.max_best_cost_iters = max_best_cost_iters if max_best_cost_iters is not None else n_iters + self.cost_eps = cost_eps + + # RRTStar params + assert n_knn >= 0, "knn parameter is < 0" + self.n_knn = n_knn + self.goal_prob = goal_prob + + self.informed = informed # Informed RRT + + self.nodes = None + self.nodes_torch = None + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + initial_nodes = observation.get('initial_nodes', None) + informed = observation.get('informed', self.informed) + eps = observation.get('eps', 1e-6) + print_freq = observation.get('print_freq', 150) + debug = observation.get('debug', False) + if self.collision_fn(self.start_state_pos).squeeze() or self.collision_fn(self.goal_state_pos).squeeze(): + return None + if initial_nodes is not None: + self.nodes = initial_nodes + self.nodes_torch = torch.vstack([node.config for node in self.nodes]) + else: + self.nodes = [OptimalNode(self.start_state_pos)] + self.nodes_torch = OptimalNode(self.start_state_pos).config + + goal_n = None + iteration = -1 + iters_after_first_success = 0 + best_cost_iters = 0 + best_cost_eps = torch.inf + success = False + + # best_possible_cost = distance_fn(goal, start) # if straight line is possible + + with TimerCUDA() as t: + while (t.elapsed < self.max_time) and (iteration < self.n_iters): + + iteration += 1 + + # Stop if the cost does not improve over iterations + if best_cost_iters >= self.max_best_cost_iters: + break + if goal_n is not None: + if goal_n.cost < best_cost_eps - self.cost_eps: + best_cost_eps = copy(goal_n.cost) + best_cost_iters = 0 + else: + best_cost_iters += 1 + + # Stop if the cost does not improve over iterations passed after the first success + success = goal_n is not None + if success: + iters_after_first_success += 1 + if self.n_iters_after_success is not None: + if iters_after_first_success > self.n_iters_after_success: + break + + # Sample new node + do_goal = goal_n is None and (iteration == 0 or torch.rand(1) < self.goal_prob) + if do_goal: + s = self.goal_state_pos + else: + s = self.sample_fn(**observation) + + if iteration % print_freq == 0 or iteration % (self.n_iters - 1) == 0 or iters_after_first_success == 1: + if debug: + self.print_info(iteration, t.elapsed, success, goal_n) + + # Informed RRT* + start_time_iter = time.time() + if informed and (goal_n is not None) and (self.distance_fn(self.start_state_pos, s) + self.distance_fn(s, self.goal_state_pos) >= goal_n.cost): + self.remove_last_pre_sample() + continue + + # nearest node to the sampled node + nearest = self.get_nearest_node(self.nodes, self.nodes_torch, s) + + # create a safe path from the sampled node to the nearest node + extended = self.extend_fn(nearest.config, s, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + if len(path) == 0: + continue + + if not do_goal and torch.allclose(path[-1], s): + self.remove_last_pre_sample() + + new = OptimalNode(path[-1], parent=nearest, d=self.distance_fn( + nearest.config, path[-1]), path=list(path[:-1]), iteration=iteration) + + if do_goal and (self.distance_fn(new.config, self.goal_state_pos) < eps): + goal_n = new + goal_n.set_solution(True) + + # Append the node to the tree + self.nodes.append(new) + self.nodes_torch = torch.vstack((self.nodes_torch, new.config)) + + # Get neighbors of new node + distances = self.distance_fn(self.nodes_torch, new.config) + if self.n_knn > 0: + # https://discuss.pytorch.org/t/k-nearest-neighbor-in-pytorch/59695 + knn = distances.topk(min(self.n_knn, len(distances)), largest=False) + neighbors_idxs = knn.indices + else: + neighbors_idxs = torch.argwhere(distances < self.n_radius) + + if neighbors_idxs.nelement() != 0: + neighbors_idxs = np.atleast_1d(neighbors_idxs.squeeze().cpu().numpy()) + try: + neighbors = list(itemgetter(*neighbors_idxs)(self.nodes)) + except TypeError: + neighbors = [itemgetter(*neighbors_idxs)(self.nodes)] + else: + neighbors = [] + + # Rewire - update parents + for n in neighbors: + d = self.distance_fn(n.config, new.config) + if (new.cost + d) < n.cost: + extended = self.extend_fn(new.config, n.config, max_step=self.step_size, max_dist=self.n_radius) + n_path = safe_path(extended, self.collision_fn) + if len(n_path) != 0: + n_dist = self.distance_fn(n.config, n_path[-1]) + if n_dist < eps: + n.rewire(new, d, list(n_path[:-1]), iteration=iteration) + + self.print_info(iteration, t.elapsed, success, goal_n) + + if goal_n is None: + return None + + # get path from start to goal + path = goal_n.retrace() + + return purge_duplicates_from_traj(path, eps=1e-6) + + def print_info(self, iteration, elapsed_time, success, goal_n): + print(f'Iteration: {iteration:5}/{self.n_iters:5} ' + f'| Time: {elapsed_time:.3f} s' + f'| Nodes: {self.nodes_torch.shape[0]} ' + f'| Success: {success} | Cost: {goal_n.cost if success else torch.inf:.12f}') + + def render(self, ax, **kwargs): + self.nodes[0].render(ax) + + +class InfRRTStar(RRTStar): + # Informed RRT Star + def __init__(self, *args, **kwargs): + super(InfRRTStar, self).__init__(*args, informed=True, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py new file mode 100644 index 0000000000000000000000000000000000000000..a950e52fdf5db16ab775ee8db8cfc49f3241ba15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py @@ -0,0 +1,340 @@ +# Adapted from https://github.com/anindex/stoch_gpmp + +import time +from copy import copy + +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from mp_baselines.planners.gpmp2 import build_gpmp2_cost_composite + + +class StochGPMP(OptimizationPlanner): + + def __init__( + self, + robot=None, + n_dof: int = None, + n_support_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + step_size=1., + multi_goal_states=None, + initial_particle_means=None, + sigma_start_init=None, + sigma_start_sample=None, + sigma_goal_init=None, + sigma_goal_sample=None, + sigma_gp_init=None, + sigma_gp_sample=None, + num_samples=2, + temperature=1., + **kwargs + ): + super(StochGPMP, self).__init__( + name='StochGPMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=False, + **kwargs + ) + + self.d_state_opt = 2 * self.n_dof + + self.goal_directed = (multi_goal_states is not None) + if not self.goal_directed: # TODO NOTE(an): if xere is no goal, we assume xere is at least one solution + self.num_goals = 1 + else: + assert multi_goal_states.dim() == 2 + self.num_goals = multi_goal_states.shape[0] + + self.num_samples = num_samples + self.step_size = step_size + self.temperature = temperature + self.sigma_start_sample = sigma_start_sample + self.sigma_goal_sample = sigma_goal_sample + self.sigma_gp_sample = sigma_gp_sample + + self._mean = None + self._weights = None + self._sample_dist = None + + ############################################## + # Construct cost function + self.cost = build_gpmp2_cost_composite( + robot=robot, + n_support_points=n_support_points, + dt=dt, + start_state=start_state, + multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + **kwargs + ) + + ############################################## + # Initialize particles + self.reset(initial_particle_means=initial_particle_means) + + def reset( + self, + start_state=None, + multi_goal_states=None, + initial_particle_means=None, + ): + if start_state is not None: + self.start_state = start_state.detach().clone() + + if multi_goal_states is not None: + self.multi_goal_states = multi_goal_states.detach().clone() + + self.set_prior_factors() + + if initial_particle_means is not None: + if initial_particle_means == 'const_vel': + self._particle_means = self.const_vel_trajectories( + self.start_state, + self.multi_goal_states, + ) + else: + self._particle_means = initial_particle_means + else: + # Initialization particles from prior distribution + self._init_dist = self.get_prior_dist( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if self.goal_directed else None, + self.start_state, + goal_states=self.multi_goal_states, + ) + self._particle_means = self._init_dist.sample(self.num_particles_per_goal).to(**self.tensor_args) + del self._init_dist # free memory + self._particle_means = self._particle_means.flatten(0, 1) + + # Sampling distributions + self._sample_dist = self.get_prior_dist( + self.start_prior_sample.K, + self.gp_prior_sample.Q_inv[0], + self.multi_goal_prior_sample[0].K if self.goal_directed else None, + self.start_state, + particle_means=self._particle_means, + goal_states=self.multi_goal_states + ) + self.Sigma_inv = self._sample_dist.Sigma_inv.to(**self.tensor_args) + self.state_samples = self._sample_dist.sample(self.num_samples).to(**self.tensor_args) + + def set_prior_factors(self): + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.d_state_opt, + self.sigma_start_init, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + self.multi_goal_prior_init = [] + if self.goal_directed: + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_init, # NOTE(sasha) Assume same goal Cov. for now + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + #========= Sampling factors =============== + self.start_prior_sample = UnaryFactor( + self.d_state_opt, + self.sigma_start_sample, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_sample = GPFactor( + self.n_dof, + self.sigma_gp_sample, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + self.multi_goal_prior_sample = [] + if self.goal_directed: + for i in range(self.num_goals): + self.multi_goal_prior_sample.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_sample, # NOTE(sasha) Assume same goal Cov. for now + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def const_vel_trajectories( + self, + start_state, + multi_goal_states, + ): + traj_dim = (multi_goal_states.shape[0], self.num_particles_per_goal, self.n_support_points, self.d_state_opt) + state_traj = torch.zeros(traj_dim, **self.tensor_args) + mean_vel = (multi_goal_states[:, :self.n_dof] - start_state[:self.n_dof]) / (self.n_support_points * self.dt) + for i in range(self.n_support_points): + interp_state = start_state[:self.n_dof] * (self.n_support_points - i - 1) / (self.n_support_points - 1) \ + + multi_goal_states[:, :self.n_dof] * i / (self.n_support_points - 1) + state_traj[:, :, i, :self.n_dof] = interp_state.unsqueeze(1) + state_traj[:, :, :, self.n_dof:] = mean_vel.unsqueeze(1).unsqueeze(1) + return state_traj + + def get_prior_dist( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + ): + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + 2 * self.n_dof, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=self.tensor_args + ) + + def _get_costs(self, **observation): + costs = self.cost.eval(self.state_samples, **observation).reshape(self.num_particles, self.num_samples) + + # Add cost from importance-sampling ratio + V = self.state_samples.view(-1, self.num_samples, self.n_support_points * self.d_state_opt) # flatten trajectories + U = self._particle_means.view(-1, 1, self.n_support_points * self.d_state_opt) + costs += self.temperature * (V @ self.Sigma_inv @ U.transpose(1, 2)).squeeze(2) + return costs + + def sample_and_eval(self, **observation): + # TODO: update prior covariance with new goal location + + # Sample state-trajectory particles + self.state_samples = self._sample_dist.sample(self.num_samples).to(**self.tensor_args) + + # Evaluate costs + costs = self._get_costs(**observation) + + position_seq = self.state_samples[..., :self.n_dof] + velocity_seq = self.state_samples[..., -self.n_dof:] + + position_seq_mean = self._particle_means[..., :self.n_dof].clone() + velocity_seq_mean = self._particle_means[..., -self.n_dof:].clone() + + return ( + velocity_seq, + position_seq, + velocity_seq_mean, + position_seq_mean, + costs, + ) + + def _update_distribution(self, costs, traj_samples): + self._weights = torch.softmax(-costs / self.temperature, dim=1) + self._weights = self._weights.reshape(-1, self.num_samples, 1, 1) + + # sum over particles + approx_grad = (self._weights * (traj_samples - self._particle_means.unsqueeze(1))).sum(1) + + self._particle_means.add_( + self.step_size * approx_grad + ) + self._sample_dist.set_mean(self._particle_means.view(self.num_particles, -1)) + + return approx_grad + + def optimize( + self, + opt_iters=None, + debug=False, + **observation + ): + + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + with torch.no_grad(): + (control_samples, + state_trajectories, + control_particles, + state_particles, + costs,) = self.sample_and_eval(**observation) + + approx_grad = self._update_distribution(costs, self.state_samples) + + self._recent_control_samples = control_samples + self._recent_control_particles = control_particles + self._recent_state_trajectories = state_trajectories + self._recent_state_particles = state_particles + self._recent_weights = self._weights + + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + # def _get_traj(self, mode='best'): + # if mode == 'best': + # # TODO: Fix for multi-particles + # particle_ind = self._weights.argmax() + # traj = self.state_samples[particle_ind].clone() + # elif mode == 'mean': + # traj = self._mean.clone() + # else: + # raise ValueError('Unidentified sampling mode in get_next_action') + # return traj + + def get_recent_samples(self): + return ( + self._recent_state_trajectories.detach().clone(), + self._recent_state_particles.detach().clone(), + self._recent_control_samples.detach().clone(), + self._recent_control_particles.detach().clone(), + self._recent_weights.detach().clone(), + ) + + def sample_trajectories(self, num_samples_per_particle): + self._sample_dist.set_mean(self._particle_means.view(self.num_particles, -1)) + self.state_samples = self._sample_dist.sample(num_samples_per_particle).to( + **self.tensor_args) + position_seq = self.state_samples[..., :self.n_dof] + velocity_seq = self.state_samples[..., -self.n_dof:] + return ( + position_seq, + velocity_seq, + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stomp.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stomp.py new file mode 100644 index 0000000000000000000000000000000000000000..efda3b3782c09c25d8810e2369ae92ed57441f85 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/stomp.py @@ -0,0 +1,220 @@ +import einops +import torch +import torch.distributions as dist + +from mp_baselines.planners.base import OptimizationPlanner + + +class STOMP(OptimizationPlanner): + + def __init__( + self, + n_dof: int, + n_support_points: int, + num_particles_per_goal: int, + num_samples: int, + opt_iters: int, + dt: float, + start_state: torch.Tensor, + cost=None, + initial_particle_means=None, + multi_goal_states: torch.Tensor = None, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=10., + temperature: float = 1., + step_size: float = 1., + sigma_spectral: float = 0.1, + goal_state: torch.Tensor = None, + pos_only: bool = True, + tensor_args: dict = None, + **kwargs + ): + super(STOMP, self).__init__(name='STOMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + cost=cost, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=pos_only, + tensor_args=tensor_args) + + # STOMP params + self.lr = step_size + self.sigma_spectral = sigma_spectral + + self.start_state = start_state + self.goal_state = goal_state + self.num_samples = num_samples + self.temperature = temperature + + self._particle_means = None + self._weights = None + self._sample_dist = None + + # Precision matrix, shape: [ctrl_dim, n_support_points, n_support_points] + self.Sigma_inv = self._get_R_mat() + self.Sigma = torch.inverse(self.Sigma_inv) + self.reset(initial_particle_means=initial_particle_means) + self.best_cost = torch.inf + + def _get_R_mat(self): + """ + STOMP time-correlated Precision matrix. + """ + upper_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=1) + lower_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=-1,) + diag = -2 * torch.eye(self.n_support_points) + A_mat = upper_diag + diag + lower_diag + A_mat = torch.cat( + (torch.zeros(1, self.n_support_points), + A_mat, + torch.zeros(1, self.n_support_points)), + dim=0, + ) + A_mat[0, 0] = 1. + A_mat[-1, -1] = 1. + A_mat = A_mat * 1./self.dt**2 * self.sigma_spectral + R_mat = A_mat.t() @ A_mat + return R_mat.to(**self.tensor_args) + + def set_noise_dist(self): + """ + Additive Gaussian noise distribution over 1-dimensional trajectory. + """ + self._noise_dist = dist.MultivariateNormal( + torch.zeros(self.num_particles, self.n_support_points, **self.tensor_args), + precision_matrix=self.Sigma_inv, + ) + + def sample(self): + """ + Generate trajectory samples from Gaussian control dist. + Return: position-trajectory samples, of shape: [num_particles, num_samples, n_support_points, n_dof] + """ + noise = self._noise_dist.sample((self.num_samples, self.d_state_opt)).transpose(0, 2).transpose(1, 3).transpose(1, 2) # [num_particles, num_samples, n_support_points, n_dof] + + # Force Bound to zero ## + noise[..., -1, :] = 0 + noise[..., 0, :] = 0 + samples = self._particle_means.unsqueeze(1) + noise + return samples + + def reset( + self, + initial_particle_means=None, + ): + # Straightline position-trajectory from start to goal + if initial_particle_means is not None: + self._particle_means = initial_particle_means.clone() + else: + self._particle_means = self.get_random_trajs() + self.set_noise_dist() + self.state_particles = self.sample() + + def const_vel_trajectory( + self, + start_state, + goal_state, + ): + num_steps = self.n_support_points - 1 + state_traj = torch.zeros(num_steps + 1, self.d_state_opt, **self.tensor_args) + for i in range(num_steps + 1): + state_traj[i, :self.n_dof] = start_state[:self.n_dof] * (num_steps - i) * 1. / num_steps \ + + goal_state[:self.n_dof] * i * 1./num_steps + if not self.pos_only: + mean_vel = (goal_state[:self.n_dof] - start_state[:self.n_dof]) / (num_steps * self.dt) + state_traj[:, self.n_dof:] = mean_vel.unsqueeze(0) + return state_traj + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + self._run_optimization(opt_iters, **observation) + # get best trajectory + curr_traj = self._get_traj() + return curr_traj + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + if opt_iters is None: + opt_iters = self.opt_iters + for opt_step in range(opt_iters): + self.costs = self._sample_and_eval(**observation) + self._update_distribution(self.costs, self.state_particles) + self._particle_means = self._particle_means.detach() + + def _sample_and_eval(self, **observation): + """ + Sample trajectories from distribution and evaluate costs. + """ + # Sample state-trajectory particles + self.state_particles = self.sample() + + ## Optional : Clamp for joint limits + # if isinstance(self.max_control, list): + # for d in range(self.control_dim): + # control_samples[..., d] = control_samples[..., d].clamp( + # min=-self.max_control[d], + # max=self.max_control[d], + # ) + # else: + # control_samples = control_samples.clamp( + # min=-self.max_control, + # max=self.max_control, + # ) + + # Evaluate quadratic costs + costs = self._get_costs(self.state_particles.flatten(0, 1), **observation) + costs = einops.rearrange(costs, "(p s) -> p s", p=self.num_particles, s=self.num_samples) + + ## Optional : Add cost term from importance sampling ratio + # for i in range(self.control_dim): + # Sigma_inv = self.ctrl_dist.Sigma[..., i].inverse() + # V = self.state_particles[..., i] + # U = self.U_mean[..., i] + # costs += self.lambda_ * (V @ Sigma_inv @ U).reshape(-1, 1) + + # Add smoothness term + # smooth_cost = self.state_particles.transpose(1, 2) @ self.Sigma_inv.unsqueeze(0) @ self.state_particles + # costs += smooth_cost.diagonal(dim1=1, dim2=2).sum(-1) + + return costs + + def _update_distribution(self, costs, traj_particles): + """ + Get sample weights and pdate trajectory mean. + """ + self._weights = self._calc_sample_weights(costs) + self._weights = self._weights.reshape(self.num_particles, self.num_samples, 1, 1) + + ## Optional: STOMP Covariance-weighted update + self._particle_means.add_( + self.lr * self.Sigma @ ( + self._weights * (traj_particles - self._particle_means.unsqueeze(1)) + ).sum(1) + ) + + # self._particle_means.add_( + # self.lr * ( + # self._weights * (traj_particles - self._particle_means.unsqueeze(1)) + # ).sum(0) + # ) + + def _calc_sample_weights(self, costs): + return torch.softmax(-costs / self.temperature, dim=1) # num_samples dim diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..39cedf13aa36d477b7e09c5f958a5d17cdf1ebfc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/mp_baselines/planners/utils.py @@ -0,0 +1,50 @@ +import torch + + +def extend_path(distance_fn, q1, q2, max_step=0.03, max_dist=0.1, tensor_args=None): + # max_dist must be <= radius of RRT star! + dist = distance_fn(q1, q2) + if dist > max_dist: + q2 = q1 + (q2 - q1) * (max_dist / dist) + + alpha = torch.linspace(0, 1, int(dist / max_step) + 2).to(**tensor_args) # skip first and last + q1 = q1.unsqueeze(0) + q2 = q2.unsqueeze(0) + extension = q1 + (q2 - q1) * alpha.unsqueeze(1) + return extension + + +def safe_path(sequence, collision_fn): + in_collision = collision_fn(sequence) + idxs_in_collision = torch.argwhere(in_collision) + if idxs_in_collision.nelement() == 0: + if sequence.ndim == 1: + return sequence.reshape((1, -1)) + return sequence[-1].reshape((1, -1)) + else: + first_idx_in_collision = idxs_in_collision[0] + if first_idx_in_collision == 0: + # the first point in the sequence is in collision + return [] + # return the point immediate before the one in collision + return sequence[first_idx_in_collision-1] + + +def purge_duplicates_from_traj(path, eps=1e-6): + # Remove duplicated points from a trajectory + if len(path) < 2: + return path + if isinstance(path, list): + path = torch.stack(path, dim=0) + if path.shape[0] == 2: + return path + + abs_diff = torch.abs(torch.diff(path, dim=-2)) + row_idxs = torch.argwhere(torch.all((abs_diff > eps) == False, dim=-1) == False).unique() + selection = path[row_idxs] + # Always add the first and last elements of the path + if torch.allclose(selection[0], path[0]) is False: + selection = torch.cat((path[0].view(1, -1), selection), dim=0) + if torch.allclose(selection[-1], path[-1]) is False: + selection = torch.cat((selection, path[-1].view(1, -1)), dim=0) + return selection diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b7e2c3ceecc3e5808724a5756d7d8d5da3e724ee --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/requirements.txt @@ -0,0 +1,3 @@ +numpy +matplotlib +cholespy diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..a16ec46c5bbb6e7b4807e45431eb7c7d7ac38bc6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/motion_planning_baselines/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup +from codecs import open +from os import path + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + +setup(name='mp_baselines', + description='Motion Planning Baselines', + author='An T. Le, Joao Carvalho', + author_email='an@robot-learning.de, joao@robot-learning.de', + packages=['mp_baselines'], + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/CITATION.cff b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..1eb6ec957cf945952a32aef09e7cd1ddcfd08ea9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: +- family-names: "Le" + given-names: "An" + affiliation: "TU Darmstadt" +- family-names: "Carvalho" + given-names: "Joao" + affiliation: "TU Darmstadt" +title: "torch_robotics" +version: 0.1.0 +date-released: 15-10-23 +url: "https://github.com/anindex/torch_robotics" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9c052bb71ac9fb2c3cf5180c836d3f317ebfc4da --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 An Thai Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/MANIFEST.in b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..0752b91419620afd59f77fd95c8471a4d5fbb832 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +include CITATION.cff +recursive-include torch_robotics/data * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/README.md b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c198bf94e6c454405c2c200ec68e93a37ffab252 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/README.md @@ -0,0 +1,61 @@ +The original repository is this one: https://github.com/anindex/torch_robotics. +Their original README follows. + +# TorchRobotics + +This library implements differentiable robot tree from URDF or MCJF robot format, and the differentiable planning objects such as obstacle avoidance, self-collision avoidance and via point. + +**NOTE**: `torch_robotics` is under heavy development and highly experimental. + +## Installation + +Simply activate your conda/Python environment and run + +```azure +pip install -e . +``` + +## Examples + +To see FK, IK of all available robot kinematics + +```azure +python examples/forward_kinematics.py +``` + +and + +```azure +python examples/inverse_kinematics.py +``` + +## Acknowledgements + +A part of this implementation is inspired from the library [differentiable robot model](https://github.com/facebookresearch/differentiable-robot-model). + +## Contact + +If you have any questions or find any bugs, please let us know: + +- [An Le](https://www.ias.informatik.tu-darmstadt.de/Team/AnThaiLe), [an@robot-learning.de](an@robot-learning.de) +- [Joao Carvalho](https://www.ias.informatik.tu-darmstadt.de/Team/JoaoCarvalho), [joao@robot-learning.de](joao@robot-learning.de) + +## Citation + +If you found this repository useful, please consider citing these references: + +```azure +@inproceedings{le2023accelerating, + title={Accelerating Motion Planning via Optimal Transport}, + author={Le, An T. and Chalvatzaki, Georgia and Biess, Armin and Peters, Jan}, + booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, + year={2023} +} + +@article{carvalho2023motion, + title={Motion planning diffusion: Learning and planning of robot motions with diffusion models}, + author={Carvalho, Joao and Le, An T and Baierl, Mark and Koert, Dorothea and Peters, Jan}, + journal={arXiv preprint arXiv:2308.01557}, + year={2023} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/forward_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/forward_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..30b502a35bbe2b0669bd90184bbc688c922f1378 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/forward_kinematics.py @@ -0,0 +1,82 @@ +import torch +import time + +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_quat_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda, DifferentiableUR10, \ + DifferentiableTiagoDualHoloMove, DifferentiableShadowHand, DifferentiableAllegroHand, DifferentiableHabitatStretch +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +if __name__ == "__main__": + seed = 1 + fix_random_seed(seed) + + batch_size = 10 + device = "cpu" + # device = "cuda:0" + + print("\n===========================Panda Model===============================") + diff_panda = DifferentiableFrankaPanda(device=device) + diff_panda.print_link_names() + print(diff_panda.get_joint_limits()) + print(diff_panda._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_panda._n_dofs).to(device).requires_grad_(True) + data = diff_panda.compute_forward_kinematics_all_links(q) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================UR10 Model===============================") + diff_ur10 = DifferentiableUR10(device=device) + diff_ur10.print_link_names() + print(diff_ur10.get_joint_limits()) + print(diff_ur10._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_ur10._n_dofs).to(device).requires_grad_(True) + data = diff_ur10.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Habitat Stretch Model===============================") + diff_str = DifferentiableHabitatStretch(device=device) + diff_str.print_link_names() + print(diff_str.get_joint_limits()) + print(diff_str._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_str._n_dofs).to(device).requires_grad_(True) + data = diff_str.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Tiago Model===============================") + diff_tiago = DifferentiableTiagoDualHoloMove(device=device) + diff_tiago.print_link_names() + print(diff_tiago.get_joint_limits()) + print(diff_tiago._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_tiago._n_dofs).to(device).requires_grad_(True) + data = diff_tiago.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Shadow Hand Model===============================") + hand = DifferentiableShadowHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Allegro Hand Model===============================") + hand = DifferentiableAllegroHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/inverse_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/inverse_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..e412cdbd7e7aa503c2b7e434eca5742d03cde3db --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/examples/inverse_kinematics.py @@ -0,0 +1,82 @@ +from copy import copy + +import matplotlib.pyplot as plt +import torch + +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame +from torch_robotics.torch_kinematics_tree.geometrics.skeleton import get_skeleton_from_model +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import y_rot, z_rot +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_rot_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from torch_robotics.visualizers.plot_utils import plot_coordinate_frame + + +if __name__ == "__main__": + seed = 0 + fix_random_seed(seed) + + batch_size = 10 + device = 'cuda' + # device = 'cpu' + tensor_args = dict(device=device, dtype=torch.float32) + + pos_target = torch.tensor([0.2, 0.4, 0.1], **tensor_args) + rot_target = (z_rot(-torch.tensor(torch.pi/2)) @ y_rot(-torch.tensor(torch.pi))).to(**tensor_args) + frame_target = Frame(rot=rot_target, trans=pos_target, device=device) + H_target = frame_target.get_transform_matrix() # set translation and orientation of target here + + print("===========================Panda Model===============================") + # Panda Kinematics + diff_panda = DifferentiableFrankaPanda(gripper=False, device=device) + + with TimerCUDA() as t: + q_ik, idx_valid = diff_panda.inverse_kinematics( + H_target, link_name='ee_link', batch_size=batch_size, max_iters=500, lr=2e-1, se3_eps=5e-2, + eps_joint_lim=torch.pi/64, + # print_freq=-1, + debug=False + ) + print(f"\nIK time: {t.elapsed:.3f} sec") + + print(f'idx_valid: {idx_valid.nelement()}/{batch_size}') + + ############################################################################################################ + # plot the result + fig, ax = create_fig_and_axes(3) + ax.set_aspect('equal') + ax.set_xlim(-0.75, 0.75) + ax.set_ylim(-0.75, 0.75) + ax.set_zlim(-0.5, 1) + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + + # Origin frame + frame_origin = Frame(device=device) + ax.plot(0, 0, 0, color='b', marker='D', markersize=10, zorder=100) + plot_coordinate_frame(ax, frame_origin, arrow_length=0.1, arrow_linewidth=3., tensor_args=tensor_args) + + # Target frame + target_pos_np = to_numpy(pos_target) + ax.plot(target_pos_np[0], target_pos_np[1], target_pos_np[2], 'r*', markersize=20, zorder=100) + plot_coordinate_frame(ax, frame_target, arrow_length=0.15, arrow_linewidth=3., tensor_args=tensor_args) + + # Robot + for q in q_ik[idx_valid]: + skeleton = get_skeleton_from_model(diff_panda, q, diff_panda.get_link_names()) # visualize IK solution + skeleton.draw_skeleton(ax=ax, color='blue') + + # draw EE frame + H_EE = diff_panda.compute_forward_kinematics_all_links(q.unsqueeze(0), link_list=['ee_link']) + frame_EE = Frame( + rot=link_rot_from_link_tensor(H_EE).squeeze(), + trans=link_pos_from_link_tensor(H_EE).squeeze(), + device=device + ) + plot_coordinate_frame(ax, frame_EE, arrow_length=0.1, arrow_alpha=0.5, tensor_args=tensor_args) + + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c8cdb7b1095c010a77a5dbe88780e89b992b247 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/requirements.txt @@ -0,0 +1,5 @@ +pyyaml +urdf-parser-py +matplotlib +trimesh +einops diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..911e23eac7991a75b5c6ea6c4d0744143aa152cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/setup.py @@ -0,0 +1,23 @@ +import setuptools +from codecs import open +from os import path + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + + +setuptools.setup( + name='torch_robotics', + description='Full Differentiable Kinematics Tree Implementation constructed from URDF', + author='An Thai Le, Joao Carvalho', + author_email='an@robots-learning.de, joao@robots-learning.de', + packages=setuptools.find_namespace_packages(), + include_package_data=True, + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4ab39114978f0d41268f26aa037d4f9f010f652 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/__init__.py @@ -0,0 +1,20 @@ +from .env_empty_2d import * +from .env_empty_2d_extra_objects import * +from .env_conveyor_2d import * +from .env_conveyor_2d_extra_objects import * +from .env_drop_region_2d import * +from .env_drop_region_2d_extra_objects import * +from .env_highways_2d import * +from .env_highways_2d_extra_objects import * +from .env_ensemble import * +from .env_drop_region_2d import * +from .env_drop_region_2d_extra_objects import * +from .env_highways_2d import * +from .env_highways_2d_extra_objects import * +from .env_empty_nowait_2d import * +from .env_empty_nowait_2d_extra_objects import * + +# hard-map additions (scenario1_hard_maps) +from .env_cross_room_2d import * +from .env_corridor_smd_2d import * +from torch_robotics.environments.env_robotarium_2d import EnvRobotarium2D diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_base.py new file mode 100644 index 0000000000000000000000000000000000000000..94cdeab21303320398280699f1020590f2aadf82 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_base.py @@ -0,0 +1,341 @@ +import itertools +from abc import ABC +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments.grid_map_sdf import GridMapSDF +from torch_robotics.environments.occupancy_map import OccupancyMap +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy, DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvBase(ABC): + + def __init__(self, + name='NameEnvBase', + limits=None, + obj_fixed_list=None, + obj_extra_list=None, + precompute_sdf_obj_fixed=False, + sdf_cell_size=0.005, + tensor_args=None, + **kwargs + ): + self.tensor_args = tensor_args + + # Robotarium port: the physical arena is 3.2 x 2.0 m while every env ships a + # [-1,1]^2 workspace. RBT_ARENA=1 widens x to +-1.6 so the side margins exist for + # the planner at all; without this, obstacles or endpoints beyond |x|>1 are + # silently outside the SDF grid and the configuration space. Opt-in via env var + # so nothing outside the port changes. + import os as _os, torch as _torch + if _os.environ.get('RBT_ARENA') == '1': + limits = _torch.tensor([[-1.6, -1.0], [1.6, 1.0]], **(tensor_args or {})) + + self.name = name + + # Workspace + assert limits is not None + self.limits = limits + self.limits_np = to_numpy(limits) + self.dim = len(limits[0]) + + ################################################################################################ + # Objects + if obj_fixed_list is not None: + for obj in obj_fixed_list: + assert (isinstance(obj, ObjectField)), "Objects must be instances of ObjectField class" + if obj_extra_list is not None: + for obj in obj_extra_list: + assert (isinstance(obj, ObjectField)), "Objects must be instances of ObjectField class" + + self.obj_fixed_list = obj_fixed_list + self.obj_extra_list = obj_extra_list + self.obj_all_list = set(itertools.chain.from_iterable(( + self.obj_fixed_list if self.obj_fixed_list is not None else [], + self.obj_extra_list if self.obj_extra_list is not None else []) + )) + self.simplify_primitives() + + ################################################################################################ + # Precompute the SDF map of fixed objects + self.grid_map_sdf_obj_fixed = None + if precompute_sdf_obj_fixed: + with TimerCUDA() as t: + # Compute SDF grid + self.grid_map_sdf_obj_fixed = GridMapSDF( + self.limits, sdf_cell_size, self.obj_fixed_list, tensor_args=self.tensor_args + ) + print(f'Precomputing the SDF grid and gradients took: {t.elapsed:.3f} sec') + + ################################################################################################ + # Occupancy map + self.occupancy_map = None + self.cell_size = None + + def get_obj_list(self): + return self.obj_all_list + + def get_df_obj_list(self, return_extra_objects_only=False): + df_obj_l = [] + if not return_extra_objects_only: + # fixed objects + if self.grid_map_sdf_obj_fixed is not None: + df_obj_l.append(self.grid_map_sdf_obj_fixed) + else: + df_obj_l.extend(self.obj_fixed_list) + + # obj_extra_list objects + if self.obj_extra_list is not None: + df_obj_l.extend(self.obj_extra_list) + + return df_obj_l + + def add_obj(self, obj): + # Adds an object to the environments + raise NotImplementedError + self.simplify_primitives() + + def simplify_primitives(self): + return + # Groups primitives of the same type for faster batched computation + raise NotImplementedError + + def build_occupancy_map(self, cell_size=None): + self.cell_size = cell_size + # Make occupancy grid + map_dim = torch.abs(self.limits[1] - self.limits[0]) + occ_map = OccupancyMap(map_dim, self.cell_size, tensor_args=self.tensor_args) + for obj in self.obj_all_list: + obj.add_to_occupancy_map(occ_map) + occ_map.convert_map_to_torch() + self.occupancy_map = occ_map + + def add_obstacle_primitive(self, obst_primitive): + raise NotImplementedError + self.obst_primitives_l.append(obst_primitive) + self.simplify_primitives() + + def zero_grad(self): + for obj in self.obj_all_list: + try: + obj.zero_grad() + except: + raise NotImplementedError + + def render(self, ax=None): + if self.obj_fixed_list is not None: + for obj in self.obj_fixed_list: + obj.render(ax) + + if self.obj_extra_list is not None: + for obj in self.obj_extra_list: + obj.render(ax, color='red', cmap='Reds') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + # if self.use_occupancy_map: + # res = self.occupancy_map.map.shape[0] + # x = np.linspace(self.limits[0][0], self.limits[1][0], res) + # y = np.linspace(self.limits[0][1], self.limits[1][1], res) + # map = self.occupancy_map.map + # map[map > 1] = 1 + # ax.contourf(x, y, map, 2, cmap='Greys') + # ax.set_aspect('equal') + # ax.set_facecolor('white') + + def compute_sdf(self, x, reshape_shape=None): + # compute sdf of fixed objects + sdf = None + # if the sdf of fixed objects is precomputed, then use it + if self.grid_map_sdf_obj_fixed is not None: + sdf = self.grid_map_sdf_obj_fixed(x) + if reshape_shape: + sdf = sdf.reshape(reshape_shape) + else: + for obj in self.obj_fixed_list: + sdf_obj = obj.compute_signed_distance(x) + if reshape_shape is not None: + sdf_obj = sdf_obj.reshape(reshape_shape) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + + # compute sdf of extra objects + if self.obj_extra_list is not None: + for obj in self.obj_extra_list: + sdf_obj = obj.compute_signed_distance(x) + if reshape_shape is not None: + sdf_obj = sdf_obj.reshape(reshape_shape) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + + return sdf + + def render_sdf(self, ax=None, fig=None): + # draw sdf + xs = torch.linspace(self.limits_np[0][0], self.limits_np[1][0], steps=200, **self.tensor_args) + ys = torch.linspace(self.limits_np[0][1], self.limits_np[1][1], steps=200, **self.tensor_args) + if self.dim == 3: + zs = torch.linspace(self.limits_np[0][2], self.limits_np[1][2], steps=200, **self.tensor_args) + X, Y, Z = torch.meshgrid(xs, ys, zs, indexing='xy') + else: + X, Y = torch.meshgrid(xs, ys, indexing='xy') + + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + if self.dim == 3: + Z_flat = torch.flatten(Z) + stacked_tensors = torch.stack((X_flat, Y_flat, Z_flat), dim=-1).view(-1, 1, self.dim) + else: + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, self.dim) + sdf = self.compute_sdf(stacked_tensors, reshape_shape=X.shape) + + sdf_np = to_numpy(sdf) + if self.dim == 3: + idxs_sdf = torch.where(sdf < 0) + random_idxs = np.random.choice(np.arange(len(idxs_sdf[0])), size=5000, replace=False) # for downsampling + idxs = idxs_sdf[0][random_idxs], idxs_sdf[1][random_idxs], idxs_sdf[2][random_idxs] + ax.scatter(to_numpy(X[idxs]), to_numpy(Y[idxs]), to_numpy(Z[idxs])) + else: + ctf = ax.contourf(to_numpy(X), to_numpy(Y), sdf_np) + if fig is not None: + fig.colorbar(ctf, orientation='vertical') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_title('Binary occupancy map') + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + def render_grad_sdf(self, ax=None, fig=None): + # draw gradient of sdf + xs = torch.linspace(self.limits_np[0][0], self.limits_np[1][0], steps=40, **self.tensor_args) + ys = torch.linspace(self.limits_np[0][1], self.limits_np[1][1], steps=40, **self.tensor_args) + if self.dim == 3: + zs = torch.linspace(self.limits_np[0][2], self.limits_np[1][2], steps=20, **self.tensor_args) + X, Y, Z = torch.meshgrid(xs, ys, zs, indexing='xy') + else: + X, Y = torch.meshgrid(xs, ys, indexing='xy') + + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + if self.dim == 3: + Z_flat = torch.flatten(Z) + stacked_tensors = torch.stack((X_flat, Y_flat, Z_flat), dim=-1).view(-1, 1, self.dim) + else: + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, self.dim) + + f_grad_sdf = lambda x: self.compute_sdf(x, reshape_shape=X.shape).sum() + grad_sdf = jacobian(f_grad_sdf, stacked_tensors) + + grad_sdf_np = to_numpy(grad_sdf).squeeze() + if self.dim == 3: + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), to_numpy(Z_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], grad_sdf_np[:, 2], + length=0.1, normalize=True, + color='red') + else: + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], + color='red') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + def get_rrt_connect_params(self, robot=None): + raise NotImplementedError + + def get_sgpmp_params(self, robot=None): + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + raise NotImplementedError + + def get_chomp_params(self, robot=None): + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Plot the trajectory. It is a tensor of shape (horizon, state_dim). + fig, ax = plt.subplots() + ax.plot(path[:, 0].detach().cpu().numpy(), path[:, 1].detach().cpu().numpy(), 'r') + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + plt.show() + + return -float('inf') + + def is_start_goal_valid_for_data_gen(self, robot, start_pos, goal_pos): + print("DEFAULTING TO TRUE IN IS_START_GOAL_VALID_FOR_DATA_GEN FUNCTION") + return True + +if __name__ == '__main__': + tensor_args = DEFAULT_TENSOR_ARGS + spheres = MultiSphereField(torch.zeros(2, **tensor_args).view(1, -1), + torch.ones(1, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + boxes = MultiBoxField(torch.zeros(2, **tensor_args).view(1, -1) + 0.5, + torch.ones(2, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + obj_field = ObjectField([spheres, boxes]) + + theta = np.deg2rad(45) + # obj_field.set_position_orientation(pos=[-0.5, 0., 0.]) + # obj_field.set_position_orientation(ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + obj_field.set_position_orientation(pos=[-0.5, 0., 0.], ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + + ############################################################################################################## + env = EnvBase( + name='DummyEnv', + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[obj_field], + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + tensor_args=tensor_args, + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..694a504242bd4dbcb5acfa11a876b1a6bded7a3c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py @@ -0,0 +1,297 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvConveyor2D(EnvBase): + + def __init__(self, + name='EnvConveyor2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + MultiBoxField( + np.array([ + [0, 0], + [0, 0.35], + [0, -0.35] + ] if __import__('os').environ.get('RBT_WIDE') != '1' else [ + # RBT_WIDE: outer walls shifted +-0.05 m outward so each channel + # opens from 0.24 to 0.29 m -- a single r=0.13 robot (0.26 needed) + # fits single-file. Everything else (lengths, scale) unchanged. + [0, 0], + [0, 0.40], + [0, -0.40] + ]), + np.array([ + [0.8, 0.1], + [1.0, 0.1], + [1.0, 0.1] + ] if __import__('os').environ.get('RBT_HARDEN') != 'v4' else [ + # v4 hardening: walls doubled in LENGTH only (user spec). Requires + # RBT_ARENA=1 and the columns start config -- doubled walls swallow + # the 0.80 ring. + [1.6, 0.1], + [2.0, 0.1], + [2.0, 0.1] + ]), + tensor_args=tensor_args + ), + + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'conveyor2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=2, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + # Options for position sequences that all paths must go through (each path must choose one). This neeed not be uniformly discretized. The optimizer + # and interpolator will take care of that. + skill0_start = torch.tensor([-0.6, -0.2], **self.tensor_args) + skill0_end = torch.tensor([0.6, -0.2], **self.tensor_args) + skill0 = torch.stack( + [torch.lerp(skill0_start, skill0_end, alpha) for alpha in torch.linspace(0, 1, 30, **self.tensor_args)]) + skill1_start = torch.tensor([0.6, 0.2], **self.tensor_args) + skill1_end = torch.tensor([-0.6, 0.2], **self.tensor_args) + skill1 = torch.stack( + [torch.lerp(skill1_start, skill1_end, alpha) for alpha in torch.linspace(0, 1, 30, **self.tensor_args)]) + skill_pos_sequence_l = [skill0, skill1] + return skill_pos_sequence_l + else: + raise NotImplementedError + + def buggy_compute_traj_data_adherence(self, path: torch.Tensor): + # Compute the data adherence of the path. + # A trajectory is said to be "adhering" if it has either + # 1. Entered the top corridor from the right and left from the left. + # 2. Entered the bottom corridor from the left and left from the right. + # Iterate through the path and find the time, if any, agent passes + # through entrance, middle, and exit of a corridor. + t_top_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + t_bottom_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + q_top_corridor = torch.tensor([[0.6, 0.2], [0.0, 0.2], [-0.6, 0.2]], **self.tensor_args) + q_bottom_corridor = torch.tensor([[-0.6, -0.2], [0.0, -0.2], [0.6, -0.2]], **self.tensor_args) + for t in range(path.shape[0]): + # Always only check the next available waypoint. + next_available_ix_top = torch.argmin(t_top_corridor) + q_top_next = q_top_corridor[next_available_ix_top, :] + if torch.norm(path[t, :] - q_top_next) < 0.2: + t_top_corridor[next_available_ix_top] = t + next_available_ix_bottom = torch.argmin(t_bottom_corridor) + q_bottom_next = q_bottom_corridor[next_available_ix_bottom, :] + if torch.norm(path[t, :] - q_bottom_next) < 0.2: + t_bottom_corridor[next_available_ix_bottom] = t + if torch.all(t_top_corridor != -1) or torch.all(t_bottom_corridor != -1): + return 1 + else: + return 0 + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Compute the data adherence of the path. + # A trajectory is said to be "adhering" if it has either + # 1. Entered the top corridor from the right and left from the left. + # 2. Entered the bottom corridor from the left and left from the right. + # Iterate through the path and find the time, if any, agent passes + # through entrance, middle, and exit of a corridor. + t_top_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + t_bottom_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + q_top_corridor = torch.tensor([[0.6, 0.2], [0.0, 0.2], [-0.6, 0.2]], **self.tensor_args) + q_bottom_corridor = torch.tensor([[-0.6, -0.2], [0.0, -0.2], [0.6, -0.2]], **self.tensor_args) + # Match corridor waypoints in order and only advance the next unmet waypoint. + for t in range(path.shape[0]): + next_available_ix_top = torch.argmin(t_top_corridor) + q_top_next = q_top_corridor[next_available_ix_top, :] + if torch.norm(path[t, :] - q_top_next) < 0.2: + t_top_corridor[next_available_ix_top] = t + next_available_ix_bottom = torch.argmin(t_bottom_corridor) + q_bottom_next = q_bottom_corridor[next_available_ix_bottom, :] + if torch.norm(path[t, :] - q_bottom_next) < 0.2: + t_bottom_corridor[next_available_ix_bottom] = t + ## Fix the bug of only checking the 1st waypoint. + if torch.all(t_top_corridor >= 0) or torch.all(t_bottom_corridor >= 0): + return 1 + else: + return 0 + + def compute_traj_data_adherence_batch(self, path: torch.Tensor): + batch_shape = path.shape[:-2] + H = path.shape[-2] + + q_top_corridor = torch.tensor([[0.6, 0.2], [0.0, 0.2], [-0.6, 0.2]], **self.tensor_args) + q_bottom_corridor = torch.tensor([[-0.6, -0.2], [0.0, -0.2], [0.6, -0.2]], **self.tensor_args) + # Each trajectory advances through the corridor waypoints in the same fixed order. + + t_top_corridor = torch.full((*batch_shape, 3), -1.0, **self.tensor_args) + t_bottom_corridor = torch.full((*batch_shape, 3), -1.0, **self.tensor_args) + + top_done = torch.zeros(batch_shape, dtype=torch.bool, device=self.tensor_args['device']) + bottom_done = torch.zeros(batch_shape, dtype=torch.bool, device=self.tensor_args['device']) + + for t in range(H): + if (top_done | bottom_done).all(): + break + + pos_t = path[..., t, :] + + active_mask_top = ~top_done + if active_mask_top.any(): + next_avail_top = torch.argmin(t_top_corridor[active_mask_top], dim=-1) + q_top_next = q_top_corridor[next_avail_top] + + dist_top = torch.norm(pos_t[active_mask_top] - q_top_next, dim=-1) + reached_top_mask = dist_top < 0.2 + + if reached_top_mask.any(): + update_indices = active_mask_top.nonzero(as_tuple=True) + final_update_indices = tuple(u[reached_top_mask] for u in update_indices) + point_indices_to_update = next_avail_top[reached_top_mask] + full_indices = final_update_indices + (point_indices_to_update,) + t_top_corridor[full_indices] = t + + active_mask_bottom = ~bottom_done + if active_mask_bottom.any(): + next_avail_bottom = torch.argmin(t_bottom_corridor[active_mask_bottom], dim=-1) + q_bottom_next = q_bottom_corridor[next_avail_bottom] + dist_bottom = torch.norm(pos_t[active_mask_bottom] - q_bottom_next, dim=-1) + reached_bottom_mask = dist_bottom < 0.2 + + if reached_bottom_mask.any(): + update_indices = active_mask_bottom.nonzero(as_tuple=True) + final_update_indices = tuple(u[reached_bottom_mask] for u in update_indices) + point_indices_to_update = next_avail_bottom[reached_bottom_mask] + full_indices = final_update_indices + (point_indices_to_update,) + t_bottom_corridor[full_indices] = t + + top_done = (t_top_corridor >= 0).all(dim=-1) + bottom_done = (t_bottom_corridor >= 0).all(dim=-1) + + return (top_done | bottom_done).long() + + +if __name__ == '__main__': + env = EnvConveyor2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..2b2e9aefba6c77f373c24f42658356a532938c5f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py @@ -0,0 +1,88 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard includes. +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +# Project includes. +from torch_robotics.environments import EnvConveyor2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvConveyor2DExtraObjects(EnvConveyor2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.0], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.2], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'conveyor2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvConveyor2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc5b6d0dbc982e49bc1e59a0a9cd6be228cc41a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py @@ -0,0 +1,120 @@ +"""SMD Fig. 3a 'Corridor' -- parallel single-file channels joined by a passing cross-roads. + +The map's challenge is SEQUENCING: channels too narrow for two robots to pass, so they +must take turns. Two earlier designs failed to deliver that: + * the shipped env_narrow_corridors.py (nine spheres of r=0.8375 per wall) leaves a + channel 0.328 wide -- two robots pass abreast easily -- and its scalloping is only a + 1.2x ripple, so there is no real bay; + * a single straight channel with one alcove IS single-file, but a sub-0.20 channel caps + free space near 10% of the arena, about 13 robots standing still. It cannot host 21. + +So: THREE parallel channels, each 0.24 wide (one robot needs 0.21, two need 0.42), joined by a horizontal cross-roads at mid-height that IS wide enough for two, with +open staging areas top and bottom. Robots funnel single-file and must resolve who yields, +while the map still holds a large team. +""" +import numpy as np +import torch + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiBoxField, MultiSphereField + + +class EnvCorridorSMD2D(EnvBase): + + def __init__(self, name='EnvCorridorSMD2D', tensor_args=None, + precompute_sdf_obj_fixed=True, sdf_cell_size=0.005, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + # Four wall columns, each split into an upper and a lower block. What is left: + # channels at x = -0.60, 0.00, 0.60, width 0.16 (single file) + # cross-roads at |y| < 0.12, height 0.24 (two abreast) + # staging areas at |y| > 0.60 (open) + # Channel width 0.36. Sized against the BINDING margin, which is not the one I first + # used: PP's start/goal sampler hardcodes obstacle_margin = 0.16 + # (multi_agent_utils.get_start_goal_pos_random_in_env), stricter than the + # validator's 0.105. At 0.24 only 0.34 m^2 of the map cleared 0.16 while 21 robots + # at 0.15 separation need ~0.44 m^2, so every one of 501 PP attempts failed. At + # 0.36 that area is 0.91 m^2. Still under 0.42, so two robots cannot pass abreast + # and the single-file property -- the point of the map -- survives. + cols = [(-0.89, 0.22), (-0.3, 0.24), (0.3, 0.24), (0.89, 0.22)] + centers, sizes = [], [] + for cx, cw in cols: + for sy in (+0.31, -0.31): + centers.append([cx, sy]) + sizes.append([cw, 0.38]) + obj_list = [ + MultiBoxField(np.array(centers), np.array(sizes), tensor_args=tensor_args), + ] + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[ObjectField(obj_list, 'corridorsmd2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None): + """Required override: EnvBase raises NotImplementedError, and generate_trajectories + calls this unconditionally BEFORE any planning -- so data generation dies on + context 0 without it. None means "no skill prior", the same as env_empty_2d.py:122. + """ + return None + + def compute_traj_data_adherence(self, path: torch.Tensor): + """Required override: the EnvBase default calls plt.show() -- a BLOCKING GUI call + inside the per-agent, per-trial inference loop -- and then returns -inf, which + silently poisons the data-adherence metric for every path. This map has no + preferred direction of travel, so adherence is defined as straight-line + efficiency: chord length over path length, in (0, 1]. + """ + if path.shape[0] < 2: + return 1.0 + seg = torch.linalg.norm(path[1:, :2] - path[:-1, :2], dim=-1).sum() + chord = torch.linalg.norm(path[-1, :2] - path[0, :2]) + if seg <= 0: + return 1.0 + return float(torch.clamp(chord / seg, 0.0, 1.0)) + + def compute_traj_data_adherence_batch(self, paths: torch.Tensor) -> torch.Tensor: + """Batched form of compute_traj_data_adherence, added for the pcdiff/DiRecT + inference harness (mmd/common/eval_utils.py:calc_data_adherence asserts on it). + Same definition as the scalar method above: straight-line efficiency, + chord / path length in (0, 1]. paths: (..., H, D) -> (...). + """ + pos = paths[..., :2] + seg = torch.linalg.norm(pos[..., 1:, :] - pos[..., :-1, :], dim=-1).sum(-1) + chord = torch.linalg.norm(pos[..., -1, :] - pos[..., 0, :], dim=-1) + return torch.clamp(chord / (seg + 1e-12), 0.0, 1.0) + + def get_rrt_connect_params(self, robot=None): + return dict(n_iters=500, step_size=0.01, n_radius=0.05, + n_pre_samples=50000, max_time=50) + + def get_gpmp2_params(self, robot=None): + # The *_init / *_sample sigmas and solver_params are NOT optional: the trajectory + # generator builds UnaryFactor(sigma=...) from them, and a missing key surfaces as + # `TypeError: unsupported operand type(s) for **: 'NoneType' and 'int'` deep inside + # the factor graph, not as a missing-key error. Copied verbatim from + # env_highways_2d.py:104-124 so the new maps are planned with identical settings. + return dict( + n_support_points=64, dt=0.04, opt_iters=20, num_samples=64, + sigma_start=1e-5, sigma_gp=1e-2, sigma_goal_prior=1e-5, + sigma_coll=1e-5, step_size=1e-1, + sigma_start_init=1e-4, sigma_goal_init=1e-4, sigma_gp_init=1e-5, + sigma_start_sample=1e-4, sigma_goal_sample=1e-4, + solver_params={'delta': 1e-2, 'trust_region': True, 'method': 'cholesky'}, + ) + + +class EnvCorridorSMD2DExtraObjects(EnvCorridorSMD2D): + + def __init__(self, tensor_args=None, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + obj_extra_list = [MultiSphereField(np.array([]), np.array([]), tensor_args=tensor_args)] + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'corridorsmd2d-extraobjects')], + tensor_args=tensor_args, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..98e51bcf1e89b6dffc85b970638c7139428e7afd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py @@ -0,0 +1,115 @@ +"""SMD Fig. 3c 'Room' -- a cross-shaped divider splitting the workspace into four rooms. + +SMD's Figure 3 shows three practical maps: a narrow corridor (3a), a shelf (3b) and a +room (3c). Only the corridor ships as code (env_narrow_corridors.py, itself unexported). +This reconstructs 3c: a plus-shaped obstacle at the centre whose arms stop short of the +boundary, so the four quadrants connect only through the four gaps at the edges. That is +the property that makes it interesting for MRMP -- every cross-quadrant trip funnels +through one of four openings, so teams contend for them. + +Built from boxes rather than spheres so the walls are straight, matching the figure. +Arm half-length 0.70 leaves a 0.30 gap at each edge; the OBS_SCALE hook on MultiBoxField +narrows those gaps directly. +""" +import numpy as np +import torch + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiBoxField + + +class EnvCrossRoom2D(EnvBase): + + def __init__(self, name='EnvCrossRoom2D', tensor_args=None, + precompute_sdf_obj_fixed=True, sdf_cell_size=0.005, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + _c = [[0.0, 0.0], [0.0, 0.0]] + _z = [[1.40, 0.18], [0.18, 1.40]] + # v4 hardening: one full cross centred on each side-edge midpoint (user spec). + # Outer halves lie beyond |x|=1.6 and are inert; needs RBT_ARENA=1. + if __import__('os').environ.get('RBT_HARDEN') == 'v4': + for _sx in (1.6, -1.6): + _c += [[_sx, 0.0], [_sx, 0.0]] + _z += [[1.40, 0.18], [0.18, 1.40]] + obj_list = [ + MultiBoxField( + np.array(_c), + np.array(_z), + tensor_args=tensor_args + ), + ] + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[ObjectField(obj_list, 'crossroom2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None): + """Required override: EnvBase raises NotImplementedError, and generate_trajectories + calls this unconditionally BEFORE any planning -- so data generation dies on + context 0 without it. None means "no skill prior", the same as env_empty_2d.py:122. + """ + return None + + def compute_traj_data_adherence(self, path: torch.Tensor): + """Required override: the EnvBase default calls plt.show() -- a BLOCKING GUI call + inside the per-agent, per-trial inference loop -- and then returns -inf, which + silently poisons the data-adherence metric for every path. This map has no + preferred direction of travel, so adherence is defined as straight-line + efficiency: chord length over path length, in (0, 1]. + """ + if path.shape[0] < 2: + return 1.0 + seg = torch.linalg.norm(path[1:, :2] - path[:-1, :2], dim=-1).sum() + chord = torch.linalg.norm(path[-1, :2] - path[0, :2]) + if seg <= 0: + return 1.0 + return float(torch.clamp(chord / seg, 0.0, 1.0)) + + def compute_traj_data_adherence_batch(self, paths: torch.Tensor) -> torch.Tensor: + """Batched form of compute_traj_data_adherence, added for the pcdiff/DiRecT + inference harness (mmd/common/eval_utils.py:calc_data_adherence asserts on it). + Same definition as the scalar method above: straight-line efficiency, + chord / path length in (0, 1]. paths: (..., H, D) -> (...). + """ + pos = paths[..., :2] + seg = torch.linalg.norm(pos[..., 1:, :] - pos[..., :-1, :], dim=-1).sum(-1) + chord = torch.linalg.norm(pos[..., -1, :] - pos[..., 0, :], dim=-1) + return torch.clamp(chord / (seg + 1e-12), 0.0, 1.0) + + def get_rrt_connect_params(self, robot=None): + return dict(n_iters=500, step_size=0.01, n_radius=0.05, + n_pre_samples=50000, max_time=50) + + def get_gpmp2_params(self, robot=None): + # The *_init / *_sample sigmas and solver_params are NOT optional: the trajectory + # generator builds UnaryFactor(sigma=...) from them, and a missing key surfaces as + # `TypeError: unsupported operand type(s) for **: 'NoneType' and 'int'` deep inside + # the factor graph, not as a missing-key error. Copied verbatim from + # env_highways_2d.py:104-124 so the new maps are planned with identical settings. + return dict( + n_support_points=64, dt=0.04, opt_iters=20, num_samples=64, + sigma_start=1e-5, sigma_gp=1e-2, sigma_goal_prior=1e-5, + sigma_coll=1e-5, step_size=1e-1, + sigma_start_init=1e-4, sigma_goal_init=1e-4, sigma_gp_init=1e-5, + sigma_start_sample=1e-4, sigma_goal_sample=1e-4, + solver_params={'delta': 1e-2, 'trust_region': True, 'method': 'cholesky'}, + ) + + +class EnvCrossRoom2DExtraObjects(EnvCrossRoom2D): + + def __init__(self, tensor_args=None, **kwargs): + from torch_robotics.environments.primitives import MultiSphereField + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + obj_extra_list = [MultiSphereField(np.array([]), np.array([]), tensor_args=tensor_args)] + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'crossroom2d-extraobjects')], + tensor_args=tensor_args, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..8c006edc2989ea662b4711c1453736a6b059aa65 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py @@ -0,0 +1,259 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDropRegion2D(EnvBase): + + def __init__(self, + name='EnvDropRegion2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + _centers = [ + [0.4, 0.4], + [-0.4, 0.4], + [0.4, -0.4], + [-0.4, -0.4], + ] + _sizes = [[0.4, 0.4]] * 4 + # Robotarium hardening candidate 'shelf4' (opt-in via env var; baseline unchanged). + # Four more copies of the SAME nominal box, outward of the originals at the same + # heights. Nominal 0.4 renders 0.16 under OBS_SCALE=0.4 (sizes scale about each + # box's own centre; centers do not scale). Endpoint check: every 0.80-ring point + # stays > 2.1*robot_r clear of these, so the shipped instance set remains valid. + import os as _os + if _os.environ.get('RBT_HARDEN') in ('shelf4', 'v4'): + _centers += [[1.1, 0.4], [1.1, -0.4], [-1.1, 0.4], [-1.1, -0.4]] + _sizes += [[0.4, 0.4]] * 4 + obj_list = [ + MultiBoxField( + np.array(_centers), + np.array(_sizes), + tensor_args=tensor_args + ), + ] + + self.drop_region_centers = [ + [0.4, 0.75], + [0.4, 0.05], + [0.4, -0.05], + [0.4, -0.75], + [-0.4, 0.75], + [-0.4, 0.05], + [-0.4, -0.05], + [-0.4, -0.75], + [0.75, 0.4], + [0.05, 0.4], + [-0.05, 0.4], + [-0.75, 0.4], + [0.75, -0.4], + [0.05, -0.4], + [-0.05, -0.4], + [-0.75, -0.4] + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'dense2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=2, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return [ + # Top and bottom of drop regions. + torch.tensor([c] * 35, **self.tensor_args) for c in self.drop_region_centers + ] + else: + raise NotImplementedError + + + def buggy_compute_traj_data_adherence(self, path: torch.Tensor, drop_region_radius=0.15, ratio_traj_steps_in_region=0.25): + # Compute the data adherence of the path. + # We mark 1 if the trajectory visits any of the drop regions for at least num_steps consecutive time steps. + num_steps_in_region = int(path.shape[0] * ratio_traj_steps_in_region) + for c in self.drop_region_centers: + region_center = torch.tensor(c, **self.tensor_args) + dist = torch.norm(path - region_center, dim=-1) + in_region_mask = dist < drop_region_radius + print("AT REGION:", torch.sum(in_region_mask.float())) + for i in range(num_steps_in_region, len(path)): + if in_region_mask[i - num_steps_in_region:i].all(): + return 1.0 + return 0.0 + + + def compute_traj_data_adherence(self, path: torch.Tensor, drop_region_radius=0.15, ratio_traj_steps_in_region=0.25): + num_steps_in_region = int(path.shape[0] * ratio_traj_steps_in_region) + for c in self.drop_region_centers: + region_center = torch.tensor(c, **self.tensor_args) + dist = torch.norm(path - region_center, dim=-1) + in_region_mask = dist < drop_region_radius + # `len(path) + 1` keeps the final length-k slice in bounds. + for i in range(num_steps_in_region, len(path)+1): + if in_region_mask[i - num_steps_in_region:i].all(): + return 1.0 + return 0.0 + + + def compute_traj_data_adherence_batch(self, path: torch.Tensor, drop_region_radius=0.15, ratio_traj_steps_in_region=0.25): + """ + Vectorized version. + Args: + path: Tensor of shape [..., H, D] where H = time steps, D = state dim. + Returns: + Tensor of shape [...] with 1.0/0.0 per trajectory + """ + assert path.ndim >= 2, "path must be at least [H, D]" + if path.ndim == 2: + path = path.unsqueeze(0) # [1, H, D] + H = path.shape[-2] + k = int(H * ratio_traj_steps_in_region) + + if k <= 0: + adheres = torch.ones(path.shape[:-2], dtype=path.dtype, device=path.device) + return float(adheres.item()) if path.ndim == 2 else adheres + if k > H: + adheres = torch.zeros(path.shape[:-2], dtype=path.dtype, device=path.device) + return float(adheres.item()) if path.ndim == 2 else adheres + + centers:torch.Tensor = torch.as_tensor(self.drop_region_centers, **self.tensor_args) + centers = centers.to(path.device, path.dtype) # [C, D] + + if centers.numel() == 0: + adheres = torch.zeros(path.shape[:-2], dtype=path.dtype, device=path.device) + return float(adheres.item()) if path.ndim == 2 else adheres + + dists = torch.cdist(path, centers) # [..., H, C] + in_region_per_center = dists < drop_region_radius # [..., H, C] + + # [..., H, C] -> unfold along H + wins = in_region_per_center.unfold(dimension=-2, size=k, step=1) # [..., H-k+1, C, k] + consec_per_center_anytime = (wins.all(dim=-1)).any(dim=-2) # [..., C] + has_run = consec_per_center_anytime.any(dim=-1) # [...] + + adheres = has_run.to(path.dtype) + + return adheres + + + +if __name__ == '__main__': + env = EnvDropRegion2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..d8bd24923911c2c1e423aa47e4d0afc8ddba1f2d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py @@ -0,0 +1,63 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvDropRegion2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDropRegion2DExtraObjects(EnvDropRegion2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'dropregion2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvDropRegion2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..d1094bafd178b2fc8bd37fc44129417633cb7a89 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py @@ -0,0 +1,188 @@ +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from mmd.config.mmd_params import MMDParams as params + + +class EnvEmpty2D(EnvBase): + + def __init__(self, + name='EnvEmpty2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'empty2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=300, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + return None + + def compute_traj_data_adherence(self, path: torch.Tensor, + fraction_of_length=params.data_adherence_linear_deviation_fraction) -> torch.Tensor: + start_state_pos = path[0][:2] + goal_state_pos = path[-1][:2] + length = torch.norm(goal_state_pos - start_state_pos) + path = path[:, :2] + path = torch.stack([path[:, 0], path[:, 1], torch.zeros_like(path[:, 0])], dim=1) + start_state_pos = torch.stack([start_state_pos[0], start_state_pos[1], torch.zeros_like(start_state_pos[0])]).unsqueeze(0) + goal_state_pos = torch.stack([goal_state_pos[0], goal_state_pos[1], torch.zeros_like(goal_state_pos[0])]).unsqueeze(0) + deviation_from_line = torch.norm(torch.cross(goal_state_pos - start_state_pos, path - start_state_pos), + dim=1) / length + return (deviation_from_line < fraction_of_length).float().mean().item() + + def compute_traj_data_adherence_batch(self, + paths: torch.Tensor, + fraction_of_length=params.data_adherence_linear_deviation_fraction + ) -> torch.Tensor: + # The score is deviation of the path from a straight line. Cost in {0, 1}. + # The score is 1 for each point on the path within a distance less than fraction_of_length * length from + # the straight line. The computation is the average of the scores for all points in the path. + # paths: (..., H, D) + # returns: (...) + start_state_pos = paths[..., 0, :2] # (..., 2) + goal_state_pos = paths[..., -1, :2] # (..., 2) + length = torch.norm(goal_state_pos - start_state_pos, dim=-1) # (...) + + # Add a dimension for broadcasting over the horizon + start_state_pos_h = start_state_pos.unsqueeze(-2) # (..., 1, 2) + goal_state_pos_h = goal_state_pos.unsqueeze(-2) # (..., 1, 2) + length_h = length.unsqueeze(-1) # (..., 1) + + path_pos = paths[..., :2] # (..., H, 2) + + # To use cross product for distance calculation, we embed in 3D + zeros = torch.zeros_like(path_pos[..., :1]) # (..., H, 1) + path_pos_3d = torch.cat((path_pos, zeros), dim=-1) # (..., H, 3) + + zeros_start_goal = torch.zeros_like(start_state_pos_h[..., :1]) # (..., 1, 1) + start_state_pos_3d = torch.cat((start_state_pos_h, zeros_start_goal), dim=-1) # (..., 1, 3) + goal_state_pos_3d = torch.cat((goal_state_pos_h, zeros_start_goal), dim=-1) # (..., 1, 3) + + # Avoid division by zero for paths where start and goal are the same + # Add a small epsilon to the length in the denominator + length_h_safe = length_h + 1e-8 + # Avoid division by zero when start and goal coincide. + + deviation_from_line = torch.norm( + torch.cross(goal_state_pos_3d - start_state_pos_3d, path_pos_3d - start_state_pos_3d, dim=-1), + dim=-1 + ) / length_h_safe # (..., H) + + # The threshold is a fraction of the total path length + threshold = fraction_of_length * length_h # (..., 1) + + # Score is the mean fraction of points within the threshold + # For paths with zero length, all points are the same, so deviation is 0, score is 1. + score = (deviation_from_line < threshold).float().mean(dim=-1) # (...) + return score + + +if __name__ == '__main__': + env = EnvEmpty2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..c2946c03acac078d7d4d08ae3de37bbcefbc3c60 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py @@ -0,0 +1,63 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvEmpty2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEmpty2DExtraObjects(EnvEmpty2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'empty2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvEmpty2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..86fee15c213bed5eb2f8c6c4e558c55645176b85 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py @@ -0,0 +1,49 @@ +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.environments import * +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEmptyNoWait2D(EnvEmpty2D): + + def __init__(self, + name='EnvEmptyNoWait2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + super().__init__( + name=name, + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + +if __name__ == '__main__': + env = EnvEmptyNoWait2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..9de16b2741f58737fc2031978c24d77a787d9b9b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py @@ -0,0 +1,63 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvEmptyNoWait2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEmptyNoWait2DExtraObjects(EnvEmptyNoWait2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'emptynowait2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvEmptyNoWait2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..d3d666ca51d7bf9c48e3a267777e5bcf29a2ea91 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_ensemble.py @@ -0,0 +1,94 @@ +from typing import Dict +from matplotlib import pyplot as plt + +import torch + +from torch_robotics.environments import * +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEnsemble(EnvBase): + def __init__(self, + envs: Dict[int, EnvBase], + transforms: Dict[int, torch.Tensor], + tensor_args=None, + **kwargs): + self.envs = envs + self.transforms = transforms # @Note: transforms are the relative positions of the envs, where the first env + # is the absolut reference + + self.env_centers = {k: self.transforms[k] for k in self.envs.keys()} + self.env_limits = {k: self.envs[k].limits for k in self.envs.keys()} + + # loop on envs and transforms, and get the big map limits + if tensor_args is None: + tensor_args = DEFAULT_TENSOR_ARGS + absolute_limits = torch.zeros(self.env_limits[0].shape, **tensor_args) + for k in self.envs.keys(): + limits = self.env_limits[k] + center = self.env_centers[k] + limits = limits + center + absolute_limits[0] = torch.min(absolute_limits[0], limits[0]) + absolute_limits[1] = torch.max(absolute_limits[1], limits[1]) + + obj_list = [] + for k, env in self.envs.items(): + obj_k_list = env.get_obj_list() + for obj in obj_k_list: + # translate the object to the absolute position obj.reference_frame = self.env_centers[k] #TODO: It + # would be nice to have a reference frame for the object and thats enough, but currently there is no + # actual use for it in EnvBase + if obj.pos.shape[0] == 3 and self.env_centers[k].shape[0] == 2: + obj.pos[:2] += self.env_centers[k] + else: + obj.pos += self.env_centers[k] + obj_list.append(obj) + + super().__init__(limits=absolute_limits, + obj_fixed_list=obj_list, + tensor_args=tensor_args, + **kwargs) + + +if __name__ == '__main__': + env0 = EnvEmpty2D(tensor_args=DEFAULT_TENSOR_ARGS) + + env1 = EnvConveyor2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + env2 = EnvSquare2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + env3 = EnvDense2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + envs = {0: env0, 1: env1, 2: env2, 3: env3} + + transforms = {0: torch.tensor([0., 0.], **DEFAULT_TENSOR_ARGS), + 1: torch.tensor([2., 0.], **DEFAULT_TENSOR_ARGS), + 2: torch.tensor([2., -2.], **DEFAULT_TENSOR_ARGS), + 3: torch.tensor([0., -2.], **DEFAULT_TENSOR_ARGS)} + + env = EnvEnsemble(envs, transforms, tensor_args=DEFAULT_TENSOR_ARGS) + + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..41e359150396489d1489ca05520e1f122d48f729 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py @@ -0,0 +1,293 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from mmd.common.trajectory_utils import densify_trajs + + +class EnvHighways2D(EnvBase): + + def __init__(self, + name='EnvHighways2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + MultiBoxField( + np.array([ + [0, 0.0], + [0., 0.875], + [0., -0.875], + [0.875, 0.0], + [-0.875, 0.0], + [0.875, 0.875], + [0.875, -0.875], + [-0.875, 0.875], + [-0.875, -0.875], + ]), + np.array([ + [0.5, 0.5], + [0.5, 0.25], + [0.5, 0.25], + [0.25, 0.5], + [0.25, 0.5], + [0.25, 0.25], + [0.25, 0.25], + [0.25, 0.25], + [0.25, 0.25] + ]), + tensor_args=tensor_args + ), + ] + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'highways2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=500, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=20, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=1e-5, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def is_start_goal_valid_for_data_gen(self, robot, start_pos, goal_pos): + """ + :param robot: Robot object. + :param start_pos: Start position. (q_dim,), often (2, ). + :param goal_pos: Goal position. (q_dim,), often (2, ). + """ + if torch.linalg.norm(start_pos - goal_pos) > 0.6: + return False + # We set squares for starts and goals. Starts can only be at start squares and goals follow similarly. + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + start_region_centers = torch.tensor([ + [0.8, 0.5], + [-0.5, 0.8], + [-0.8, -0.5], + [0.5, -0.8] + ], **self.tensor_args) + goal_region_centers = torch.tensor([ + [0.8, -0.5], + [0.5, 0.8], + [-0.8, 0.5], + [-0.5, -0.8] + ], **self.tensor_args) + start_region_radius = 0.15 + goal_region_radius = 0.15 + if torch.any(torch.norm(start_region_centers - start_pos, dim=-1) < start_region_radius).item() and \ + torch.any(torch.norm(goal_region_centers - goal_pos, dim=-1) < goal_region_radius).item(): + return True + else: + return False + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + + # These are quadrant-midpoints. + ordered_waypoints = torch.tensor([ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5] + ], **self.tensor_args) + + # Find the closest waypoint to the start. + start_pos = start_pos.unsqueeze(0) + ordered_waypoints = ordered_waypoints.unsqueeze(1) + distances = torch.norm(ordered_waypoints - start_pos, dim=-1) + closest_waypoint_start_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_start = ordered_waypoints[closest_waypoint_start_idx].squeeze(0) + + # Find the closest waypoint to the goal. + goal_pos = goal_pos.unsqueeze(0) + distances = torch.norm(ordered_waypoints - goal_pos, dim=-1) + closest_waypoint_goal_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_goal = ordered_waypoints[closest_waypoint_goal_idx].squeeze(0) + + # Create a skill that goes from closest entrance to closest exit via the other waypoints order. + skill_pos_sequence = [closest_waypoint_start] + if closest_waypoint_start_idx == closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + while closest_waypoint_start_idx != closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + skill_pos_sequence.append(ordered_waypoints[closest_waypoint_start_idx].squeeze(0)) + # skill_pos_sequence.append(closest_waypoint_goal) + skill_pos_sequence_l = [torch.stack(skill_pos_sequence)] + + skill_pos_sequence_l = densify_trajs(skill_pos_sequence_l, n_points_interp=10) + # Get rid of the first few points and the last few points. We only care about the homotopy. + skill_pos_sequence_l = [skill_pos_sequence_l[0][4:-4]] + # Add noise. + skill_pos_sequence_l += [skill_pos_sequence_l[i] + torch.randn_like(skill_pos_sequence_l[i]) * 0.01 for i in range(len(skill_pos_sequence_l))] + + # Show the skill. + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots() + # print(skill_pos_sequence_l[0]) + # ax.plot(skill_pos_sequence_l[0][:, 0].detach().cpu().detach().cpu().numpy(), skill_pos_sequence_l[0][:, 1].detach().cpu().numpy(), 'r') + # ax.scatter(start_pos[0, 0].item(), start_pos[0, 1].item(), c='g') + # ax.scatter(goal_pos[0, 0].item(), goal_pos[0, 1].item(), c='b') + # ax.set_xlim(-1, 1) + # ax.set_ylim(-1, 1) + # plt.show() + + return skill_pos_sequence_l + else: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + vectors = path[:,:] + + norms = torch.norm(vectors, dim=1, keepdim=True) + vectors_normalized = vectors / norms + + # Get pairs of consecutive normalized vectors + vec1 = vectors_normalized[:-1] + vec2 = vectors_normalized[1:] + + # Compute the 2D cross products of consecutive vectors + cross_products = vec1[:, 0] * vec2[:, 1] - vec1[:, 1] * vec2[:, 0] + + # Sum the cross products and determine if counterclockwise or clockwise + aggregate_cross_product = torch.sum(cross_products) + + return 1 if aggregate_cross_product > 0 else 0 + + def compute_traj_data_adherence_batch(self, paths: torch.Tensor, eps=1e-10) -> torch.Tensor: + """ + Batched version of compute_traj_data_adherence that preserves all preceding dims. + Args: + paths: Tensor of shape (..., horizon, dim). + Returns: + adherence: LongTensor of shape (..., horizon), 1 if the cumulative + cross-product sum up to that horizon is positive (ccw), else 0. + """ + *batch_shape, H, D = paths.shape + # step‐to‐step differences + paths_normed = torch.nn.functional.normalize(paths, dim=-1, eps=eps) + # consecutive vector pairs + v1, v2 = paths_normed[..., :-1, :], paths_normed[..., 1:, :] + cross = v1[..., 0] * v2[..., 1] - v1[..., 1] * v2[..., 0] + cross_sum = cross.sum(dim=-1) + signs = (cross_sum > 0).to(torch.long) + adherence = signs + return adherence + +if __name__ == '__main__': + env = EnvHighways2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..d1cd547f02bdd7b274e327eb0c5321f846e08f78 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py @@ -0,0 +1,88 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard includes. +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +# Project includes. +from torch_robotics.environments import EnvHighways2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvHighways2DExtraObjects(EnvHighways2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.0], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.2], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'conveyor2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvHighways2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_robotarium_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_robotarium_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..136ed2257dde747d96ff0a800a8ba2aa67b3f81a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/env_robotarium_2d.py @@ -0,0 +1,140 @@ +"""The Georgia Tech Robotarium testbed, in its own units. + +Every other environment in this suite lives in a dimensionless [-1, 1]^2 box. This one is +in METRES on the physical arena, so a result here is a statement about hardware rather +than about a normalised proxy: + + arena 3.2 m x 2.0 m, x in [-1.6, 1.6], y in [-1.0, 1.0] + robot GRITSBot/GTernal, 0.11 m diameter -> radius 0.055 + collision the Robotarium verifier flags a violation when two COLLISION CENTRES + (the pose centre pushed 0.025 m forward along the heading) come within + 0.135 m. Worst case over headings that is 0.135 + 2*0.025 = 0.185 m of + pose-centre separation, so R_HARD = 0.185 is the hardware threshold. + separation 0.25 m planned, leaving 0.065 m of slack for pose-estimation and + tracking error. The Robotarium guide recommends 0.15 m. + speed 0.08 m/s peak reference; the tracker caps its single-integrator command + at 0.13 m/s; the hardware limit is 0.2 m/s. + +Constants and their justification come from +/robotarium_hardware/plan_robotarium.py, which is what runs on the +real testbed, so the baselines and the hardware planner are held to one specification. + +The arena is EMPTY: the testbed has no interior obstacles, and the difficulty is the +inter-robot coupling alone. Walls are the limits, and the keep-out from them is +0.055 + 0.05 = 0.105 m. + +A consequence of working in metres: the y range is 2.0 m while x is 3.2 m, so this is the +only non-square environment in the suite. Anything that assumed a square workspace -- +notably the +/-0.95 sampling box in mmd/common/multi_agent_utils.py -- must read the +limits instead, or be bypassed by supplying the instances directly. +""" +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + +# --- the physical testbed, all in metres ------------------------------------------- +ARENA_X = 1.6 # half-width, x in [-1.6, 1.6] +ARENA_Y = 1.0 # half-height, y in [-1.0, 1.0] +ROBOT_DIAMETER = 0.11 +COLLISION_DIAMETER = 0.135 # verifier threshold between collision centres +COLLISION_OFFSET = 0.025 # collision centre is this far forward of the pose +R_HARD = COLLISION_DIAMETER + 2 * COLLISION_OFFSET # 0.185 m, worst case +R_SEP = 0.25 # planned centre-to-centre separation +WALL_CLEAR = ROBOT_DIAMETER / 2 + 0.05 # 0.105 m +V_PEAK = 0.08 # m/s, planned peak reference speed +V_TRACKER = 0.13 # m/s, tracker's single-integrator cap +V_MAX = 0.20 # m/s, hardware limit +CIRCLE_R = 0.80 # start/goal circle for the antipodal swap + + +class EnvRobotarium2D(EnvBase): + + def __init__(self, + name='EnvRobotarium2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + # No interior obstacles: the testbed is a bare arena. + obj_list = [ + MultiSphereField(np.array([]), np.array([]), tensor_args=tensor_args), + ] + super().__init__( + name=name, + limits=torch.tensor([[-ARENA_X, -ARENA_Y], [ARENA_X, ARENA_Y]], **tensor_args), + obj_fixed_list=[ObjectField(obj_list, 'robotarium2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + # step_size and n_radius are lengths, so they scale with the arena: the defaults + # elsewhere are tuned for a 2 m box and would be ~1.6x too fine here. + return dict(n_iters=10000, step_size=0.016, n_radius=0.08, + n_pre_samples=50000, max_time=60) + + def get_gpmp2_params(self, robot=None): + return dict( + n_support_points=64, dt=0.04, opt_iters=20, num_samples=64, + sigma_start=1e-5, sigma_gp=1e-2, sigma_goal_prior=1e-5, + sigma_coll=1e-5, step_size=1e-1, + sigma_start_init=1e-4, sigma_goal_init=1e-4, sigma_gp_init=1e-5, + sigma_start_sample=1e-4, sigma_goal_sample=1e-4, + solver_params={'delta': 1e-2, 'trust_region': True, 'method': 'cholesky'}, + ) + + def compute_traj_data_adherence_batch(self, paths: torch.Tensor) -> torch.Tensor: + """Straight-line efficiency, chord / path length in (0, 1]. + + The testbed prescribes no motion pattern -- there is no lane to follow and no drop + region to visit -- so the only environment-specific notion of "as demonstrated" + available is directness. An antipodal swap has a straight-line solution whenever + the other robots permit it, so this reads 1 when a robot went straight and less + when it detoured. + """ + pos = paths[..., :2] + seg = torch.linalg.norm(pos[..., 1:, :] - pos[..., :-1, :], dim=-1).sum(-1) + chord = torch.linalg.norm(pos[..., -1, :] - pos[..., 0, :], dim=-1) + return torch.clamp(chord / (seg + 1e-12), 0.0, 1.0) + + +def antipodal_swap(n_agents, rot=0.0, circle_r=CIRCLE_R): + """The testbed's own instance: agent i goes from angle th_i to th_i + pi. + + Deterministic given (n_agents, rot), so a sample of instances is a sample of + rotations rather than of random endpoints. This is the case that stresses the + coupling hardest -- every straight-line path runs through the centre. + + Returns (starts, goals), each (n_agents, 2) in metres. + """ + th = rot + 2.0 * np.pi * np.arange(n_agents) / n_agents + s = np.stack([circle_r * np.cos(th), circle_r * np.sin(th)], axis=-1) + return s.astype(np.float64), (-s).astype(np.float64) + + +def start_separation(n_agents, circle_r=CIRCLE_R): + """Chord between neighbours on the start circle, the tightest pair in the instance.""" + return 2.0 * circle_r * np.sin(np.pi / n_agents) + + +if __name__ == '__main__': + env = EnvRobotarium2D(precompute_sdf_obj_fixed=True, sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS) + print(f'limits {env.limits.tolist()}') + for n in (3, 6, 9, 12, 15, 18, 21): + d = start_separation(n) + flag = 'ok' if d >= R_SEP else ('above hardware threshold' if d >= R_HARD else 'INFEASIBLE') + print(f' N={n:<3} neighbour separation on the start circle {d:.3f} m ({flag})') + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py new file mode 100644 index 0000000000000000000000000000000000000000..3fbd711c75e310c07b6290105e7ca5e2e9b7f146 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py @@ -0,0 +1,117 @@ +from math import ceil + +import matplotlib.pyplot as plt +import numpy as np +import torch +from torch.autograd.functional import jacobian + + +class GridMapSDF: + """ + Generates an SDF grid. + """ + def __init__(self, limits, cell_size, obj_list, tensor_args=None): + + self.limits = limits + self.dim = limits.shape[-1] + + self.tensor_args = tensor_args + + # objects + self.obj_list = obj_list + + # SDF grids + map_dim = torch.abs(limits[1] - limits[0]) + self.map_dim = map_dim + self.cell_size = cell_size + self.cmap_dim = torch.ceil(map_dim/cell_size).type(torch.LongTensor).to(self.tensor_args['device']) + + self.points_for_sdf = None + self.sdf_tensor = None + self.grad_sdf_tensor = None + self.precompute_sdf() + + def precompute_sdf(self): + # create voxel grid of points + basis_ranges = [ + torch.linspace(self.limits[0][0], self.limits[1][0], self.cmap_dim[0], **self.tensor_args), + torch.linspace(self.limits[0][1], self.limits[1][1], self.cmap_dim[1], **self.tensor_args), + ] + if self.dim == 3: + basis_ranges.append( + torch.linspace(self.limits[0][2], self.limits[1][2], self.cmap_dim[2], **self.tensor_args) + ) + points_for_sdf_meshgrid = torch.meshgrid(*basis_ranges, indexing='ij') + self.points_for_sdf = torch.stack(points_for_sdf_meshgrid, dim=-1) + + f_grad_sdf = lambda x: self.compute_signed_distance_raw(x).sum() + sdf_tensor_l = [] + grad_sdf_tensor_l = [] + # TODO - compute sdf and gradient in batches to prevent memory overflow + for i in range(self.points_for_sdf.shape[0]): + torch.cuda.empty_cache() + # sdf + points_sdf = self.points_for_sdf[i] + sdf_tensor = self.compute_signed_distance_raw(points_sdf) + sdf_tensor_l.append(sdf_tensor) + # gradient of sdf + grad_sdf_tensor = jacobian(f_grad_sdf, points_sdf) + grad_sdf_tensor_l.append(grad_sdf_tensor) + torch.cuda.empty_cache() + + self.sdf_tensor = torch.stack(sdf_tensor_l, dim=0) + self.grad_sdf_tensor = torch.stack(grad_sdf_tensor_l, dim=0) + + def compute_signed_distance_raw(self, x): + sdf = None + for obj in self.obj_list: + sdf_obj = obj.compute_signed_distance(x) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + return sdf + + def __call__(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def compute_cost(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def compute_signed_distance(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def get_sdf(self, X, **kwargs): + """ + Checks for collision in a batch of trajectories using the generated occupancy grid (i.e. obstacle map), and + returns sum of collision costs for the entire batch. + + :param weight: weight on obstacle cost, float tensor. + :param X: Tensor of trajectories, of shape (batch_size, horizon, task_spaces, position_dim) + :return: collision cost on the trajectories + """ + X_in_map = ((X-self.limits[0])/self.map_dim * self.cmap_dim).floor().to(torch.int) + + # Project out-of-bounds locations to axis + max_idx = torch.tensor(self.points_for_sdf.shape[:-1]).type_as(X_in_map) - 1 + X_in_map = X_in_map.clamp(torch.zeros_like(max_idx), max_idx) + + # SDFs and gradients + # To compute the gradients, because we already have computed the gradients, we use a surrogate sdf function + # surrogate_sdf(x) = sdf(x_detachted) + x @ grad_sdf(x_detachted) - x_detached @ grad_sdf(x_detachted) + # This surrogate has the property + # surrogate_sdf(x) = sdf(x_detachted) + # grad_x_surrogate_sdf(x) = grad_sdf(x_detachted) + X_in_map_detached = X_in_map.detach() + X_query = torch.unbind(X_in_map_detached, dim=-1) + + sdf_vals = self.sdf_tensor[X_query] + grad_sdf = self.grad_sdf_tensor[X_query] + + X_detached = X.detach() + sdf_vals += (X * grad_sdf).sum(-1) - (X_detached * grad_sdf).sum(-1) + + return sdf_vals + + def zero_grad(self): + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/objects.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/objects.py new file mode 100644 index 0000000000000000000000000000000000000000..ac1a351dbf62757ebd10e32460680890fdd9a29d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/objects.py @@ -0,0 +1,89 @@ +import abc + +import torch +from urdf_parser_py.urdf import Box + +from torch_robotics.environments.primitives import ObjectField, MultiBoxField +from torch_robotics.torch_utils.torch_utils import to_numpy + + +class GraspedObject(ObjectField): + + def __init__(self, primitive_fields, **kwargs): + # pos, ori - position and orientation are specified wrt to the end-effector link + + # Only one primitive type + assert len(primitive_fields) == 1 + + super().__init__(primitive_fields, **kwargs) + + # Geometry URDF + self.geometry_urdf = self.get_geometry_urdf() + + def get_geometry_urdf(self): + primitive_field = self.fields[0] + + if isinstance(primitive_field, MultiBoxField): + size = primitive_field.sizes[0] + return Box(to_numpy(size)) + else: + raise NotImplementedError + + @abc.abstractmethod + def get_base_points_for_collision(self): + raise NotImplementedError + + +class GraspedObjectPandaBox(GraspedObject): + + def __init__(self, tensor_args=None, **kwargs): + # One box + primitive_fields = [ + MultiBoxField(torch.zeros(3, **tensor_args).view(1, -1), + torch.tensor([0.05, 0.05, 0.15], **tensor_args).view(1, -1), + tensor_args=tensor_args) + ] + + # position and orientation wrt to the robots's end-effector link -> for panda reference_frame='panda_hand' + pos = torch.tensor([0., 0., 0.11], **tensor_args) + ori = torch.tensor([0, 0.7071081, 0, 0.7071055], **tensor_args) + + super().__init__( + primitive_fields, + name='GraspedObjectPandaBox', + pos=pos, ori=ori, reference_frame='panda_hand', + **kwargs) + + self.base_points_for_collision = self.get_base_points_for_collision() + self.n_base_points_for_collision = len(self.base_points_for_collision) + + def get_base_points_for_collision(self): + # points on vertices and centers of faces + size = self.fields[0].sizes[0] + x, y, z = size + vertices = torch.tensor( + [ + [x/2, y/2, -z/2], + [x/2, -y/2, -z/2], + [-x/2, -y/2, -z/2], + [-x/2, y/2, -z/2], + [x/2, y/2, z/2], + [x/2, -y/2, z/2], + [-x/2, -y/2, z/2], + [-x/2, y/2, z/2], + ], + **self.tensor_args) + + faces = torch.tensor( + [ + [x/2, 0, 0], + [0, -y/2, 0], + [-x/2, 0, 0], + [0, y/2, 0], + [0, 0, z/2], + [0, 0, -z/2], + ], + **self.tensor_args) + + points = torch.cat((vertices, faces), dim=0) + return points diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/occupancy_map.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/occupancy_map.py new file mode 100644 index 0000000000000000000000000000000000000000..06a6a95182da52cd2c1359b69f8961515fdab6e7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/occupancy_map.py @@ -0,0 +1,172 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le, João Carvalho + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le, João Carvalho since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) 2022 Sasha Lambert + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from math import ceil + +import matplotlib.pyplot as plt +import numpy as np +import torch + + +class OccupancyMap: + """ + Generates an occupancy grid. + """ + def __init__(self, map_dim, cell_size, tensor_args=None): + + for d in map_dim: + assert d % 2 == 0, f"Dimension {d} is not an even number" + + self.map_dim = map_dim + + self.n_dim = len(map_dim) + + if tensor_args is None: + tensor_args = {'device': torch.device('cpu'), 'dtype': torch.float32} + self.tensor_args = tensor_args + + self.cmap_dim = [0 for _ in map_dim] + for i, d in enumerate(self.cmap_dim): + self.cmap_dim[i] = ceil(map_dim[i]/cell_size) + + self.map = np.zeros(self.cmap_dim) + self.map_torch = None + + self.cell_size = cell_size + + # Map center (in cells) + self.origin = np.array([d//2 for d in self.cmap_dim]) + self.c_offset = torch.Tensor(self.origin).to(**tensor_args) + + # limits + self.dims = self.map.shape + self.lims = [(-self.cell_size * d/2, self.cell_size * d/2) for d in self.dims] + + def convert_map_to_torch(self): + self.map_torch = torch.Tensor(self.map).to(**self.tensor_args) + return self.map_torch + + def get_collisions(self, X, **kwargs): + """ + Checks for collision in a batch of trajectories using the generated occupancy grid (i.e. obstacle map), and + returns sum of collision costs for the entire batch. + + :param weight: weight on obstacle cost, float tensor. + :param X: Tensor of trajectories, of shape (batch_size, horizon, task_spaces, position_dim) + :return: collision cost on the trajectories + """ + + X_occ = X * (1/self.cell_size) + self.c_offset + X_occ = X_occ.floor() + + # X_occ = X_occ.cpu().numpy().astype(np.int) + X_occ = X_occ.type(torch.LongTensor) + X_occ = X_occ.to(device=self.tensor_args['device']) + + # Project out-of-bounds locations to axis + for i in range(X_occ.shape[-1]): + X_occ[..., i] = X_occ[..., i].clamp(0, self.map.shape[i]-1) + + # Collisions + try: + if X_occ.shape[-1] == 2: + collision_vals = self.map_torch[X_occ[..., 1], X_occ[..., 0]] + elif X_occ.shape[-1] == 3: + collision_vals = self.map_torch[X_occ[..., 1], X_occ[..., 0], X_occ[..., 2]] + else: + raise NotImplementedError + except Exception as e: + print(e) + print(X_occ) + print(X_occ.clamp(0, self.map.shape[0]-1)) + return collision_vals + + def compute_distances(self, X, **kwargs): + """ + Computes euclidean distances of X to all points in the occupied grid + """ + X_grid_points_idxs = torch.nonzero(self.map_torch > 0) + X_grid_points_task_space = (X_grid_points_idxs - self.c_offset) * self.cell_size + distances = torch.cdist(X, X_grid_points_task_space, p=2.0) + return distances + + def compute_cost(self, X, **kwargs): + return self.get_collisions(X, **kwargs) + + def zero_grad(self): + pass + + def plot(self, ax=None, save_dir=None, filename="obst_map.png"): + if ax is None: + if self.n_dim == 2: + fig, ax = plt.subplots() + else: + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + if self.n_dim == 2: + res = self.map.shape[0] + x = np.linspace(self.lims[0][0], self.lims[0][1], res) + y = np.linspace(self.lims[1][0], self.lims[1][1], res) + ax.contourf(x, y, np.clip(self.map, 0, 1), 2, cmap='Greys') + else: + x, y, z = np.indices(np.array(self.map.shape) + 1, dtype=float) + x -= self.origin[0] + x = x * self.cell_size + y -= self.origin[1] + y = y * self.cell_size + z -= self.origin[2] + z = z * self.cell_size + ax.voxels(y, x, z, self.map, facecolors='gold', edgecolor='black', shade=False, alpha=0.05) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/primitives.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/primitives.py new file mode 100644 index 0000000000000000000000000000000000000000..e11bbaaa02bba1b25b98476212279d8f4f0f010b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/primitives.py @@ -0,0 +1,653 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from math import ceil +from typing import List + +import einops +import numpy as np +import torch +from matplotlib import pyplot as plt, transforms +from matplotlib.patches import FancyBboxPatch, BoxStyle +from torch.autograd.functional import jacobian + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_rotation_matrix +from torch_robotics.torch_kinematics_tree.geometrics.utils import transform_point, rotate_point +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS, to_torch, to_numpy, tensor_linspace_v1 +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + + +class PrimitiveShapeField(ABC): + """ + Represents a primitive object in N-D. + """ + + def __init__(self, dim=3, tensor_args=None): + self.dim = dim + if tensor_args is None: + tensor_args = DEFAULT_TENSOR_ARGS + self.tensor_args = tensor_args + + def compute_signed_distance(self, x): + """ + Returns the signed distance at x. + Parameters + ---------- + x : torch array (batch, dim) or (batch, horizon, dim) + """ + return self.compute_signed_distance_impl(x) + + @abstractmethod + def compute_signed_distance_impl(self, x): + # The SDF computed here assumes the primitive shape main center is located at the origin, and there is no + # rotation. + # Note that this center is different from e.g. the centers for spheres in MultiSphereField, since a primitive + # shape can be made of multiple shapes. + raise NotImplementedError() + + def zero_grad(self): + pass + + def obstacle_collision_check(self, obst_map): + valid = True + obst_map_test = self.add_to_occupancy_map(deepcopy(obst_map)) + if np.any(obst_map_test.map > 1): + valid = False + return valid + + def point_collision_check(self, obst_map, pts): + valid = True + if pts is not None: + obst_map_test = self.add_to_occupancy_map(np.copy(obst_map)) + for pt in pts: + if obst_map_test[ceil(pt[0]), ceil(pt[1])] >= 1: + valid = False + break + return valid + + @abstractmethod + def add_to_occupancy_map(self, obst_map): + # Adds obstacle to an occupancy grid + raise NotImplementedError + + @abstractmethod + def render(self, ax, pos=None, ori=None, color=None, **kwargs): + raise NotImplementedError + + +def plot_sphere(ax, center, pos, radius, cmap): + u, v = np.mgrid[0:2 * np.pi:30j, 0:np.pi:20j] + x = radius * (np.cos(u) * np.sin(v)) + y = radius * (np.sin(u) * np.sin(v)) + z = radius * np.cos(v) + ax.plot_surface( + x + center[0] + pos[0], y + center[1] + pos[1], z + center[2] + pos[2], + cmap=cmap, + alpha=1 + ) + + +class MultiSphereField(PrimitiveShapeField): + + def __init__(self, centers, radii, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Centers of the spheres. + radii : numpy array + Radii of the spheres. + """ + super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) + self.centers = to_torch(centers, **self.tensor_args) + # OBS_SCALE difficulty axis (sphere obstacles). Default 1.0 reproduces the + # shipped geometry exactly. '' -> default, not a crash. + _obs_scale = float(__import__('os').environ.get('OBS_SCALE') or '1.0') + self.radii = to_torch(radii, **self.tensor_args) * _obs_scale + + def __repr__(self): + return f"MultiSphereField(centers={self.centers}, radii={self.radii})" + + def compute_signed_distance_impl(self, x): + if len(self.centers) == 0: + return torch.ones_like(x[..., 0]) + + distance_to_centers = torch.norm(x.unsqueeze(-2) - self.centers.unsqueeze(0), dim=-1) + # sdfs = distance_to_centers - self.radii.unsqueeze(0) + sdfs = distance_to_centers - self.radii + return torch.min(sdfs, dim=-1)[0] + + def zero_grad(self): + self.centers.grad = None + self.radii.grad = None + + def add_to_occupancy_map(self, obst_map): + # Adds obstacle to occupancy map + for center, radius in zip(self.centers, self.radii): + n_dim = len(center) + # Convert dims to cell indices + c_r = ceil(radius / obst_map.cell_size) + c_x = ceil(center[0] / obst_map.cell_size) + c_y = ceil(center[1] / obst_map.cell_size) + + # centers in cell indices + obst_map_origin_xi = obst_map.origin[0] + obst_map_origin_yi = obst_map.origin[1] + + c_x_cell = c_x + obst_map_origin_xi + c_y_cell = c_y + obst_map_origin_yi + + obst_map_x_dim = obst_map.dims[0] + obst_map_y_dim = obst_map.dims[1] + + if n_dim == 3: + c_z = ceil(center[2] / obst_map.cell_size) + obst_map_origin_zi = obst_map.origin[2] + c_z_cell = c_z + obst_map_origin_zi + obst_map_z_dim = obst_map.dims[2] + + for i in range(c_x_cell - 2 * c_r, c_x_cell + 2 * c_r): + if i < 0 or i >= obst_map_x_dim: + continue + for j in range(c_y_cell - 2 * c_r, c_y_cell + 2 * c_r): + if j < 0 or j >= obst_map_y_dim: + continue + + if n_dim == 3: + for k in range(c_z_cell - 2 * c_r, c_z_cell + 2 * c_r): + if k < 0 or k >= obst_map_z_dim: + continue + + p = torch.tensor([(i - obst_map_origin_xi) * obst_map.cell_size, + (j - obst_map_origin_yi) * obst_map.cell_size, + (k - obst_map_origin_zi) * obst_map.cell_size + ], + **self.tensor_args) + + if self.is_inside(p, center, radius): + obst_map.map[j, i, k] += 1 + + else: + p = torch.tensor([(i - obst_map_origin_xi) * obst_map.cell_size, + (j - obst_map_origin_yi) * obst_map.cell_size], + **self.tensor_args) + + if self.is_inside(p, center, radius): + obst_map.map[j, i] += 1 + return obst_map + + @staticmethod + def is_inside(p, center, radius): + # Check if point p is inside the discretized sphere + return torch.linalg.norm(p - center) <= radius + + def render(self, ax, pos=None, ori=None, color='wheat', cmap='wheat', **kwargs): + for center, radius in zip(self.centers, self.radii): + center = to_numpy(center) + radius = to_numpy(radius) + if pos is None: + pos = np.zeros(self.dim) + else: + pos = to_numpy(pos) + # orientation is not needed, because the shape is symmetric + if ax.name == '3d': + plot_sphere(ax, center, pos, radius, cmap) + else: + circle = plt.Circle((center[0] + pos[0], center[1] + pos[1]), radius, color=color, linewidth=0, alpha=1) + ax.add_patch(circle) + + +def patch_rotate_translate(ax, patch, rot, trans): + rot_angle_deg = np.rad2deg(np.arctan2(rot[1, 0], rot[0, 0])) + affine_transf = transforms.Affine2D().rotate_deg(rot_angle_deg).translate(trans[0], trans[1]) + ax.transData + patch.set_transform(affine_transf) + ax.add_patch(patch) + + +class MultiBoxField(PrimitiveShapeField): + + def __init__(self, centers, sizes, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Center of the boxes. + sizes: numpy array + Sizes of the boxes. + """ + super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) + self.centers = to_torch(centers, **self.tensor_args) + # OBS_SCALE difficulty axis: inflates every box about its own centre. + _obs_scale = float(__import__('os').environ.get('OBS_SCALE') or '1.0') + self.sizes = to_torch(sizes, **self.tensor_args) * _obs_scale + self.half_sizes = self.sizes / 2 + + def __repr__(self): + return f"MultiBoxField(centers={self.centers}, sizes={self.sizes})" + + def compute_signed_distance_impl(self, x): + distance_to_centers = torch.abs(x.unsqueeze(-2) - self.centers.unsqueeze(0)) + sdfs = torch.max(distance_to_centers - self.half_sizes.unsqueeze(0), dim=-1)[0] + return torch.min(sdfs, dim=-1)[0] + + def zero_grad(self): + self.centers.grad = None + self.sizes.grad = None + self.half_sizes.grad = None + + def add_to_occupancy_map(self, obst_map): + for center, size in zip(self.centers, self.sizes): + n_dim = len(center) + width = size[0] + height = size[1] + # Convert dims to cell indices + w = ceil(width / obst_map.cell_size) + h = ceil(height / obst_map.cell_size) + c_x = ceil(center[0] / obst_map.cell_size) + c_y = ceil(center[1] / obst_map.cell_size) + + obst_map_origin_xi = obst_map.origin[0] + obst_map_origin_yi = obst_map.origin[1] + + if n_dim == 2: + obst_map.map[ + c_y - ceil(h / 2.) + obst_map_origin_yi:c_y + ceil(h / 2.) + obst_map_origin_yi, + c_x - ceil(w / 2.) + obst_map_origin_xi:c_x + ceil(w / 2.) + obst_map_origin_xi + ] += 1 + else: + depth = size[2] + c_z = ceil(center[2] / obst_map.cell_size) + obst_map_origin_zi = obst_map.origin[2] + d = ceil(depth / obst_map.cell_size) + obst_map.map[ + c_y - ceil(h / 2.) + obst_map_origin_yi:c_y + ceil(h / 2.) + obst_map_origin_yi, + c_x - ceil(w / 2.) + obst_map_origin_xi:c_x + ceil(w / 2.) + obst_map_origin_xi, + c_z - ceil(d / 2.) + obst_map_origin_zi:c_z + ceil(d / 2.) + obst_map_origin_zi + ] += 1 + return obst_map + + def get_cube(self): + phi = torch.arange(1, 10, 2, **self.tensor_args) * torch.pi / 4 + Phi, Theta = torch.meshgrid(phi, phi, indexing="ij") + x = torch.cos(Phi) * torch.sin(Theta) + y = torch.sin(Phi) * torch.sin(Theta) + z = torch.cos(Theta) / np.sqrt(2) + return x, y, z + + def render(self, ax, pos=None, ori=None, color='wheat', cmap='gray', **kwargs): + + rot = q_to_rotation_matrix(ori).squeeze() + if ax.name == '3d': + x, y, z = self.get_cube() + for center, size in zip(self.centers, self.sizes): + cx, cy, cz = center + a, b, c = size + + points_x = cx + x * a + points_y = cy + y * b + points_z = cz + z * c + + points = torch.stack((points_x.ravel(), points_y.ravel(), points_z.ravel()), dim=-1) + points = transform_point(points, rot, pos) + + d = x.shape[0] + points_x = points[:, 0].view(d, d) + points_y = points[:, 1].view(d, d) + points_z = points[:, 2].view(d, d) + + points_x_np, points_y_np, points_z_np = to_numpy(points_x), to_numpy(points_y), to_numpy(points_z) + # TODO - implemented drawing of rounded boxes in 3D + ax.plot_surface(points_x_np, points_y_np, points_z_np, cmap=cmap, alpha=0.25) + else: + for i, (center, size) in enumerate(zip(self.centers, self.sizes)): + cx, cy = to_numpy(center) + a, b = to_numpy(size) + pos_np = to_numpy(pos) + # by definition a rotation in the xy-plane is around the z-axis + rot_np = to_numpy(rot[:2, :2]) + point = np.array([cx - a / 2, cy - b / 2]) + self.draw_box(ax, i, point, a, b, rot_np, pos_np[:2], color) + + def draw_box(self, ax, i, point, a, b, rot, trans, color='wheat'): + rectangle = plt.Rectangle((point[0], point[1]), a, b, + color=color, linewidth=0, alpha=1) + patch_rotate_translate(ax, rectangle, rot, trans) + + +class MultiRoundedBoxField(MultiBoxField): + + def __init__(self, centers, sizes, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Center of the boxes. + sizes: numpy array + Sizes of the boxes. + """ + super().__init__(centers, sizes, tensor_args=tensor_args) + self.radius = torch.min(self.sizes, dim=-1)[0] * 0.15 # empirical value + + def compute_signed_distance_impl(self, x): + # Implementation of rounded box + # https://raphlinus.github.io/graphics/2020/04/21/blurred-rounded-rects.html + distance_to_centers = torch.abs(x.unsqueeze(-2) - self.centers.unsqueeze(0)) + q = distance_to_centers - self.half_sizes.unsqueeze(0) + self.radius.unsqueeze(0).unsqueeze(-1) + max_q = torch.amax(q, dim=-1) + sdfs = torch.minimum(max_q, torch.zeros_like(max_q)) + torch.linalg.norm(torch.relu(q), dim=-1) - self.radius.unsqueeze(0) + return torch.min(sdfs, dim=-1)[0] + + def draw_box(self, ax, i, point, a, b, rot, trans, color='wheat'): + rounded_box = FancyBboxPatch((point[0], point[1]), a, b, color=color, + boxstyle=BoxStyle.Round(pad=0., rounding_size=to_numpy(self.radius[i]).item()) + ) + patch_rotate_translate(ax, rounded_box, rot, trans) + + +# Alias for rounded box. +# Use a rounded box instead of a box by default. +# This creates smoother cost functions, which are important to gradient-based optimization methods. +MultiBoxField = MultiRoundedBoxField + + +# TODO - NEEDS CHECKING +# class MultiInfiniteCylinderField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Centers of the cylinders. +# radii : numpy array +# Radii of the cylinders. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.centers = torch.tensor(centers, **self.tensor_args) +# self.radii = torch.tensor(radii, **self.tensor_args) +# +# def __repr__(self): +# return f"InfiniteCylinder(centers={self.centers}, radii={self.radii})" +# +# def compute_signed_distance_impl(self, x): +# # treat it like a circle in 2d +# distance_to_centers_2d = torch.norm(x[..., :2].unsqueeze(-2) - self.centers.unsqueeze(0)[..., :2], dim=-1) +# sdfs = distance_to_centers_2d - self.radii.unsqueeze(0) +# return torch.min(sdfs, dim=-1)[0] +# +# def zero_grad(self): +# self.centers.grad = None +# self.radii.grad = None +# +# def add_to_occupancy_map(self, obst_map): +# raise NotImplementedError +# +# def render(self, ax, pos=None, ori=None): +# raise NotImplementedError +# # https://stackoverflow.com/a/49311446 +# def data_for_cylinder_along_z(center_x, center_y, radius, height_z): +# z = np.linspace(0, height_z, 50) +# theta = np.linspace(0, 2 * np.pi, 50) +# theta_grid, z_grid = np.meshgrid(theta, z) +# x_grid = radius * np.cos(theta_grid) + center_x +# y_grid = radius * np.sin(theta_grid) + center_y +# return x_grid, y_grid, z_grid +# +# for center, radius in zip(self.centers, self.radii): +# cx, cy, cz = to_numpy(center) +# radius = to_numpy(radius) +# xc, yc, zc = data_for_cylinder_along_z(cx, cy, radius, 1.5) # add height just for visualization +# ax.plot_surface(xc, yc, zc, cmap='wheat', alpha=0.75) +# +# +# class MultiCylinderField(MultiInfiniteCylinderField): +# +# def __init__(self, centers, radii, heights, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Centers of the cylinders. +# radii : numpy array +# Radii of the cylinders. +# heights : numpy array +# Heights of the cylinders. +# """ +# super().__init__(centers, radii, tensor_args=tensor_args) +# self.heights = torch.tensor(heights, **self.tensor_args) +# self.half_heights = self.heights / 2 +# +# def __repr__(self): +# return f"Cylinder(centers={self.centers}, radii={self.radii}, heights={self.heights})" +# +# def compute_signed_distance_impl(self, x): +# raise NotImplementedError +# x = x - self.center +# x_proj = x[:, :2] +# x_proj_norm = torch.norm(x_proj, dim=-1) +# x_proj_norm = torch.where(x_proj_norm > 0, x_proj_norm, torch.ones_like(x_proj_norm)) +# x_proj = x_proj / x_proj_norm[:, None] +# x_proj = x_proj * self.radius +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# return torch.norm(x - x_proj, dim=-1) - self.radius +# +# def zero_grad(self): +# super().zero_grad() +# self.heights.grad = None +# +# def add_to_occupancy_map(self, obst_map): +# raise NotImplementedError +# +# +# class MultiEllipsoidField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, tensor_args=None): +# """ +# Axis aligned ellipsoid. +# Parameters +# ---------- +# center : numpy array +# Center of the ellipsoid. +# radii : numpy array +# Radii of the ellipsoid. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.center = torch.tensor(centers, **self.tensor_args) +# self.radii = torch.tensor(radii, **self.tensor_args) +# +# def compute_signed_distance_impl(self, x): +# return torch.norm((x - self.center) / self.radii, dim=-1) - 1 +# +# def zero_grad(self): +# self.center.grad = None +# self.radii.grad = None +# +# def __repr__(self): +# return f"Ellipsoid(center={self.center}, radii={self.radii})" +# +# +# class MultiCapsuleField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, heights, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Center of the capsule. +# radiii : float +# Radius of the capsule. +# heights : float +# Height of the capsule. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.center = torch.tensor(centers, **self.tensor_args) +# self.radius = torch.tensor(radii, **self.tensor_args) +# self.height = torch.tensor(heights, **self.tensor_args) +# +# def compute_signed_distance_impl(self, x): +# x = x - self.center +# x_proj = x[:, :2] +# x_proj_norm = torch.norm(x_proj, dim=-1) +# x_proj_norm = torch.where(x_proj_norm > 0, x_proj_norm, torch.ones_like(x_proj_norm)) +# x_proj = x_proj / x_proj_norm[:, None] +# x_proj = x_proj * self.radius +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# x_proj = torch.norm(x - x_proj, dim=-1) - self.radius +# x_proj = torch.where(x_proj > 0, x_proj, torch.zeros_like(x_proj)) +# x_proj = torch.where(x_proj < self.height, x_proj, torch.ones_like(x_proj) * self.height) +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# return torch.norm(x - x_proj, dim=-1) - self.radius +# +# def zero_grad(self): +# self.center.grad = None +# self.radius.grad = None +# self.height.grad = None +# +# def __repr__(self): +# return f"Capsule(center={self.center}, radius={self.radius}, height={self.height})" +# +# +# class MeshField(PrimitiveShapeField): +# """ +# Represents a mesh as a primitive shape. +# """ +# +# def __init__(self, **kwargs): +# super().__init__(**kwargs) + + +######################################################################################################################## +class ObjectField(PrimitiveShapeField): + + def __init__(self, primitive_fields, name='object', pos=None, ori=None, reference_frame='base'): + """ + Holds an object made of primitives and manages its position and orientation in the environments. + """ + self.name = name + + assert primitive_fields is not None + assert isinstance(primitive_fields, List) + super().__init__(dim=primitive_fields[0].dim, tensor_args=primitive_fields[0].tensor_args) + self.fields = primitive_fields + + # position and orientation + assert (pos is None and ori is None) or (pos.nelement() == 3 and ori.nelement() == 4) + self.pos = torch.zeros(3, **self.tensor_args) if pos is None else pos + self.ori = torch.tensor([1, 0, 0, 0], **self.tensor_args) if ori is None else ori # quat - wxyz + + # Reference frame for the position and orientation + self.reference_frame = reference_frame + + # precomputed sdf and gradients + # holds the sdf (and its gradient) of this object for all points in the environments workspace + self.grid_map_sdf = None + + def __repr__(self): + return f"ObjectField(fields={self.fields})" + + def set_position_orientation(self, pos=None, ori=None): + if pos is not None: + assert len(pos) == 3 + self.pos = to_torch(pos, **self.tensor_args) + if ori is not None: + assert len(ori) == 4, "quaternion wxyz" + self.ori = to_torch(ori, **self.tensor_args) + + def join_primitives(self): + raise NotImplementedError + + def compute_signed_distance_impl(self, x): + # Transform the point before computing the SDF. + # The implemented SDFs assume the objects are centered around 0 and not rotated. + x_shape = x.shape + if x_shape[-1] == 2: + x_new = torch.cat((x, torch.zeros((x.shape[:-1]), **self.tensor_args).unsqueeze(-1)), dim=-1) + else: + x_new = x + + # transform back to the origin + rot = q_to_rotation_matrix(self.ori).squeeze().transpose(-2, -1) + x_new = rotate_point(x_new-self.pos, rot) + if x_shape[-1] == 2: + x_new = x_new[..., :2] + + sdf_fields = [] + for field in self.fields: + sdf_fields.append(field.compute_signed_distance_impl(x_new)) + return torch.min(torch.stack(sdf_fields, dim=-1), dim=-1)[0] + + def render(self, ax, pos=None, ori=None, color='wheat', **kwargs): + for field in self.fields: + if pos is None: + pos = self.pos + if ori is None: + ori = self.ori + field.render(ax, pos=pos, ori=ori, color=color, **kwargs) + + def add_to_occupancy_map(self, occ_map): + for field in self.fields: + field.add_to_occupancy_map(occ_map) + + def zero_grad(self): + for field in self.fields: + field.zero_grad() + + +if __name__ == '__main__': + tensor_args = DEFAULT_TENSOR_ARGS + spheres = MultiSphereField(torch.zeros(2, **tensor_args).view(1, -1), + torch.ones(1, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + boxes = MultiBoxField(torch.zeros(2, **tensor_args).view(1, -1) + 0.5, + torch.ones(2, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + obj_field = ObjectField([spheres, boxes]) + + theta = np.deg2rad(45) + # obj_field.set_position_orientation(pos=[-0.5, 0., 0.]) + # obj_field.set_position_orientation(ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + obj_field.set_position_orientation(pos=[-0.5, 0., 0.], ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + + # # Render objects + fig, ax = plt.subplots() + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + obj_field.render(ax) + plt.show() + + # Render sdf + fig, ax = plt.subplots() + xs = torch.linspace(-1, 1, steps=400, **tensor_args) + ys = torch.linspace(-1, 1, steps=400, **tensor_args) + X, Y = torch.meshgrid(xs, ys, indexing='xy') + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + sdf = obj_field.compute_signed_distance(torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, 2)) + sdf = sdf.reshape(X.shape) + sdf_np = to_numpy(sdf) + ctf = ax.contourf(to_numpy(X), to_numpy(Y), sdf_np) + fig.colorbar(ctf, orientation='vertical') + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + + # Render gradient sdf + xs = torch.linspace(-1, 1, steps=20, **tensor_args) + ys = torch.linspace(-1, 1, steps=20, **tensor_args) + X, Y = torch.meshgrid(xs, ys, indexing='xy') + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, 2) + + f_grad_sdf = lambda x: obj_field.compute_signed_distance(x).sum() + grad_sdf = jacobian(f_grad_sdf, stacked_tensors) + + grad_sdf_np = to_numpy(grad_sdf).squeeze() + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], + color='red') + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..42d6c406adac166f5ea33534c19c118bcacd0c01 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/environments/utils.py @@ -0,0 +1,29 @@ +import numpy as np + +from torch_robotics.environments.primitives import MultiSphereField, ObjectField, MultiBoxField + + +def create_grid_spheres(rows=5, cols=5, heights=0, radius=0.1, distance_from_border=0.1, tensor_args=None): + # Generates a grid (rows, cols, heights) of circles + # if heights = 0, creates a 2d grid, else a 3d grid + dim = 2 if heights == 0 else 3 + centers_x = np.linspace(-1 + distance_from_border, 1 - distance_from_border, cols) + centers_y = np.linspace(-1 + distance_from_border, 1 - distance_from_border, rows) + z_flat = None + if dim == 3: + centers_z = np.linspace(-1 + distance_from_border, 1 - distance_from_border, heights) + X, Y, Z = np.meshgrid(centers_x, centers_y, centers_z) + z_flat = Z.flatten() + else: + X, Y = np.meshgrid(centers_x, centers_y) + + flats = [X.flatten(), Y.flatten()] + if z_flat: + flats.append(z_flat) + centers = np.array(flats).T + radii = np.ones(flats[0].shape[0]) * radius + + spheres = MultiSphereField(centers, radii, tensor_args=tensor_args) + obj_field = ObjectField([spheres], 'grid-of-spheres') + obj_list = [obj_field] + return obj_list diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..189f43be656a473ff86fa2d923b9a7a524844383 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/__init__.py @@ -0,0 +1,2 @@ +from .robot_planar_disk import * + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_base.py new file mode 100644 index 0000000000000000000000000000000000000000..e1980cec1a0723125d3a9e4ab4b3bc20b913b738 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_base.py @@ -0,0 +1,205 @@ +import abc +import itertools +from abc import ABC +from math import ceil +from typing import Tuple + +import torch + +from torch_robotics.torch_planning_objectives.fields.distance_fields import CollisionSelfField +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch +from torch_robotics.trajectory.utils import finite_difference_vector + + +class RobotBase(ABC): + + def __init__( + self, + name='RobotBase', + q_limits=None, + grasped_object=None, + margin_for_grasped_object_collision_checking=0.001, + link_names_for_object_collision_checking=None, + link_margins_for_object_collision_checking=None, + link_idxs_for_object_collision_checking=None, + link_names_for_self_collision_checking=None, + link_names_pairs_for_self_collision_checking=None, + link_idxs_for_self_collision_checking=None, + self_collision_margin_robot=0.001, + link_names_for_self_collision_checking_with_grasped_object=None, + self_collision_margin_grasped_object=0.05, + num_interpolated_points_for_self_collision_checking=1, + num_interpolated_points_for_object_collision_checking=1, + dt=1.0, # time interval to compute velocities and accelerations from positions via finite difference + tensor_args=None, + **kwargs + ): + self.name = name + self.tensor_args = tensor_args + + self.dt = dt + + ################################################################################################ + # Configuration space + assert q_limits is not None, "q_limits cannot be None" + self.q_limits = q_limits + self.q_min = q_limits[0] + self.q_max = q_limits[1] + self.q_min_np = to_numpy(self.q_min) + self.q_max_np = to_numpy(self.q_max) + self.q_distribution = torch.distributions.uniform.Uniform(self.q_min, self.q_max) + self.q_dim = len(self.q_min) + + ################################################################################################ + # Grasped object + self.grasped_object = grasped_object + self.margin_for_grasped_object_collision_checking = margin_for_grasped_object_collision_checking + + ################################################################################################ + # Objects collision field + assert num_interpolated_points_for_object_collision_checking >= len(link_names_for_object_collision_checking) + if num_interpolated_points_for_object_collision_checking % len(link_names_for_object_collision_checking) != 0: + self.points_per_link_object_collision_checking = ceil(num_interpolated_points_for_object_collision_checking / len(link_names_for_object_collision_checking)) + num_interpolated_points_for_object_collision_checking = self.points_per_link_object_collision_checking * len(link_names_for_object_collision_checking) + else: + self.points_per_link_object_collision_checking = int(num_interpolated_points_for_object_collision_checking / len(link_names_for_object_collision_checking)) + self.self_collision_margin_robot = self_collision_margin_robot + self.num_interpolated_points_for_object_collision_checking = num_interpolated_points_for_object_collision_checking + self.link_names_for_object_collision_checking = link_names_for_object_collision_checking + self.n_links_for_object_collision_checking = len(link_names_for_object_collision_checking) + self.link_margins_for_object_collision_checking = link_margins_for_object_collision_checking + self.link_margins_for_object_collision_checking_robot_tensor = torch.tensor( + link_margins_for_object_collision_checking, **self.tensor_args).repeat_interleave( + int(num_interpolated_points_for_object_collision_checking / len(link_margins_for_object_collision_checking)) + ) + self.link_margins_for_object_collision_checking_tensor = self.link_margins_for_object_collision_checking_robot_tensor + # append grasped object margins + if self.grasped_object is not None: + self.link_margins_for_object_collision_checking_tensor = torch.cat( + (self.link_margins_for_object_collision_checking_tensor, + torch.ones(self.grasped_object.n_base_points_for_collision, **self.tensor_args) * self.margin_for_grasped_object_collision_checking) + ) + + self.link_idxs_for_object_collision_checking = link_idxs_for_object_collision_checking + + ################################################################################################ + # Self collision field + if link_names_for_self_collision_checking is None: + self.df_collision_self = None + else: + assert num_interpolated_points_for_self_collision_checking >= len(link_names_for_self_collision_checking) + if num_interpolated_points_for_self_collision_checking % len(link_names_for_self_collision_checking) != 0: + self.points_per_link_self_collision_checking = ceil(num_interpolated_points_for_self_collision_checking / len(link_names_for_self_collision_checking)) + num_interpolated_points_for_self_collision_checking = self.points_per_link_self_collision_checking * len(link_names_for_self_collision_checking) + else: + self.points_per_link_self_collision_checking = int(num_interpolated_points_for_self_collision_checking / len(link_names_for_self_collision_checking)) + + self.link_names_for_self_collision_checking = link_names_for_self_collision_checking + self.link_names_pairs_for_self_collision_checking = link_names_pairs_for_self_collision_checking + + self.link_idxs_for_self_collision_checking = link_idxs_for_self_collision_checking + + self.link_names_for_self_collision_checking_with_grasped_object = link_names_for_self_collision_checking_with_grasped_object + + self.self_collision_margin_grasped_object = self_collision_margin_grasped_object + + # build indices to retrieve distances from self collision distance matrix + # including the grasped object + idxs_links_distance_matrix = [] + p = self.points_per_link_self_collision_checking + total_self_distances_robot = 0 + for i, link_1 in enumerate(self.link_names_for_self_collision_checking): + if link_1 in self.link_names_pairs_for_self_collision_checking: + for link_2 in self.link_names_pairs_for_self_collision_checking[link_1]: + j = self.link_names_for_self_collision_checking.index(link_2) + idxs = [(i*p + m, j*p + n) for m, n in list(itertools.product(range(p), range(p)))] + idxs_links_distance_matrix.extend(idxs) + total_self_distances_robot += len(idxs) + + self_collision_margin_vector = [self.self_collision_margin_robot] * total_self_distances_robot + + if self.grasped_object is not None: + total_self_distances_grasped_object = 0 + self_collision_robot_last_row_idx = len(self.link_names_for_self_collision_checking) * p + n_grasped_points = self.grasped_object.n_base_points_for_collision + for link_1 in self.link_names_for_self_collision_checking_with_grasped_object: + j = self.link_names_for_self_collision_checking.index(link_1) + idxs = [(self_collision_robot_last_row_idx + m, j * p + n) for m, n in list(itertools.product(range(n_grasped_points), range(p)))] + idxs_links_distance_matrix.extend(idxs) + total_self_distances_grasped_object += len(idxs) + + self_collision_margin_vector.extend([self.self_collision_margin_grasped_object] * total_self_distances_grasped_object) + + self_collision_margin_vector = to_torch(self_collision_margin_vector, **self.tensor_args) + + self.df_collision_self = CollisionSelfField( + self, + link_idxs_for_collision_checking=self.link_idxs_for_self_collision_checking, + idxs_links_distance_matrix=idxs_links_distance_matrix, + num_interpolated_points=num_interpolated_points_for_self_collision_checking, + cutoff_margin=self_collision_margin_vector, + tensor_args=self.tensor_args + ) + + def random_q(self, n_samples=10): + # Random position in configuration space + q_pos = self.q_distribution.sample((n_samples,)) + return q_pos + + def get_position(self, x): + return x[..., :self.q_dim] + + def get_direction(self, x): + raise NotImplementedError + + def get_velocity(self, x): + vel = x[..., self.q_dim:2 * self.q_dim] + # If there is no velocity in the state, then compute it via finite difference + if x.nelement() != 0 and vel.nelement() == 0: + vel = finite_difference_vector(x, dt=self.dt, method='central') + return vel + return vel + + def get_acceleration(self, x): + acc = x[..., 2 * self.q_dim:3 * self.q_dim] + # If there is no acceleration in the state, then compute it via finite difference + if x.nelement() != 0 and acc.nelement() == 0: + vel = self.get_velocity(x) + acc = finite_difference_vector(vel, dt=self.dt, method='central') + return acc + return acc + + def distance_q(self, q1, q2): + return torch.linalg.norm(q1 - q2, dim=-1) + + def fk_map_collision(self, q, **kwargs): + if q.ndim == 1: + q = q.unsqueeze(0) # add batch dimension + return self.fk_map_collision_impl(q, **kwargs) + + @abc.abstractmethod + def fk_map_collision_impl(self, q, **kwargs): + # q: (..., q_dim) + # return: (..., links_collision_positions, 3) + raise NotImplementedError + + @abc.abstractmethod + def render(self, ax, **kwargs): + raise NotImplementedError + + @abc.abstractmethod + def render_trajectories(self, ax, trajs=None, **kwargs): + raise NotImplementedError + + def check_rr_collisions(self, robot_q: torch.tensor) -> Tuple[torch.tensor, torch.tensor]: + """ + Check collisions between robots. (Robot-robot collisions). + Args: + robot_q: (..., n_robots, q_dim) + Returns: + collisions: (..., n_robots, n_robots), True if there is a collision between the robots. + collision_points: (..., n_robots, n_robots, 2 or 3), collision points. + """ + raise NotImplementedError + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..bfe435492ffd596a99083174408faaa593cceece --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py @@ -0,0 +1,163 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from MPD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from typing import Tuple + +import numpy as np +import torch +import matplotlib.collections as mcoll +import matplotlib.pyplot as plt + +from mmd.common.constraints import * +from mmd.common.trajectory_utils import * +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, tensor_linspace_v1, to_torch +from mmd.config import MMDParams as params + + +class RobotPlanarDisk(RobotBase): + + def __init__(self, + name='RobotPlanarDisk', + radius=params.robot_planar_disk_radius, + q_limits=torch.tensor([[-1, -1], [1, 1]]), # configuration space limits (RBT_ARENA widens below) + **kwargs): + super().__init__( + name=name, + q_limits=to_torch( + __import__('torch').tensor([[-1.6, -1.0], [1.6, 1.0]]) + if __import__('os').environ.get('RBT_ARENA') == '1' else q_limits, + **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0'], + link_margins_for_object_collision_checking=[radius * 1.1], + link_idxs_for_object_collision_checking=[0], + num_interpolated_points_for_object_collision_checking=1, + **kwargs + ) + self.radius = radius + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + if q is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q = to_numpy(q) + if q.ndim == 1: + if self.q_dim == 2: + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + elif self.q_dim == 3: + plot_sphere(ax, q, np.zeros_like(q), margin, cmap) + else: + raise NotImplementedError + elif q.ndim == 2: + if q.shape[-1] == 2: + circ = [] + for q_ in q: + circ.append(plt.Circle(q_, margin, color=color)) + coll = mcoll.PatchCollection(circ, zorder=10) + ax.add_collection(coll) + elif q.shape[-1] == 3: + for q_ in q: + plot_sphere(ax, q_, np.zeros_like(q_), margin, cmap) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', constraints_l=None, **kwargs): + if trajs is not None: + trajs_pos = self.get_position(trajs) + trajs_np = to_numpy(trajs_pos) + if self.q_dim == 3: + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1], trajs_np[..., 2]))).swapaxes(1, 2) + line_segments = Line3DCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 3)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], points[:, 2], color=colors_scatter, s=2**2) + else: + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle, linewidth=kwargs.get('linewidth', 1.0)) + ax.add_collection(line_segments) + if start_state is not None: + start_state_np = to_numpy(start_state) + if len(start_state_np) == 3: + ax.plot(start_state_np[0], start_state_np[1], start_state_np[2], 'go', markersize=self.radius*100) + else: + ax.plot(start_state_np[0], start_state_np[1], 'go', markersize=self.radius*100) + if goal_state is not None: + goal_state_np = to_numpy(goal_state) + if len(goal_state_np) == 3: + ax.plot(goal_state_np[0], goal_state_np[1], goal_state_np[2], marker='o', + color='purple', markersize=self.radius*100) + else: + ax.plot(goal_state_np[0], goal_state_np[1], marker='o', color='purple', markersize=self.radius*100) + if constraints_l is not None: + for c in constraints_l: + q_l = c.get_q_l() + q_l_np = [to_numpy(q) for q in q_l] + for q in q_l_np: + if self.q_dim == 3: + plot_sphere(ax, q, np.zeros_like(q), self.radius, 'Blues') + else: + for i in range(1, 50): + circle = plt.Circle(q, self.radius * (i / 50)**4, color='red', zorder=10, alpha=0.2 * (50 - i)/50) + ax.add_patch(circle) + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Assume it's the identity. + # Add tasks space dimension + return q.unsqueeze(-2) + + def check_rr_collisions(self, robot_q: torch.tensor) -> Tuple[torch.tensor, torch.tensor]: + """ + Check collisions between robots. (Robot-robot collisions). + Args: + robot_q: (..., n_robots, q_dim) + Returns: + collisions: (..., n_robots, n_robots), True if there is a collision between the robots. + collision_points: (..., n_robots, n_robots, 2 or 3), the collision points. + """ + points_batch = robot_q + margin = 2.1 * self.radius + assert points_batch.dim() >= 2 + points_batch1 = points_batch.unsqueeze(-2) + points_batch2 = points_batch.unsqueeze(-3) + robot_dq = points_batch1 - points_batch2 + robot_dq_norm = torch.norm(robot_dq, dim=-1) + collisions = robot_dq_norm < margin + collisions = collisions & ~torch.eye(collisions.shape[-1], device=collisions.device, dtype=collisions.dtype) + collision_points = (points_batch1 + points_batch2) / 2 + collision_points = collision_points * collisions.unsqueeze(-1) + collision_points[~collisions.unsqueeze(-1).expand_as(collision_points)] = float('nan') + return collisions, collision_points diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..7f9f6978b2ca00047c5f10355ecab1ea5c31a183 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks.py @@ -0,0 +1,331 @@ +import sys +from abc import ABC +from functools import partial + +import einops +import numpy as np +import torch + +from torch_robotics.torch_planning_objectives.fields.distance_fields import CollisionWorkspaceBoundariesDistanceField, \ + CollisionSelfField, CollisionObjectDistanceField +from torch_robotics.trajectory.utils import interpolate_traj_via_points + + +class Task(ABC): + + def __init__(self, env=None, robot=None, tensor_args=None, **kwargs): + self.env = env + self.robot = robot + self.tensor_args = tensor_args + + +class PlanningTask(Task): + + def __init__( + self, + ws_limits=None, + use_occupancy_map=False, + cell_size=0.01, + obstacle_cutoff_margin=0.01, + **kwargs + ): + super().__init__(**kwargs) + self.ws_limits = self.env.limits if ws_limits is None else ws_limits + self.ws_min = self.ws_limits[0] + self.ws_max = self.ws_limits[1] + + # Optional: use an occupancy map for collision checking -- useful for sampling-based algorithms + # A precomputed collision map is faster when checking for collisions, in comparison to computing the distances + # from tasks spaces to objects + self.use_occupancy_map = use_occupancy_map + if use_occupancy_map: + self.env.build_occupancy_map(cell_size=cell_size) + + ################################################################################################ + # Collision fields + # collision field for self-collision + self.df_collision_self = self.robot.df_collision_self + + # collision field for objects + self.df_collision_objects = CollisionObjectDistanceField( + self.robot, + df_obj_list_fn=self.env.get_df_obj_list, + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + tensor_args=self.tensor_args + ) + + if self.env.obj_extra_list is not None: + self.df_collision_extra_objects = CollisionObjectDistanceField( + self.robot, + df_obj_list_fn=partial(self.env.get_df_obj_list, return_extra_objects_only=True), + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + tensor_args=self.tensor_args + ) + self._collision_fields_extra_objects = [self.df_collision_extra_objects] + else: + self._collision_fields_extra_objects = [] + + # collision field for workspace boundaries + self.df_collision_ws_boundaries = CollisionWorkspaceBoundariesDistanceField( + self.robot, + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + # Hack for Diffusion Ensemble to allow larger footprint robots to be near boundary. + ws_bounds_min=self.ws_min * 1.08, + # Hack for Diffusion Ensemble to allow larger footprint robots to be near boundary. + ws_bounds_max=self.ws_max * 1.08, + tensor_args=self.tensor_args + ) + + self._collision_fields = [self.df_collision_self, self.df_collision_objects, self.df_collision_ws_boundaries] + + def get_collision_fields(self): + return self._collision_fields + + def get_collision_fields_extra_objects(self): + return self._collision_fields_extra_objects + + def distance_q(self, q1, q2): + return self.robot.distance_q(q1, q2) + + def sample_q(self, without_collision=True, **kwargs): + if without_collision: + return self.random_coll_free_q(**kwargs) + else: + return self.robot.random_q(**kwargs) + + def random_coll_free_q(self, n_samples=1, max_samples=1000, max_tries=1000): + # Random position in configuration space not in collision + reject = True + samples = torch.zeros((n_samples, self.robot.q_dim), **self.tensor_args) + idx_begin = 0 + for i in range(max_tries): + qs = self.robot.random_q(max_samples) + in_collision = self.compute_collision(qs).squeeze() + idxs_not_in_collision = torch.argwhere(in_collision == False).squeeze() + if idxs_not_in_collision.nelement() == 0: + # all points are in collision + continue + if idxs_not_in_collision.nelement() == 1: + idxs_not_in_collision = [idxs_not_in_collision] + idx_random = torch.randperm(len(idxs_not_in_collision))[:n_samples] + free_qs = qs[idxs_not_in_collision[idx_random]] + idx_end = min(idx_begin + free_qs.shape[0], samples.shape[0]) + samples[idx_begin:idx_end] = free_qs[:idx_end - idx_begin] + idx_begin = idx_end + if idx_end >= n_samples: + reject = False + break + + if reject: + sys.exit("Could not find a collision free configuration") + + return samples.squeeze() + + def compute_collision(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='occupancy', **kwargs) + + def compute_collision_cost(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='sdf', **kwargs) + + def _compute_collision_or_cost(self, q, field_type='occupancy', **kwargs): + # q.shape needs to be reshaped to (batch, horizon, q_dim) + q_original_shape = q.shape + b = 1 + h = 1 + collisions = None + if q.ndim == 1: + q = q.unsqueeze(0).unsqueeze(0) # add batch and horizon dimensions for interface + collisions = torch.ones((1, ), **self.tensor_args) + elif q.ndim == 2: + b = q.shape[0] + q = q.unsqueeze(1) # add horizon dimension for interface + collisions = torch.ones((b, 1), **self.tensor_args) # (batch, 1) + elif q.ndim == 3: + b = q.shape[0] + h = q.shape[1] + collisions = torch.ones((b, h), **self.tensor_args) # (batch, horizon) + elif q.ndim > 3: + raise NotImplementedError + + if self.use_occupancy_map: + raise NotImplementedError + # ---------------------------------- For occupancy maps ---------------------------------- + ######################################## + # Configuration space boundaries + idxs_coll_free = torch.argwhere(torch.all( + torch.logical_and(torch.greater_equal(q, self.robot.q_min), torch.less_equal(q, self.robot.q_max)), + dim=-1)) # I, 2 + + # check if all points are out of bounds (in collision) + if idxs_coll_free.nelement() == 0: + return collisions + + ######################################## + # Task space collisions + # forward kinematics + q_try = q[idxs_coll_free[:, 0], idxs_coll_free[:, 1]] # I, q_dim + x_pos = self.robot.fk_map_collision(q_try, pos_only=True) # I, taskspaces, x_dim + + # workspace boundaries + # configuration is not valid if any points in the tasks spaces is out of workspace boundaries + idxs_ws_in_boundaries = torch.argwhere(torch.all(torch.all(torch.logical_and( + torch.greater_equal(x_pos, self.ws_min), torch.less_equal(x_pos, self.ws_max)), dim=-1), + dim=-1)).squeeze() # I_ws + + idxs_coll_free = idxs_coll_free[idxs_ws_in_boundaries].view(-1, 2) + + # collision in tasks space + x_pos_in_ws = x_pos[idxs_ws_in_boundaries] # I_ws, x_dim + collisions_pos_x = self.env.occupancy_map.get_collisions(x_pos_in_ws, **kwargs) + if len(collisions_pos_x.shape) == 1: + collisions_pos_x = collisions_pos_x.view(1, -1) + idxs_taskspace = torch.argwhere(torch.all(collisions_pos_x == 0, dim=-1)).squeeze() + + idxs_coll_free = idxs_coll_free[idxs_taskspace].view(-1, 2) + + # filter collisions + if len(collisions) == 1: + collisions[idxs_coll_free[:, 0]] = 0 + else: + collisions[idxs_coll_free[:, 0], idxs_coll_free[:, 1]] = 0 + else: + # ---------------------------------- For distance fields ---------------------------------- + ######################################## + # For distance fields + + # forward kinematics + fk_collision_pos = self.robot.fk_map_collision(q) # batch, horizon, taskspaces, x_dim + + ######################## + # Self collision + if self.df_collision_self is not None: + cost_collision_self = self.df_collision_self.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_self = 0 + + # Object collision + if self.df_collision_objects is not None: + cost_collision_objects = self.df_collision_objects.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_objects = 0 + + # Workspace boundaries + if self.df_collision_ws_boundaries is not None: + cost_collision_border = self.df_collision_ws_boundaries.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_border = 0 + + if field_type == 'occupancy': + collisions = cost_collision_self | cost_collision_objects | cost_collision_border + else: + collisions = cost_collision_self + cost_collision_objects + cost_collision_border + + return collisions + + def get_trajs_collision_and_free(self, trajs, return_indices=False, num_interpolation=5): + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: # n_goals (or steps), batch of trajectories, length, dim + N, B, H, D = trajs.shape + trajs_new = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + trajs_new = trajs + + ############################################################################################################### + # compute collisions on a finer interpolated trajectory + trajs_interpolated = interpolate_traj_via_points(trajs_new, num_interpolation=num_interpolation) + margin = 0.0 + # TODO(yoraish): this is a hack to accomodate the planar disk robot which only has one point of collision but a volume nontheless. + # If robot has a radius, then use it as margin. + if getattr(self.robot, 'radius', None) is not None: + margin = self.robot.radius + trajs_waypoints_collisions = self.compute_collision(trajs_interpolated, margin=margin) + + if trajs.ndim == 4: + trajs_waypoints_collisions = einops.rearrange(trajs_waypoints_collisions, '(N B) H -> N B H', N=N, B=B) + + trajs_free_idxs = torch.argwhere(torch.logical_not(trajs_waypoints_collisions).all(dim=-1)) + trajs_coll_idxs = torch.argwhere(trajs_waypoints_collisions.any(dim=-1)) + + ############################################################################################################### + # Check that trajectories that are not in collision are inside the joint limits + if trajs_free_idxs.nelement() == 0: + pass + else: + if trajs.ndim == 4: + trajs_free_tmp = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + else: + trajs_free_tmp = trajs[trajs_free_idxs.squeeze(), ...] + + trajs_free_tmp_position = self.robot.get_position(trajs_free_tmp) + trajs_free_inside_joint_limits_idxs = torch.logical_and( + trajs_free_tmp_position >= self.robot.q_min, + trajs_free_tmp_position <= self.robot.q_max).all(dim=-1).all(dim=-1) + trajs_free_inside_joint_limits_idxs = torch.atleast_1d(trajs_free_inside_joint_limits_idxs) + trajs_free_idxs_try = trajs_free_idxs[torch.argwhere(trajs_free_inside_joint_limits_idxs).squeeze()] + if trajs_free_idxs_try.nelement() == 0: + trajs_coll_idxs = trajs_free_idxs.clone() + else: + trajs_coll_idxs_joint_limits = trajs_free_idxs[torch.argwhere(torch.logical_not(trajs_free_inside_joint_limits_idxs)).squeeze()] + if trajs_coll_idxs_joint_limits.ndim == 1: + trajs_coll_idxs_joint_limits = trajs_coll_idxs_joint_limits[..., None] + trajs_coll_idxs = torch.cat((trajs_coll_idxs, trajs_coll_idxs_joint_limits)) + trajs_free_idxs = trajs_free_idxs_try + + ############################################################################################################### + # Return trajectories free and in collision + if trajs.ndim == 4: + trajs_free = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0).unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs[:, 0], trajs_coll_idxs[:, 1], ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0).unsqueeze(0) + else: + trajs_free = trajs[trajs_free_idxs.squeeze(), ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs.squeeze(), ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0) + + if trajs_coll.nelement() == 0: + trajs_coll = None + if trajs_free.nelement() == 0: + trajs_free = None + + if return_indices: + return trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions + return trajs_coll, trajs_free + + def compute_fraction_free_trajs(self, trajs, **kwargs): + # Compute the fractions of trajs that are collision free + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.get_trajs_collision_and_free(trajs, return_indices=True) + n_trajs_free = trajs_free_idxs.nelement() + n_trajs_coll = trajs_coll_idxs.nelement() + return n_trajs_free/(n_trajs_free + n_trajs_coll) + + def compute_collision_intensity_trajs(self, trajs, **kwargs): + # Compute the fraction of waypoints that are in collision + _, _, _, _, trajs_waypoints_collisions = self.get_trajs_collision_and_free(trajs, return_indices=True) + return torch.count_nonzero(trajs_waypoints_collisions)/trajs_waypoints_collisions.nelement() + + def compute_success_free_trajs(self, trajs, **kwargs): + # If at least one trajectory is collision free, then we consider success + _, trajs_free = self.get_trajs_collision_and_free(trajs) + if trajs_free is not None: + if trajs_free.nelement() >= 1: + return 1 + return 0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..22e16f414b0efbd6b94392182909ab783e17d093 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py @@ -0,0 +1,370 @@ +import torch + +from .tasks import * +from torch_robotics.environments import * +from typing import Dict +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from mmd.common.pretty_print import * + +class TaskEnsemble(Task): + def __init__(self, + tasks: Dict[int, Task], + transforms: Dict[int, torch.Tensor], + **kwargs): + self.tasks = tasks # Dict[int, PlanningTask] + self.transforms = transforms # Dict[int, torch.Tensor [dx dy] in 2D.] + envs_dict = {k: task.env for k, task in tasks.items()} + env = EnvEnsemble(envs_dict, transforms) + super().__init__(env, tasks[0].robot, **kwargs) + + def transform_q(self, task_id: int, q): + if q.shape[-1] > self.transforms[task_id].shape[0]: + transform = torch.cat( + [self.transforms[task_id], torch.zeros(q.shape[-1] - self.transforms[task_id].shape[0], + device=self.transforms[task_id].device)]) + else: + transform = self.transforms[task_id] + return q + transform + + def inverse_transform_q(self, task_id: int, q): + if q.shape[-1] > self.transforms[task_id].shape[0]: + transform = torch.cat( + [self.transforms[task_id], torch.zeros(q.shape[-1] - self.transforms[task_id].shape[0], + device=self.transforms[task_id].device)]) + else: + transform = self.transforms[task_id] + return q - transform + + +class PlanningTaskEnsemble(TaskEnsemble): + def __init__(self, + tasks: Dict[int, PlanningTask], + transforms: Dict[int, torch.Tensor], + ws_limits=None, + **kwargs): + super().__init__(tasks, transforms, **kwargs) + self.ws_limits = self.env.limits if ws_limits is None else ws_limits + self.ws_min = self.ws_limits[0] + self.ws_max = self.ws_limits[1] + + def get_collision_fields(self, task_id=None): + if task_id is None: + tot_fields = [] + for task in self.tasks.values(): + col_fields = task.get_collision_fields() + tot_fields.extend(col_fields) + return tot_fields + return self.tasks[task_id].get_collision_fields() + + def get_collision_fields_extra_objects(self, task_id=None): + if task_id is None: + tot_fields = [] + for task in self.tasks.values(): + col_fields = task.get_collision_fields_extra_objects() + tot_fields.extend(col_fields) + return tot_fields + return self.tasks[task_id].get_collision_fields_extra_objects() + + def distance_q(self, task_id: int, q1, q2): + return self.tasks[task_id].distance_q(q1, q2) + + def sample_q(self, task_id: int, without_collision=True, **kwargs): + return self.tasks[task_id].sample_q(without_collision=without_collision, **kwargs) + + def random_coll_free_q(self, task_id: int, n_samples=1, max_samples=1000, max_tries=1000): + return self.tasks[task_id].random_coll_free_q(n_samples, max_samples, max_tries) + + def get_traj_unnormalized(self, task_id: int, + datasets, + traj_normalized): + trajs_iters = datasets[task_id].unnormalize_trajectories(traj_normalized) + trajs_final = trajs_iters[-1] + trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs, _ = \ + (self.tasks[task_id].get_trajs_collision_and_free(trajs_final, return_indices=True)) + return trajs_iters, trajs_final, trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs + + def get_stats(self, + task_id: int, + trajs_iters, + trajs_final, + trajs_final_coll, + trajs_final_coll_idxs, + trajs_final_free, + trajs_final_free_idxs, + t_total, + save_data=True, + results_dir=None + ): + success_free_trajs = self.tasks[task_id].compute_success_free_trajs(trajs_final) + fraction_free_trajs = self.tasks[task_id].compute_fraction_free_trajs(trajs_final) + collision_intensity_trajs = self.tasks[task_id].compute_collision_intensity_trajs(trajs_final) + + print(f'success: {success_free_trajs}') + print(f'percentage free trajs: {fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {collision_intensity_trajs * 100:.2f}') + + # compute costs only on collision-free trajectories + traj_final_free_best = None + idx_best_traj = None + cost_best_free_traj = None + cost_smoothness = None + cost_path_length = None + cost_all = None + variance_waypoint_trajs_final_free = None + if trajs_final_free is not None: + cost_smoothness = compute_smoothness(trajs_final_free, self.tasks[task_id].robot) + print(f'cost smoothness: {cost_smoothness.mean():.4f}, {cost_smoothness.std():.4f}') + + cost_path_length = compute_path_length(trajs_final_free, self.tasks[task_id].robot) + print(f'cost path length: {cost_path_length.mean():.4f}, {cost_path_length.std():.4f}') + + # compute best trajectory + cost_all = cost_path_length + cost_smoothness + idx_best_traj_free = torch.argmin(cost_all).item() + traj_final_free_best = trajs_final_free[idx_best_traj_free] + cost_best_free_traj = torch.min(cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + # variance of waypoints + variance_waypoint_trajs_final_free = compute_variance_waypoints(trajs_final_free, self.tasks[task_id].robot) + print(f'variance waypoint: {variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + ######################################################################################################################## + # Save data + results_data_dict = { + 'trajs_iters': trajs_iters, + 'trajs_final_coll': trajs_final_coll, + 'trajs_final_coll_idxs': trajs_final_coll_idxs, + 'trajs_final_free': trajs_final_free, + 'trajs_final_free_idxs': trajs_final_free_idxs, + 'success_free_trajs': success_free_trajs, + 'fraction_free_trajs': fraction_free_trajs, + 'collision_intensity_trajs': collision_intensity_trajs, + # 'idx_best_traj_free': idx_best_traj_free, + 'traj_final_free_best': traj_final_free_best, + 'cost_best_free_traj': cost_best_free_traj, + 'cost_path_length_trajs_final_free': cost_path_length, + 'cost_smoothness_trajs_final_free': cost_smoothness, + 'cost_all_trajs_final_free': cost_all, + 'variance_waypoint_trajs_final_free': variance_waypoint_trajs_final_free, + 't_total': t_total + } + + if save_data: + import os + import pickle + with open(os.path.join(results_dir, f'results_data_dict_model{task_id}.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + return results_data_dict + + def combine_trajs(self, + results_ensemble: Dict[int, Dict]): + keys = list(results_ensemble.values())[0].keys() + for task_id, results in results_ensemble.items(): + for k in keys: + if "idxs" not in k and k.startswith("traj"): + if results[k] is not None: + results_ensemble[task_id][k] = self.transform_q(task_id, results[k]) + else: + results_ensemble[task_id][k] = None + + results = {'trajs_iters': torch.cat([results_ensemble[task_id]['trajs_iters'] for task_id in + results_ensemble.keys()], dim=-2)} + trajs_final = results['trajs_iters'][-1] + coll_ids = torch.tensor([], **self.tensor_args) + for batch_id in range(results_ensemble[0]['trajs_iters'].shape[1]): + for task_id in results_ensemble.keys(): + if batch_id in results_ensemble[task_id]['trajs_final_coll_idxs']: + coll_ids = torch.cat((coll_ids, torch.tensor([batch_id], **self.tensor_args))) + break + free_ids = torch.tensor([i for i in range(results_ensemble[0]['trajs_iters'].shape[1]) if i not in coll_ids], + **self.tensor_args) + + results['trajs_final_coll_idxs'] = coll_ids.long() + results['trajs_final_free_idxs'] = free_ids.long() + results['trajs_final_coll'] = trajs_final[:, results['trajs_final_coll_idxs'], ...] \ + if len(coll_ids) > 0 else torch.tensor([], **self.tensor_args) + results['trajs_final_free'] = trajs_final[results['trajs_final_free_idxs'], :, ...] \ + if len(free_ids) > 0 else torch.tensor([], **self.tensor_args) + results['success_free_trajs'] = 1 if len(free_ids) > 0 else 0 + results['fraction_free_trajs'] = len(free_ids) / results['trajs_iters'].shape[1] + results['collision_intensity_trajs'] = 1 - results['fraction_free_trajs'] + if len(free_ids) > 0: + # results['cost_smoothness_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_smoothness_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + # results['cost_path_length_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_path_length_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + results['cost_smoothness_trajs_final_free'] = compute_smoothness(results['trajs_final_free'], self.robot) + results['cost_path_length_trajs_final_free'] = compute_path_length(results['trajs_final_free'], self.robot) + + # results['cost_all_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_all_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + + results['cost_all_trajs_final_free'] = results['cost_smoothness_trajs_final_free'] + results['cost_path_length_trajs_final_free'] + + # results['variance_waypoint_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['variance_waypoint_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + results['variance_waypoint_trajs_final_free'] = compute_variance_waypoints(results['trajs_final_free'], self.robot) + + idx_best_traj_among_free_trajs = torch.argmin(results['cost_all_trajs_final_free'], dim=-1) + # Index of best free trajectory among all trajectories. + results['idx_best_traj'] = results['trajs_final_free_idxs'][idx_best_traj_among_free_trajs] + results['traj_final_free_best'] = results['trajs_final_free'][idx_best_traj_among_free_trajs, ...] + results['cost_best_free_traj'] = torch.min(results['cost_all_trajs_final_free'], dim=-1)[0] + results['t_total'] = results_ensemble[0]['t_total'] + return results + + # return results_ensemble + + def compute_collision(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='occupancy', **kwargs) + + def compute_collision_cost(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='sdf', **kwargs) + + def _compute_collision_or_cost(self, q, field_type='occupancy', **kwargs): + # q.shape needs to be reshaped to (batch, horizon, q_dim) + q_original_shape = q.shape + b = 1 + h = 1 + collisions = None + if q.ndim == 1: + q = q.unsqueeze(0).unsqueeze(0) # add batch and horizon dimensions for interface + collisions = torch.ones((1,), **self.tensor_args) + elif q.ndim == 2: + b = q.shape[0] + q = q.unsqueeze(1) # add horizon dimension for interface + collisions = torch.ones((b, 1), **self.tensor_args) # (batch, 1) + elif q.ndim == 3: + b = q.shape[0] + h = q.shape[1] + collisions = torch.ones((b, h), **self.tensor_args) # (batch, horizon) + elif q.ndim > 3: + raise NotImplementedError + collisions = collisions.long() + # get the right task based on q values and each task limits + task_ids = self.infer_task_id_from_q(q) # Shape (num_qs, ). + for task_id in self.tasks.keys(): + mask = task_ids == task_id + if torch.any(mask): + q_task = q[mask] + q_task = self.inverse_transform_q(task_id, q_task) + mask = mask.view(-1) + collisions[mask] = self.tasks[task_id]._compute_collision_or_cost(q_task, field_type=field_type, **kwargs) + + return collisions.view(q_original_shape[:-1]) + + # return self.tasks[task_id]._compute_collision_or_cost(self.inverse_transform_q(task_id, q), field_type=field_type, **kwargs) + + def get_trajs_collision_and_free(self, trajs, return_indices=False, num_interpolation=5): + # TEST TEST TEST. Return all valid. + # TODO(yorai): get_trajs_collision_and_free returns all free. This is for visualization, but still needs to be + # fixed. + if return_indices: + return None, torch.tensor([]), trajs, torch.tensor([i for i in range(trajs.shape[0])]), torch.tensor([False for i in range(trajs.shape[1])]) + return None, trajs + + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: # n_goals (or steps), batch of trajectories, length, dim + N, B, H, D = trajs.shape + trajs_new = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + trajs_new = trajs + + # ############################################################################################################## + # # compute collisions on a finer interpolated trajectory + # trajs_interpolated = interpolate_traj_via_points(trajs_new, num_interpolation=num_interpolation) + trajs_interpolated_all = [] + trajs_waypoints_collisions_all = {} + for task_id, task in self.tasks.items(): + ############################################################################################################### + # compute collisions on a finer interpolated trajectory + trajs_interpolated = interpolate_traj_via_points( + trajs_new[:, task_id * params.horizon:(task_id + 1) * params.horizon, ...], + num_interpolation=num_interpolation) + trajs_interpolated_all.append(trajs_interpolated) + # TODO(yoraish): this is a hack to accomodate the planar disk robot which only has one point of collision + # but a volume nonetheless. If robot has a radius, then use it as margin. + if getattr(self.robot, 'radius', None) is not None: + margin = self.robot.radius + trajs_waypoints_collisions = task.compute_collision(trajs_interpolated, margin=margin) + trajs_waypoints_collisions_all[task_id] = trajs_waypoints_collisions + + trajs = torch.cat(trajs_interpolated_all, dim=1) + trajs_waypoints_collisions = torch.stack(list(trajs_waypoints_collisions_all.values()), dim=0).all(dim=0) + if trajs.ndim == 4: + trajs_waypoints_collisions = einops.rearrange(trajs_waypoints_collisions, '(N B) H -> N B H', N=N, B=B) + + trajs_free_idxs = torch.argwhere(torch.logical_not(trajs_waypoints_collisions).all(dim=-1)) + trajs_coll_idxs = torch.argwhere(trajs_waypoints_collisions.any(dim=-1)) + + # Return trajectories free and in collision + if trajs.ndim == 4: + trajs_free = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0).unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs[:, 0], trajs_coll_idxs[:, 1], ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0).unsqueeze(0) + else: + trajs_free = trajs[trajs_free_idxs.squeeze(), ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs.squeeze(), ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0) + + if trajs_coll.nelement() == 0: + trajs_coll = None + if trajs_free.nelement() == 0: + trajs_free = None + + if return_indices: + return trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions + return trajs_coll, trajs_free + + def infer_task_id_from_q_idx(self, q_idx): + task_id = int(q_idx // params.horizon) + task = self.tasks[task_id] + return task_id, task + + def infer_task_id_from_q(self, q): + """ + Infer the task id from the joint values. + :param q: Shape (B, H, q_dim). + :return: task_ids: Shape (B, ). With each entry is the task id of the corresponding joint values traj. + """ + q_pos = self.robot.get_position(q) + # check the limits of each task + env_limits = [] + for task_id, task in self.tasks.items(): + limits = task.env.limits + # transform the limits + lower = self.transform_q(task_id, limits[0]) + upper = self.transform_q(task_id, limits[1]) + env_limits.append((lower, upper)) + task_ids = torch.zeros(q_pos.shape[0], device=q_pos.device, dtype=torch.long) - 1 + + for i, limits in enumerate(env_limits): + # if torch.all(q_pos >= limits[0]) and torch.all(q_pos <= limits[1]): + # task_id = i + # break + mask = torch.logical_and(q_pos >= limits[0], q_pos <= limits[1]) + mask = mask.all(dim=-1).squeeze() + task_ids[mask] = i + + return task_ids # Shape (num_qs, ). diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py new file mode 100644 index 0000000000000000000000000000000000000000..f01e409cd977d6f508ade1bfa35f9cb9aec36754 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py @@ -0,0 +1,86 @@ +import torch +import numpy as np + + +class Gaussian(object): + """ + Batch implementation of Gaussian distribution defined on a manifold. + """ + + def __init__(self, manifold, mean, cov): + self._manifold = manifold + if mean.shape[0] != manifold.dim_M: + raise TypeError(f'[Gaussian]: mean dim_M {mean.shape[0]} and the manifold dim_M {manifold.dim_M} mismatch!') + if cov.shape[0] != manifold.dim_T or cov.shape[1] != manifold.dim_T: + raise TypeError(f'[Gaussian]: cov shape {cov.shape} and the manifold dim_T {manifold.dim_T, manifold.dim_T} mismatch!') + self.mean = mean + self.cov = cov + + def pdf(self, x): + """ + Evaluates pdf of the distribution at the point x. + Parameters + ---------- + :param x: torch of shape (..., self.manifold.dim_M) + Returns + ------- + :return pdf_p: torch of shape (...,) + """ + v = self.manifold.log_map(x, base=self.mean) + return self._nf * torch.exp(-(v.transpose(-2, -1) @ self.cov_inv @ v).sum(-1) / 2) + + def transform(self, A, b): + """ + Gaussian transformation. + :param A: torch of shape (..., dim_T, dim_T), rotation in tangent space + :param b: np.array of shape (..., dim_M), translation in manifold space + Returns + ------- + :return np array of the transformed Gaussians with shape (..., ) + """ + if A.shape[-2] != self.manifold.dim_T or A.shape[-1] != self.manifold.dim_T: + raise RuntimeError('[Gaussian]: Expected A to be of the dimension of the tangent' + ' space (%s, %s) and not %s' % (self.manifold.dim_T, self.manifold.dim_T, A.shape)) + if b.shape[-1] != self.manifold.dim_M: + raise RuntimeError('[Gaussian]: Expected b to be of the dimension of the manifold' + ' space (%s,) and not %s' % (self.manifold.dim_M, b.shape)) + batch_dim = b.shape[:-1] + mu_trafo = self.manifold.exp_map(A @ (self.manifold.log_map(self.mean)), base=b).view((-1, self.manifold.dim_M)) # (2.53) + sigma_trafo = self.manifold.matrix_parallel_transport(A @ self.cov @ A.transpose(-2, -1), b, mu_trafo).view((-1, self.manifold.dim_T, self.manifold.dim_T)) # (2.54) + gaussians = [] + for i in range(mu_trafo.shape[0]): + gaussians.append(Gaussian(self.manifold, mu_trafo[i], sigma_trafo[i])) + gaussians = np.array(gaussians).reshape(batch_dim) + return gaussians + + def kl_divergence_mvn(self, g): + term1 = torch.log(torch.linalg.det(g.cov)) - torch.log(torch.linalg.det(self.cov)) + term2 = torch.trace(torch.linalg.solve(g.cov, self.cov)) - self.manifold.dim_T + v = g.manifold.log_map(self.mean, base=g.mean) + term3 = v.transpose(-2, -1) @ g.cov_inv @ v + return (term1 + term2 + term3) / 2 + + def recompute_inv(self): + self._cov_inv = torch.linalg.inv(self.cov) + self._nf = 1 / (torch.sqrt((2 * np.pi)**self.manifold.dim_T * torch.linalg.det(self.cov))) + + @property + def manifold(self): + return self._manifold + + @property + def cov(self): + return self._cov + + @property + def nf(self): + return self._nf + + @cov.setter + def cov(self, value): + self._cov = value + self.recompute_inv() + + @property + def cov_inv(self): + return self._cov_inv diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py new file mode 100644 index 0000000000000000000000000000000000000000..a7e6c80be65585526fd2fa66910432c89d82452a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py @@ -0,0 +1,20 @@ + +def e_log_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x - base + + +def e_exp_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x + base + + +def e_parallel_transport(x, g, h): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + return x \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py new file mode 100644 index 0000000000000000000000000000000000000000..27c775ebcfdba8669e4c79e18627d97621842987 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py @@ -0,0 +1,137 @@ +from __future__ import annotations +from typing import Optional +import torch +import math + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_rotation_matrix +from torch_robotics.torch_kinematics_tree.geometrics.utils import vector3_to_skew_symm_matrix, multiply_inv_transform, \ + multiply_transform + + + +class Frame(object): + + def __init__(self, + rot: Optional[torch.Tensor] = None, + trans: Optional[torch.Tensor] = None, + pose: Optional[torch.Tensor] = None, + device='cpu'): + + self.device = device + + if rot is None: + self._rot = torch.eye(3, device=device).unsqueeze(0) + else: + self._rot = rot.to(device) + if len(self._rot.shape) == 2: + self._rot = self._rot.unsqueeze(0) + + if trans is None: + self._trans = torch.zeros(1, 3, device=device) + else: + self._trans = trans.to(device) + if len(self._trans.shape) == 1: + self._trans = self._trans.unsqueeze(0) + + if (pose is not None): + self.set_pose(pose) + assert self._trans.shape[0] == self._rot.shape[0] + self.batch_size = self._trans.shape[0] + + def set_pose(self, pose: torch.Tensor) -> None: + """ + Args: + pose[0, :]: x, y, z, qw, qx, qy, qz + """ + if pose.ndim == 1: + pose = pose.unsqueeze(0) + self._trans = pose[:, :3].clone().to(self.device) + self._rot = q_to_rotation_matrix(pose[:, 3:]).to(self.device) + + def set_translation(self, t: torch.Tensor) -> None: + self._trans = t.to(self.device) + + def set_rotation(self, rot: torch.Tensor) -> None: + self._rot = rot.to(self.device) + + def inverse(self) -> Frame: + rot_transpose = self._rot.transpose(-2, -1) + return Frame( + rot_transpose, + -(rot_transpose @ self._trans.unsqueeze(2)).squeeze(2), + device=self.device) + + def multiply_transform(self, frame: Frame) -> Frame: + new_rot, new_trans = multiply_transform( + self._rot, self._trans, frame.rotation, + frame.translation) + return Frame(new_rot, new_trans, device=self.device) + + def multiply_inv_transform(self, frame: Frame) -> Frame: + new_rot, new_trans = multiply_inv_transform( + frame.rotation, + frame.translation, self._rot, self._trans) + return Frame(new_rot, + new_trans, + device=self.device) + + def trans_cross_rot(self) -> torch.Tensor: + return vector3_to_skew_symm_matrix(self._trans) @ self._rot + + def get_transform_matrix(self) -> torch.Tensor: + mat = torch.eye(4, device=self.device).repeat(self.batch_size, 1, 1) + mat[:, :3, :3] = self._rot + mat[:, :3, 3] = self._trans + return mat + + def get_quaternion(self) -> torch.Tensor: + M = torch.zeros((self.batch_size, 4, 4)).to(self._rot.device) + M[:, :3, :3] = self._rot + M[:, :3, 3] = self._trans + M[:, 3, 3] = 1 + q = torch.empty((self.batch_size, 4)).to(self._rot.device) + t = torch.einsum('bii->b', M) + for n in range(self.batch_size): + tn = t[n] + if tn > M[n, 3, 3]: + q[n, 3] = tn + q[n, 2] = M[n, 1, 0] - M[n, 0, 1] + q[n, 1] = M[n, 0, 2] - M[n, 2, 0] + q[n, 0] = M[n, 2, 1] - M[n, 1, 2] + else: + i, j, k = 0, 1, 2 + if M[n, 1, 1] > M[n, 0, 0]: + i, j, k = 1, 2, 0 + if M[n, 2, 2] > M[n, i, i]: + i, j, k = 2, 0, 1 + tn = M[n, i, i] - (M[n, j, j] + M[n, k, k]) + M[n, 3, 3] + q[n, i] = tn + q[n, j] = M[n, i, j] + M[n, j, i] + q[n, k] = M[n, k, i] + M[n, i, k] + q[n, 3] = M[n, k, j] - M[n, j, k] + + q[n, :] *= 0.5 / math.sqrt(tn * M[n, 3, 3]) + return q + + def transform_point(self, point: torch.Tensor) -> torch.Tensor: + new_point = ((self._rot @ point.unsqueeze(0).transpose(-1, -2)).transpose(-1, -2)) + self._trans.unsqueeze(-2) + return new_point + + def get_euler(self) -> torch.Tensor: + return torch.atan2(self._rot[:, 2, 1], self._rot[:, 2, 2]), torch.asin(-self._rot[:, 2, 0]), torch.atan2(self._rot[:, 1, 0], self._rot[:, 0, 0]) + + @property + def rotation(self) -> torch.Tensor: + return self._rot + + @property + def translation(self) -> torch.Tensor: + return self._trans + + +if __name__ == '__main__': + # test Frame + frame = Frame() + print(frame.get_transform_matrix()) + print(frame.get_quaternion()) + print(frame.get_euler()) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py new file mode 100644 index 0000000000000000000000000000000000000000..b515eef0fd46038211629a5a5b819c83b39a16c6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py @@ -0,0 +1,278 @@ +import torch +import numpy as np +import logging +from torch_kinematics_tree.geometrics.quaternion import q_log_map, q_exp_map, q_parallel_transport +from torch_kinematics_tree.geometrics.euclidean import e_log_map, e_exp_map, e_parallel_transport +from torch_kinematics_tree.geometrics.distributions import Gaussian + + +class Manifold(object): + """ + Riemannian manifolds. + "Programming by Demonstration on Riemannian Manifolds", M.J.A. Zeestraten, 2018. + Attributes + ---------- + self.name, str, the manifold name, e.g. "R^3 x S^3" + self.dim_M, int, the manifold space dim_M + self.dim_T, int, the tangent space dim_M, dim_T <= dim_M + self.log_map, function, the map from tangent space to manifold: p_M = self.log_map(p_T, base=None), base=None is global map + self.exp_map, function, the map from manifold to tangent space: p_T = self.exp_map(p_M, base=None), base=None is global map + self.parallel_transport, the parallel transport map: p_hT = self.parallel_transport(p_gT, g, h) + """ + logger = logging.getLogger(__name__) + + def __init__(self, dim_M, dim_T, log_map, exp_map, parallel_transport, name, tensor_args=None): + self._dim_M = dim_M + self._dim_T = dim_T + self._log_map = log_map + self._exp_map = exp_map + self._parallel_transport = parallel_transport + self._name = name + if tensor_args is None: + device = torch.device('cuda:' + str(0) if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + self.tensor_args = tensor_args + + def cartesian_product(self, other): + """ + Compute the cartesian product between the current manifold self and another manifold other. + """ + dim_M = self.dim_M + other.dim_M + dim_T = self.dim_T + other.dim_T + name = self.name + " x " + other.name + + def log_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.log_map(p1, b1), other.log_map(p2, b2)), dim=-1) + + def exp_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.exp_map(p1, b1), other.exp_map(p2, b2)), dim=-1) + + def parallel_transport(p, g, h): + g1 = g[..., :self.dim_M] + g2 = g[..., self.dim_M:] + h1 = h[..., :self.dim_M] + h2 = h[..., self.dim_M:] + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + return torch.cat((self.parallel_transport(p1, g1, h1), other.parallel_transport(p2, g2, h2)), dim=-1) + + return Manifold(dim_M, dim_T, log_map, exp_map, parallel_transport, name) + + def matrix_parallel_transport(self, sigma, g, h): + Lp = torch.linalg.cholesky(sigma).transpose(-2, -1) + A_g_h_L = self.parallel_transport(Lp, g, h) + sigma_parallel = A_g_h_L.transpose(-2, -1) @ A_g_h_L + return sigma_parallel + + def mean(self, points_in_manifold, **kwargs): + """ + Computes the mean of the points points_in_manifold on the manifold according to the iterative likelihood maximization. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 50) + return_projections = kwargs.get('return_projections', False) + if isinstance(points_in_manifold, np.ndarray): + points_in_manifold = torch.from_numpy(points_in_manifold).to(**self.tensor_args) + if weights is None: + weights = torch.ones((points_in_manifold.shape[0], 1), **self.tensor_args) # batch size + if init_mu is None: + mu = points_in_manifold[0] + else: + mu = init_mu + eps_of_mu = None + k = 0 + for k in range(max_iter): + mu_last = mu + eps_of_mu = self.log_map(points_in_manifold, base=mu) + delta = (eps_of_mu * weights).mean(0) + mu = self.exp_map(delta, base=mu) + if torch.norm(mu - mu_last) < eps: + break + if k == max_iter - 1: + Manifold.logger.warning(f'Maximum of {max_iter} iterations reached!') + if return_projections: + return mu.squeeze(), eps_of_mu.squeeze() + else: + return mu.squeeze() + + def normal_distribution(self, points_in_manifold, **kwargs): + """ + Computes the Manifold Gaussian (mean and covariance) of the points points_in_manifold on the manifold + Parameters + ---------- + :param points_in_manifold: list of np.array of shape (dim_M,) or np.array of shape (dim_M, length). + Optional parameters + ------------------- + :param weights: np.array of shape (length,) that contains a weight for each point. None for no weights (all 1). + :param init_mu: np.array of shape (dim_M,), the initial mean for the iterative algorithm. + :param regularization: float, a regularization factor that prevents singular covariances + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: ManifoldGaussian, the Gaussian in this manifold. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + regularization = kwargs.get('regularization', 1e-5) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 100) + mu, eps_of_mu = self.mean( + points_in_manifold, + weights=weights, + init_mu=init_mu, + eps=eps, + max_iter=max_iter, + return_projections=True, + ) + sigma = torch.cov(eps_of_mu.T, aweights=weights) + regularization * torch.eye(self.dim_T, **self.tensor_args) # Table 2.4 + return Gaussian(self, mu, sigma) + + def gaussian_product(self, g_list, eps=1e-5, max_iter=100): + """ + Computes the Gaussian product of the ManifoldGaussians specified in g_list with the iterative likelihood maximization. + Table 2.3, Table 2.4 + Parameters + ---------- + :param g_list: list of ManifoldGaussian + Optional parameters + ------------------- + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: Gaussian, the combined Gaussian in this manifold. + """ + dim_T = self.dim_T + mus = torch.stack([g.mean.squeeze() for g in g_list], dim=0) + mu = self.mean(mus) # initial guess + J_T_W = torch.zeros((dim_T, len(g_list) * dim_T), **self.tensor_args) + W = torch.zeros((len(g_list) * dim_T, len(g_list) * dim_T), **self.tensor_args) + sigma_inv = torch.zeros((dim_T, dim_T), **self.tensor_args) + # start + k = 0 + log_last = -np.inf + eps_of_mu = torch.zeros(dim_T * len(g_list), **self.tensor_args) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + for k in range(max_iter): + sigma_inv *= 0 + mu_last = mu + for p, g in enumerate(g_list): + sigma_parallel_p = self.matrix_parallel_transport(g.cov, g.mean, mu) + J_T_W[:, dim_T * p:dim_T * (p + 1)] = -torch.linalg.inv(sigma_parallel_p) + W[dim_T * p:dim_T * (p + 1), dim_T * p:dim_T * (p + 1)] = J_T_W[:, dim_T * p:dim_T * (p + 1)] + sigma_inv += -J_T_W[:, dim_T * p:dim_T * (p + 1)] + # backtracking line search + log_likelihood = -np.inf + delta = -torch.linalg.solve(sigma_inv, J_T_W @ eps_of_mu) + s = 1 + while log_likelihood <= log_last and np.abs(s) > 1e-30: + mu = self.exp_map(delta * s, base=mu_last) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + log_likelihood = eps_of_mu @ W @ eps_of_mu + s /= -np.sqrt(2) + if log_likelihood - log_last < eps: + break + log_last = log_likelihood + if k == max_iter - 1: + Manifold.logger.warning(f'Iterative likelihood maximization for mean terminated after reaching {max_iter} iterations') + return Gaussian(self, mu.squeeze(), torch.linalg.inv(sigma_inv)) + + def get_origin(self): + spaces = self.name.split(" x ") + origin = [] + for man_name in spaces: + if man_name == "S^3": + origin.extend([1., 0., 0., 0.]) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + origin.extend([0.] * n) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return torch.tensor(origin, **self.tensor_args) + + def get_pos_quat_indices(self, tangent=False): + spaces = self.name.split(" x ") + pos_idx, quat_idx = [], [] + i = 0 + for man_name in spaces: + if man_name == "S^3": + r = 3 if tangent else 4 + quat_idx.extend(range(i, i + r)) + i += r + elif man_name[:2] == "R^": + n = int(man_name[2:]) + pos_idx.extend(range(i, i + n)) + i += n + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return np.array(pos_idx), np.array(quat_idx) + + @staticmethod + def get_quaternion_manifold(): + return Manifold(4, 3, q_log_map, q_exp_map, q_parallel_transport, "S^3") + + @staticmethod + def get_euclidean_manifold(n): + return Manifold(n, n, e_log_map, e_exp_map, e_parallel_transport, ("R^%s" % n)) + + @staticmethod + def get_manifold_from_name(name): + manifold_names = name.split(" x ") + manifolds = [] + for man_name in manifold_names: + if man_name == "S^3": + manifolds.append(Manifold.get_quaternion_manifold()) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + manifolds.append((Manifold.get_euclidean_manifold(n))) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + full_manifold = manifolds[0] + for man in manifolds[1:]: + full_manifold = full_manifold.cartesian_product(man) + return full_manifold + + @property + def dim_M(self): # noqa + return self._dim_M + + @property + def dim_T(self): # noqa + return self._dim_T + + @property + def log_map(self): + return self._log_map + + @property + def exp_map(self): + return self._exp_map + + @property + def parallel_transport(self): + return self._parallel_transport + + @property + def name(self): + return self._name \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py new file mode 100644 index 0000000000000000000000000000000000000000..48ab1413f8f8b3aadf1caa674e530a4c53dbf093 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py @@ -0,0 +1,242 @@ +import numpy as np +import torch +import torch.nn.functional as F + + +''' +"Programming by Demonstration on Riemannian Manifolds", M.J.A. Zeestraten, 2018. TABLE I +https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles +Format: q = [w, x, y, z]. # NOTE: change to [x, y, z, w] for pyBullet +''' + + +def sqrt_with_mask(x): + """ + torch.sqrt(torch.max(0, x)) + """ + ret = torch.zeros_like(x) + positive_mask = x > 0 + ret[positive_mask] = torch.sqrt(x[positive_mask]) + return ret + + +def q_exp_map(v, base=None): + v = v.unsqueeze(0) if len(v.shape) == 1 else v + tensor_args = {'device': v.device, 'dtype': v.dtype} + batch_dim = v.shape[:-1] + if base is None: + norm_v = torch.norm(v, dim=-1) + q = torch.zeros(batch_dim + (4,), **tensor_args) + q[..., 0] = 1. + non_0 = torch.where(norm_v > 0) + non_0_norm = norm_v[non_0].unsqueeze(-1) + q[non_0] = torch.cat(( + torch.cos(non_0_norm), + (torch.sin(non_0_norm) / non_0_norm) * v[non_0]), dim=-1) + return q + else: + return q_mul(base, q_exp_map(v)) + + +def q_log_map(q, base=None, eps=1e-10): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + tensor_args = {'device': q.device, 'dtype': q.dtype} + batch_dim = q.shape[:-1] + if base is None: + norm_q = torch.norm(q[..., 1:], dim=-1) + non_0 = torch.where((norm_q > 0) * (torch.abs(q[..., 0]) <= 1)) # eps for numerical stability + q_non_singular = q[non_0] + non_0_norm = norm_q[non_0].unsqueeze(-1) + acos = torch.acos(q_non_singular[..., 0]).unsqueeze(-1) + acos[torch.where(q_non_singular[..., 0] < 0)] += -np.pi # q and -q maps to the same point in SO(3) + v = torch.zeros(batch_dim + (3,), **tensor_args) + # print(q_non_singular[..., 1:].shape, (acos / non_0_norm.unsqueeze(-1)).repeat((1,)*len(batch_dim) + (3,)).shape) + v[non_0] = q_non_singular[..., 1:] * (acos / non_0_norm) + return v + else: + return q_log_map(q_mul(q_inverse(base), q)) + + +def q_parallel_transport(p_g, g, h, eps=1e-10): + p_g = p_g.unsqueeze(0) if len(p_g.shape) == 1 else p_g + g, h = g.squeeze(), h.squeeze() + tensor_args = {'device': p_g.device, 'dtype': p_g.dtype} + Q_g = q_to_quaternion_matrix(g).squeeze() + Q_h = q_to_quaternion_matrix(h).squeeze() + B = torch.cat([ + torch.zeros((1, 3)), torch.eye(3) + ], dim=0).to(**tensor_args) + log_g_h = q_log_map(h, base=g) + m = torch.norm(log_g_h, dim=-1) + if m < eps: # divide by zero + return p_g + q_temp = torch.zeros((1, 4), **tensor_args) + q_temp[0, 1:] = log_g_h / m + u = (Q_g @ q_temp.unsqueeze(-1)).squeeze() + I4 = torch.eye(4).to(**tensor_args) + R_g_h = I4 - torch.sin(m) * torch.outer(g, u) + (torch.cos(m) - 1) * torch.outer(u, u) + A_g_h = B.T @ Q_h.T @ R_g_h @ Q_g @ B + res = (A_g_h @ p_g.unsqueeze(-1)).squeeze(-1) + return res + + +def q_mul(q1, q2): + res = q_to_quaternion_matrix(q1) @ q2.unsqueeze(-1) + return res.squeeze(-1) + + +def q_inverse(q): + tensor_args = {'device': q.device, 'dtype': q.dtype} + scaling = torch.tensor([1, -1, -1, -1], **tensor_args) + return q * scaling / q_norm_squared(q) + + +def q_div(q1, q2): + return q_mul(q1, q_inverse(q2)) + + +def q_norm_squared(q): + return torch.sum(q ** 2, dim=-1, keepdim=True) + + +def q_to_rotation_matrix(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + double_cover = 2.0 / (q ** 2).sum(-1) + o = torch.stack( + ( + 1 - double_cover * (y * y + z * z), + double_cover * (x * y - z * w), + double_cover * (x * z + y * w), + double_cover * (x * y + z * w), + 1 - double_cover * (x * x + z * z), + double_cover * (y * z - x * w), + double_cover * (x * z - y * w), + double_cover * (y * z + x * w), + 1 - double_cover * (x * x + y * y), + ), + dim=-1 + ) + return o.reshape(q.shape[:-1] + (3, 3)) + + +def q_to_quaternion_matrix(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + o = torch.stack([ + w, -x, -y, -z, + x, w, -z, y, + y, z, w, -x, + z, -y, x, w + ], dim=-1) + return o.reshape(q.shape[:-1] + (4, 4)) + + +def rotation_matrix_to_q(rot_mat): + rot_mat = rot_mat.unsqueeze(0) if len(rot_mat.shape) == 1 else rot_mat + assert rot_mat.size(-1) == 3 or rot_mat.size(-2) == 3 + batch_dim = rot_mat.shape[:-2] + tensor_args = {'device': rot_mat.device, 'dtype': rot_mat.dtype} + m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind( + rot_mat.reshape(batch_dim + (9,)), dim=-1 + ) + q_abs = sqrt_with_mask( + torch.stack( + [ + 1.0 + m00 + m11 + m22, + 1.0 + m00 - m11 - m22, + 1.0 - m00 + m11 - m22, + 1.0 - m00 - m11 + m22, + ], + dim=-1, + ) + ).to(**tensor_args) + quat_by_wxyz = torch.stack( + [ + torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1), + torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1), + torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1), + torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1), + ], + dim=-2, + ).to(**tensor_args) + flr = torch.tensor(0.1).to(**tensor_args) + quat_candidates = quat_by_wxyz / (2.0 * q_abs[..., None].max(flr)) + # choose the best-conditioned quaternion with the largest denominator + return quat_candidates[F.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape(batch_dim + (4,)) + + +def q_to_axis_angles(q, eps=1e-10): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + norm_q = torch.norm(q[..., 1:], p=2, dim=-1, keepdim=True) + half_angles = torch.atan2(norm_q, q[..., :1]) + angles = 2 * half_angles + beta = angles.abs() < eps + s_half_angles = torch.empty_like(angles) + s_half_angles[~beta] = ( + torch.sin(half_angles[~beta]) / angles[~beta] + ) + s_half_angles[beta] = ( + 0.5 - (angles[beta] * angles[beta]) / 48 + ) + return q[..., 1:] / s_half_angles + + +def axis_angles_to_q(axis_angles, eps=1e-10): + axis_angles = axis_angles.unsqueeze(0) if len(axis_angles.shape) == 1 else axis_angles + angles = torch.norm(axis_angles, p=2, dim=-1, keepdim=True) + half_angles = angles / 2 + beta = angles.abs() < eps + s_half_angles = torch.empty_like(angles) + s_half_angles[~beta] = ( + torch.sin(half_angles[~beta]) / angles[~beta] + ) + s_half_angles[beta] = ( + 0.5 - (angles[beta] * angles[beta]) / 48 + ) + q = torch.cat( + [torch.cos(half_angles), axis_angles * s_half_angles], dim=-1 + ) + return q + + +def q_to_euler(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + t0 = 2.0 * (w * x + y * z) + t1 = 1.0 - 2.0 * (x * x + y * y) + roll = torch.atan2(t0, t1) + t2 = torch.clamp(2.0 * (w * y - z * x), -1.0, 1.0) + pitch = torch.asin(t2) + t3 = 2.0 * (w * z + x * y) + t4 = 1.0 - 2.0 * (y * y + z * z) + yaw = torch.atan2(t3, t4) + euler = torch.stack([roll, pitch, yaw], dim=-1) + return euler + + +def euler_to_q(euler): + euler = euler.unsqueeze(0) if len(euler.shape) == 1 else euler + roll, pitch, yaw = torch.unbind(euler, -1) + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + cp = torch.cos(pitch * 0.5) + sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5) + sr = torch.sin(roll * 0.5) + w = cr * cp * cy + sr * sp * sy + x = sr * cp * cy - cr * sp * sy + y = cr * sp * cy + sr * cp * sy + z = cr * cp * sy - sr * sp * cy + q = torch.stack([w, x, y, z], dim=-1) + return q + + +def q_convert_xyzw(q): + w, x, y, z = torch.unbind(q, dim=-1) + return torch.stack([x, y, z, w], dim=-1) + + +def q_convert_wxyz(q): + x, y, z, w = torch.unbind(q, dim=-1) + return torch.stack([w, x, y, z], dim=-1) \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py new file mode 100644 index 0000000000000000000000000000000000000000..e61ff5c30a65b2eb302bc69c664d528dbe18ce15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py @@ -0,0 +1,205 @@ +from copy import deepcopy +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph +import json +import torch +import numpy as np +import torch.distributions as D +from torch_robotics.torch_kinematics_tree.geometrics.utils import MinMaxScaler, euclidean_distance + + +class Skeleton(): + + def __init__(self, data, node_list=None, distance_func=None, device='cpu'): + self.load_skeleton(data) + self.node_pos = nx.get_node_attributes(self.skeleton, 'pose') + self.node_var = nx.get_node_attributes(self.skeleton, 'var') + self.node_name = nx.get_node_attributes(self.skeleton, 'name') + self.node_ids = list(self.node_name.keys()) + if node_list is None: + node_list = deepcopy(self.node_ids) + self.node_list = node_list + self.num_node = len(self.node_ids) + # self.node_ids.sort() + if distance_func is None: + distance_func = euclidean_distance + self.distance_func = distance_func + self.device = device + self.node_dist = { + id: D.MultivariateNormal(torch.tensor(self.node_pos[id]).to(device), + covariance_matrix=self.node_var[id] * torch.eye(2, device=device)) + for id in self.node_ids + } + # compute link lengths + self.compute_link_lengths() + # for plotting + self.three_d = len(self.node_pos[self.node_list[-1]]) == 3 + + def load_skeleton(self, data): + if isinstance(data, Path): + with open(data) as f: + data = json.load(f) + self.skeleton = json_graph.node_link_graph(data) + else: # assume dict + self.skeleton = json_graph.node_link_graph(data) + + def compute_link_lengths(self): + for u, v in self.skeleton.edges(): + u_pos, v_pos = torch.tensor(self.node_pos[u], device=self.device), torch.tensor(self.node_pos[v], device=self.device) + self.skeleton[u][v][0]['length'] = self.distance_func(u_pos, v_pos).squeeze().cpu().item() + + def compute_adjacency_matrix(self, weight='length'): + A = nx.adjacency_matrix(self.skeleton, weight=weight) + A = A.todense() + return A + + def get_edges(self, weighted=False): + edges = np.array(list(self.skeleton.edges())) + if weighted: + weights = [[self.skeleton[u][v][0]['length']] for u, v in edges] + else: + weights = [[1.0]] * len(edges) + weights = np.array(weights) + return edges, weights + + def update_node_pose(self, node_dict): + nx.set_node_attributes(self.skeleton, node_dict) + self.node_pos = nx.get_node_attributes(self.skeleton, 'pose') + self.compute_link_lengths() + + def compute_self_distance(self, length_cutoff=None, normalized=False): + res = dict(nx.all_pairs_dijkstra_path_length(self.skeleton, cutoff=length_cutoff, weight='length')) + # since res is a generator + D = np.zeros((len(self.node_list), len(self.node_list))) + for i, id1 in enumerate(self.node_list): + for j, id2 in enumerate(self.node_list): + D[i, j] = res[id1][id2] + if normalized: + scaler = MinMaxScaler() + D = scaler.scale(D) + return D + + def get_all_neighbors(self): + N = np.zeros(len(self.node_list)) + for i, id in enumerate(self.node_list): + N[i] = len(list(self.skeleton.neighbors(id))) + return N + + def get_pos_tensor(self): + return torch.tensor(np.array([self.node_pos[id] for id in self.node_ids]), device=self.device) + + def get_pos_tensor_from_list(self, node_list=None): + if node_list is None: + node_list = self.node_list + return torch.tensor(np.array([self.node_pos[id] for id in node_list]), device=self.device) + + def get_node_list_dict(self): + return {k: v for k, v in self.node_pos.items() if k in self.node_list} + + def draw_skeleton(self, pos=None, shift=None, with_labels=False, color='b', ax=None): + if pos is None: + pos = self.node_pos + else: + if torch.is_tensor(next(iter(pos.values()))): + pos = {k: v.cpu().numpy() for k, v in pos.items()} + if shift is not None: + pos = {k: v + shift for k, v in pos.items()} + if self.three_d: + ax.scatter3D( + xs=[pos[id][0] for id in self.node_list], + ys=[pos[id][1] for id in self.node_list], + zs=[pos[id][2] for id in self.node_list], + color=color, + linewidth=10) + for u, v in self.skeleton.edges(): + if u in self.node_list and v in self.node_list: + ax.plot3D( + xs=[pos[u][0], pos[v][0]], + ys=[pos[u][1], pos[v][1]], + zs=[pos[u][2], pos[v][2]], + color='k', + linewidth=7) + else: + nx.draw(self.skeleton, pos, ax, labels=self.node_name, with_labels=with_labels, font_size=6) + + def sample_posture(self, batch_size): + postures = [] + for id in self.node_ids: + postures.append(self.node_dist[id].sample((batch_size, ))) + return torch.stack(postures, dim=1) + + +def get_skeleton_from_model(model, q_calib, link_list=None): + '''Get Skeleton from robots model''' + if isinstance(q_calib, np.ndarray) or isinstance(q_calib, list): + q_calib = torch.tensor(q_calib, device=model._device) + if q_calib.ndim == 1: + q_calib = q_calib.unsqueeze(0) + H = model.compute_forward_kinematics_all_links(q_calib, return_dict=True) + node_list = [] + # only get euclidean positions + for name, frame in H.items(): + pose = frame.translation + pose = pose.squeeze().detach().cpu().numpy() + node_list.append({ + 'id': name, 'name': name, 'pose': pose, 'var': 0.001 + }) + child_map = model._model.robot.child_map + edge_list = [] + for parent, childs in child_map.items(): + for child in childs: + edge_list.append({ + 'source': parent, + 'target': child[1] + }) + data = {'nodes': node_list, 'links': edge_list} + skeleton = Skeleton(data, node_list=link_list, device=model._device) + return skeleton + + +def get_skeleton_from_mediapipe(landmark_list, connections, link_list=None, present_thres=0.5, vis_thres=0.5, mirror=False, relative_pose=False, shift=np.zeros(3), device='cpu'): + if landmark_list is None: + return None + + plotted_landmarks = {} + for idx, landmark in enumerate(landmark_list.landmark): + if ((landmark.HasField('visibility') and + landmark.visibility < vis_thres) or + (landmark.HasField('presence') and + landmark.presence < present_thres)): + continue + if mirror: + plotted_landmarks[idx] = [-landmark.z, -landmark.x, landmark.y] + else: + plotted_landmarks[idx] = [landmark.z, -landmark.x, -landmark.y] + + + if link_list is not None: + # return None if fail to recognize correct links + if not all(item in plotted_landmarks for item in link_list): + return None + node_list = [] + # only get euclidean positions + base_pose = np.array(plotted_landmarks[0]) + for name, pose in plotted_landmarks.items(): + pose = np.array(pose) + node_list.append({ + 'id': name, 'name': name, 'pose': ((pose - base_pose) if relative_pose else pose) + shift, 'var': 0.001 + }) + num_landmarks = len(landmark_list.landmark) + edge_list = [] + for connection in connections: + start_idx = connection[0] + end_idx = connection[1] + if not (0 <= start_idx < num_landmarks and 0 <= end_idx < num_landmarks): + raise ValueError(f'Landmark index is out of range. Invalid connection ' + f'from landmark #{start_idx} to landmark #{end_idx}.') + if start_idx in plotted_landmarks and end_idx in plotted_landmarks: + edge_list.append({ + 'source': start_idx, + 'target': end_idx + }) + data = {'nodes': node_list, 'links': edge_list} + skeleton = Skeleton(data, node_list=link_list, device=device) + return skeleton diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py new file mode 100644 index 0000000000000000000000000000000000000000..547c7dc97ab9745a1c41e739bbb11dea73f531aa --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py @@ -0,0 +1,106 @@ +from __future__ import annotations +from typing import Optional +import torch +from torch_robotics.torch_kinematics_tree.geometrics.utils import cross_product, to_torch_2d_min +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame + + +def x_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.ones(batch_size) + R[:, 1, 1] = torch.cos(angle) + R[:, 1, 2] = -torch.sin(angle) + R[:, 2, 1] = torch.sin(angle) + R[:, 2, 2] = torch.cos(angle) + return R + + +def y_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.cos(angle) + R[:, 0, 2] = torch.sin(angle) + R[:, 1, 1] = torch.ones(batch_size) + R[:, 2, 0] = -torch.sin(angle) + R[:, 2, 2] = torch.cos(angle) + return R + + +def z_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.cos(angle) + R[:, 0, 1] = -torch.sin(angle) + R[:, 1, 0] = torch.sin(angle) + R[:, 1, 1] = torch.cos(angle) + R[:, 2, 2] = torch.ones(batch_size) + return R + + +class MotionVec(object): + def __init__( + self, + lin_motion: Optional[torch.Tensor] = None, + ang_motion: Optional[torch.Tensor] = None, + device='cpu', + ): + if lin_motion is None or ang_motion is None: + device = device + self.lin = ( + lin_motion if lin_motion is not None else torch.zeros((1, 3), device=device) + ) + self.ang = ( + ang_motion if ang_motion is not None else torch.zeros((1, 3), device=device) + ) + + def add_motion_vec(self, mv: MotionVec) -> MotionVec: + """ + Args: + mv: spatial motion vector + Returns: + the sum of motion vectors + """ + + return MotionVec(self.lin + mv.lin, self.ang + mv.ang) + + def cross_motion_vec(self, mv: MotionVec) -> MotionVec: + """ + Args: + mv: spatial motion vector + Returns: + the cross product between motion vectors + """ + new_ang = cross_product(self.ang, mv.ang) + new_lin = cross_product(self.ang, mv.lin) + cross_product(self.lin, mv.ang) + return MotionVec(new_lin, new_ang) + + def transform(self, transform: Frame) -> MotionVec: + """ + Args: + transform: a coordinate transform object + Returns: + the motion vector (self) transformed by the coordinate transform + """ + new_ang = (transform.rotation @ self.ang.unsqueeze(2)).squeeze(2) + new_lin = (transform.trans_cross_rot() @ self.ang.unsqueeze(2)).squeeze(2) + new_lin += (transform.rotation @ self.lin.unsqueeze(2)).squeeze(2) + return MotionVec(new_lin, new_ang) + + def get_vector(self): + return torch.cat([self.ang, self.lin], dim=1) + + def dot(self, mv): + tmp1 = torch.sum(self.ang * mv.ang, dim=-1) + tmp2 = torch.sum(self.lin * mv.lin, dim=-1) + return tmp1 + tmp2 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py new file mode 100644 index 0000000000000000000000000000000000000000..6c388f45fb25ea4301ba90289eae63241cb8c6ba --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py @@ -0,0 +1,102 @@ +import numpy as np +import torch + +from torch_kinematics_tree.geometrics.manifold import Manifold + + +def compute_traj_derivatives(traj, dt, manifold=None, smooth=False): + """ + Estimate the trajectory dynamics. + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param smooth (boolean): whether smoothing is applied to traj first. + Returns + ---------- + :return traj (torch of shape (length, dim_M)): pose trajectory without/with smoothing. + :return d_traj (torch of shape (length, dim_T)): first derivative of traj. + :return dd_traj (torch of shape (length, dim_T)): second derivative of traj. + """ + dim_M = traj.shape[-1] + if not manifold: + # if no manifold specified, Euclidean manifold is used. + manifold = Manifold.get_euclidean_manifold(dim_M) + # smoothing first + if smooth: + traj = smooth_traj(traj, manifold=manifold) + # compute derivatives + d_traj = compute_traj_velocity(traj, dt, manifold=manifold) + dd_traj = compute_traj_velocity(d_traj, dt) + # copy the last 2 entries (corner case) + dd_traj[-2, :] = dd_traj[-3, :] + dd_traj[-1, :] = dd_traj[-3, :] + return traj, d_traj, dd_traj + + +def smooth_traj(traj, manifold=None, window_length=30, beta=14): + """ + Smooth the pose using the kaiser window np.kaiser(window_length, beta). + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param window_length (int): the length of the window. (Default: 30). + :param beta (float): shape parameter for the kaiser window. (Default: 14). + Returns + ---------- + :return smooth_traj (torch of shape (length, dim_M)): pose trajectory after smoothing. + """ + length, dim_M = traj.shape[-2:] + tensor_args = {'device': traj.device, 'dtype': traj.dtype} + if not manifold: + manifold = Manifold.get_euclidean_manifold(dim_M) + if dim_M != manifold.dim_M: + raise ValueError('[Trajectory]: Input X shape %s is not consistent with manifold.dim_M %s' % (dim_M, manifold.dim_M)) + # apply kaiser filter + half_wl = int(window_length / 2) + window = torch.kaiser_window(window_length, periodic=False, beta=beta, **tensor_args).unsqueeze(-1) + smooth_traj = traj.detach().clone() + for t in range(length): + weights = window[max(half_wl - t, 0):(2 * half_wl - max(0, t + half_wl - length))] + smooth_traj[t, :] = manifold.mean(smooth_traj[max(t - half_wl, 0):(t + half_wl), :], weights=weights) + return smooth_traj + + +def compute_traj_velocity(traj, dt, manifold=None): + """ + Estimate the first derivative of input trajectory traj. + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param dt (float): sampling time associated with traj. + Returns + ---------- + :return d_traj (torch of shape (length, dim_T)): first derivative of traj. + """ + length, dim_M = traj.shape[-2:] + if not manifold: + manifold = Manifold.get_euclidean_manifold(dim_M) + if dim_M != manifold.dim_M: + raise ValueError('[Trajectory]: Input X shape %s is not consistent with manifold.dim_M %s' % (dim_M, manifold.dim_M)) + # estimate d_traj + d_traj = [] + for t in range(length - 1): + d_traj_t = manifold.log_map(traj[t + 1, :], base=traj[t, :]) / dt + d_traj.append(d_traj_t.squeeze()) + d_traj.append(d_traj_t.squeeze()) + d_traj = torch.stack(d_traj, dim=0) + names = np.array(manifold.name.split(' x ')) + if 'S^3' in names: + indices = np.where(names == 'S^3')[0] + for i in indices: + d_traj[:, i * 3:i * 3 + 3] *= 2 # convert to angular + if d_traj.shape[-1] != manifold.dim_T: + raise ValueError('[Trajectory]: d_traj shape %s is not consistent with manifold.dim_T %s' % (d_traj.shape[0], manifold.dim_T)) + if d_traj.shape[-2] != length: + raise ValueError('[Trajectory]: Length of d_traj %s is not consistent with input traj length %s' % (d_traj.shape[1], length)) + return d_traj + + +def interpolate(p1, p2, d_param, dist): + """ interpolate between points """ + alpha = min(max(d_param / dist, 0.), 1.) + return (1. - alpha) * p1 + alpha * p2 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..9580b14dca47725d673be6d3e5c295cb3340d048 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py @@ -0,0 +1,349 @@ +import torch +import numpy as np + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import rotation_matrix_to_q +from torch_robotics.torch_utils.torch_utils import to_torch, to_torch_2d_min + +DEFAULT_ACOS_BOUND: float = 1.0 - 1e-4 + + +@torch.jit.script +def multiply_transform(w_rot_l, w_trans_l, l_rot_c, l_trans_c): + + l_trans_c = l_trans_c.float() + w_rot_c = w_rot_l @ l_rot_c + w_trans_c = (w_rot_l @ l_trans_c.unsqueeze(-1)).squeeze(-1) + w_trans_l + + return w_rot_c, w_trans_c + + +@torch.jit.script +def multiply_inv_transform(l_rot_w, l_trans_w, l_rot_c, l_trans_c): + w_rot_l = l_rot_w.transpose(-1, -2) + w_rot_c = w_rot_l @ l_rot_c + + w_trans_l = -(w_rot_l @ l_trans_w.unsqueeze(2)).squeeze(2) + w_trans_c = (w_rot_l @ l_trans_c.unsqueeze(-1)).squeeze(-1) + w_trans_l + + return w_rot_c, w_trans_c + + +@torch.jit.script +def transform_point(point, rot, trans): + new_point = (point @ rot.transpose(-1, -2)) + trans + return new_point + + +@torch.jit.script +def rotate_point(point, rot): + new_point = (point @ rot.transpose(-1, -2)) + return new_point + + +def vector3_to_skew_symm_matrix(vec3): + vec3 = to_torch_2d_min(vec3) + batch_size = vec3.shape[0] + skew_symm_mat = vec3.new_zeros((batch_size, 3, 3)) + skew_symm_mat[:, 0, 1] = -vec3[:, 2] + skew_symm_mat[:, 0, 2] = vec3[:, 1] + skew_symm_mat[:, 1, 0] = vec3[:, 2] + skew_symm_mat[:, 1, 2] = -vec3[:, 0] + skew_symm_mat[:, 2, 0] = -vec3[:, 1] + skew_symm_mat[:, 2, 1] = vec3[:, 0] + return skew_symm_mat + + +def skew_symm_matrix_to_vec(R): + if R.ndim < 3: + R = R.unsqueeze(0) + batch_dim = R.shape[:-2] + vec = torch.zeros(batch_dim + (3,)) + vec[..., 0] = R[..., 2, 1] + vec[..., 1] = R[..., 0, 2] + vec[..., 2] = R[..., 1, 0] + return vec + + +class MinMaxScaler(): + # NOTE: scale x, y to range [0, 1] + def __init__(self, min=None, max=None, dim=None) -> None: + self.min = min + self.max = max + self.dim = dim + + def scale(self, X): + if isinstance(X, np.ndarray): + if self.min is None: + self.min = X.min(self.dim) # link dim + if self.max is None: + self.max = X.max(self.dim) # link dim + else: + if self.min is None: + if self.dim is None: + self.min = X.detach().min() + else: + self.min = X.detach().min(self.dim)[0] # link dim + if self.max is None: + if self.max is None: + self.max = X.detach().max() + else: + self.max = X.detach().min(self.dim)[0] # link dim + + X_scaled = (X - self.min) / (self.max - self.min) + return X_scaled + + +def euclidean_distance(x_batch, x_target, vel_batch=None, vel_target=None, w_pos=1., w_rot=0., w_vpos=1., w_vrot=0., broadcast_target=False, normalized_input=False): + # NOTE: normalizing x inputs + if x_batch.ndim == 1: + x_batch = x_batch.unsqueeze(0) + + if x_target.ndim == 1: + x_target = x_target.unsqueeze(0) + + dim_scale_target = -2 + if broadcast_target: + x_target = x_target.unsqueeze(1) + dim_scale_target = -3 + if normalized_input: + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + x_batch = scaler1.scale(x_batch) + x_target = scaler2.scale(x_target) + T_distance = torch.linalg.norm(x_batch - x_target, dim=-1) + D = w_pos * T_distance + + # now compute vel distance + if vel_batch is not None and vel_target is not None: + if vel_target.ndim == 1: + vel_target = vel_target.unsqueeze(0) + if broadcast_target: + vel_target = vel_target.unsqueeze(1) + if normalized_input: # normalize to range [0, 1] + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + vel_batch = scaler1.scale(vel_batch) + vel_target = scaler2.scale(vel_target) + D += w_vpos * torch.linalg.norm(vel_batch - vel_target, dim=-1) + + return w_pos * T_distance + + +def SE3_distance(H_batch, H_target, vel_batch=None, vel_target=None, w_pos=1., w_rot=1., w_vpos=1., w_vrot=1., broadcast_target=False, normalized_input=False): + # NOTE: normalizing x inputs + if H_batch.ndim == 2: + H_batch = H_batch.unsqueeze(0) + + if H_target.ndim == 2: + H_target = H_target.unsqueeze(0) + + dim_scale_target = -2 + if broadcast_target: + H_target = H_target.unsqueeze(1) + dim_scale_target = -3 + + x_batch, x_target = H_batch[..., :-1, -1], H_target[..., :-1, -1] + if normalized_input: # normalize to range [0, 1] + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + x_batch = scaler1.scale(x_batch) + x_target = scaler2.scale(x_target) + D = 0. + if w_rot > 0.: + R_distance = (1 - so3_relative_angle(H_batch[..., :3, :3], H_target[..., :3, :3], cos_angle=True)) + D += w_rot * R_distance + if w_pos > 0.: + T_distance = torch.linalg.norm(x_batch - x_target, dim=-1) + D += w_pos * T_distance + + # now compute vel distance + if vel_batch is not None and vel_target is not None: + v_trans, v_rot = vel_batch[0], vel_batch[1] + v_trans_target, v_rot_target = vel_target[0], vel_target[1] + if v_trans_target.ndim == 1: + v_trans_target = v_trans_target.unsqueeze(0) + if v_rot_target.ndim == 1: + v_rot_target = v_rot_target.unsqueeze(0) + if broadcast_target: + v_trans_target = v_trans_target.unsqueeze(1) + v_rot_target = v_rot_target.unsqueeze(1) + if normalized_input: # normalize to range [0, 1] + v_trans_scaler1, v_rot_scaler1 = MinMaxScaler(dim=-2), MinMaxScaler(dim=-2) + v_trans = v_trans_scaler1.scale(v_trans) + v_rot = v_rot_scaler1.scale(v_rot) + v_trans_scaler2, v_rot_scaler2 = MinMaxScaler(dim=dim_scale_target), MinMaxScaler(dim=dim_scale_target) + v_trans_target = v_trans_scaler2.scale(v_trans_target) + v_rot_target = v_rot_scaler2.scale(v_rot_target) + if w_vpos > 0.: + D += w_vpos * torch.linalg.norm(v_trans - v_trans_target, dim=-1) + if w_vrot > 0.: + D += w_vrot * torch.linalg.norm(v_rot - v_rot_target, dim=-1) + return D + + +def minus_SO3(R1, R2, eps=1.0e-14, add_dim=True): + R12 = torch.matmul(R1, R2.transpose(-2, -1)) + skew_mat = log_SO3(R12, eps=eps) + vec = skew_symm_matrix_to_vec(skew_mat) + if add_dim: + vec = vec.unsqueeze(0) + return vec + + +def log_SO3(R, eps=1.0e-14): + # assert ( + # torch.abs(torch.abs(torch.det(R)) - 1.0) < 1e-3 + # ), "det(R) = %f" % torch.det(R) + trR = (torch.einsum('...ii->...', R) - 1.0) / 2.0 + cond = trR < -1 + if cond.any(): + print("Warning: trR/2-1 = %f < -1.0" % trR) + trR[cond] = -1. + cond = trR > 1 + if cond.any(): + print("Warning: trR/2-1 = %f > 1.0" % trR) + trR[cond] = 1. + + theta = torch.acos(trR).view(R.shape[:-2] + (1, 1)) + omegahat = (R - R.mT) / ((2.0 * torch.sin(theta)) + eps) + return theta * omegahat + + +def so3_relative_angle(R1, R2, cos_angle=False, eps=1e-4): + R12 = torch.matmul(R1, R2.transpose(-2, -1)) + return so3_rotation_angle(R12, cos_angle=cos_angle, eps=eps) + + +def so3_rotation_angle(R, cos_angle=False, eps=1e-4): + dim1, dim2 = R.shape[-2:] + if dim1 != 3 or dim2 != 3: + raise ValueError("Input has to be a batch of 3x3 Tensors.") + rot_trace = R[..., 0, 0] + R[..., 1, 1] + R[..., 2, 2] + + # phi rotation angle + phi_cos = (rot_trace - 1.0) * 0.5 + + if cos_angle: + return phi_cos + else: + if eps > 0.0: + bound = 1.0 - eps + return acos_linear_extrapolation(phi_cos, (-bound, bound)) + else: + return torch.acos(phi_cos) + + +def acos_linear_extrapolation(x, bounds=(-(DEFAULT_ACOS_BOUND), DEFAULT_ACOS_BOUND)): + lower_bound, upper_bound = bounds + if lower_bound > upper_bound: + raise ValueError("lower bound has to be smaller or equal to upper bound.") + if lower_bound <= -1.0 or upper_bound >= 1.0: + raise ValueError("Both lower bound and upper bound have to be within (-1, 1).") + + # init an empty tensor and define the domain sets + acos_extrap = torch.empty_like(x) + x_upper = x >= upper_bound + x_lower = x <= lower_bound + x_mid = (~x_upper) & (~x_lower) + + # acos calculation for upper_bound < x < lower_bound + acos_extrap[x_mid] = torch.acos(x[x_mid]) + # the linear extrapolation for x >= upper_bound + acos_extrap[x_upper] = _acos_linear_approximation(x[x_upper], upper_bound) + # the linear extrapolation for x <= lower_bound + acos_extrap[x_lower] = _acos_linear_approximation(x[x_lower], lower_bound) + + return acos_extrap + + +def _acos_linear_approximation(x: torch.Tensor, x0: float) -> torch.Tensor: + """ + Calculates the 1st order Taylor expansion of `arccos(x)` around `x0`. + """ + return (x - x0) * _dacos_dx(x0) + np.arccos(x0) + + +def _dacos_dx(x: float) -> float: + """ + Calculates the derivative of `arccos(x)` w.r.t. `x`. + """ + return (-1.0) / np.sqrt(1.0 - x * x) + + +def cross_product(vec3a, vec3b): + vec3a = to_torch_2d_min(vec3a) + vec3b = to_torch_2d_min(vec3b) + skew_symm_mat_a = vector3_to_skew_symm_matrix(vec3a) + return (skew_symm_mat_a @ vec3b.unsqueeze(2)).squeeze(2) + + +def bfill_lowertriangle(A: torch.Tensor, vec: torch.Tensor): + ii, jj = np.tril_indices(A.size(-2), k=-1, m=A.size(-1)) + A[..., ii, jj] = vec + return A + + +def bfill_diagonal(A: torch.Tensor, vec: torch.Tensor): + ii, jj = np.diag_indices(min(A.size(-2), A.size(-1))) + A[..., ii, jj] = vec + return A + +def torch_square(x): + return x * x + + +def exp_map_so3(omega, eps=1.0e-14): + omegahat = vector3_to_skew_symm_matrix(omega).squeeze() + + norm_omega = torch.norm(omega, p=2) + exp_omegahat = ( + torch.eye(3) + + ((torch.sin(norm_omega) / (norm_omega + eps)) * omegahat) + + ( + ((1.0 - torch.cos(norm_omega)) / (torch_square(norm_omega + eps))) + * (omegahat @ omegahat) + ) + ) + return exp_omegahat + + +def rot_mat_to_euler(R): + sy = torch.sqrt(R[0, 0] * R[0, 0] + R[1, 0] * R[1, 0]) + singular = sy < 1e-10 + if not singular: + x = torch.atan2(R[2, 1] , R[2, 2]) + y = torch.atan2(-R[2, 0], sy) + z = torch.atan2(R[1, 0], R[0, 0]) + else: + x = torch.atan2(-R[1, 2], R[1, 1]) + y = torch.atan2(-R[2, 0], sy) + z = 0 + return torch.tensor([x, y, z]) + + +def link_pos_from_link_tensor(link_tensor): + """ + Extracts the position of the link from the link tensor. + """ + if link_tensor.shape[-1] == 3: + link_pos = link_tensor[..., :2, 2] + elif link_tensor.shape[-1] == 4: + link_pos = link_tensor[..., :3, 3] + else: + raise ValueError + return link_pos + + +def link_rot_from_link_tensor(link_tensor): + if link_tensor.shape[-1] == 3: + link_rot = link_tensor[..., :2, :2] + elif link_tensor.shape[-1] == 4: + link_rot = link_tensor[..., :3, :3] + else: + raise ValueError + return link_rot + + +def link_quat_from_link_tensor(link_tensor): + rot = link_rot_from_link_tensor(link_tensor) + quat = rotation_matrix_to_q(rot) + return quat + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..34c82471022f4739f91761406b70d9edb13beb86 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE @@ -0,0 +1,52 @@ +MIT License + +Copyright (c) 2022 An Thai Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +********************************************************************** +The first version of some files were licensed as +"Original Source License" (see below). Several enhancements and bug fixes +were done by An Thai Le since obtaining the first version. + + + +Original Source License: + +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py new file mode 100644 index 0000000000000000000000000000000000000000..30cb9af896e970bd658a6bef7ff7c98f95a6986d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py @@ -0,0 +1,270 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) Facebook, Inc. and its affiliates. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List, Dict, Optional + +import torch +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import ( + MotionVec, + z_rot, + y_rot, + x_rot, +) +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame + + +class DifferentiableRigidBody(torch.nn.Module): + """ + Differentiable Representation of a link + """ + + _parents: Optional["DifferentiableRigidBody"] + _children: List["DifferentiableRigidBody"] + + def __init__(self, rigid_body_params, device='cpu'): + + super().__init__() + + self.is_root = False + self._parents = None + self._children = [] + + self._device = device + self.joint_id = rigid_body_params["joint_id"] + self.name = rigid_body_params["link_name"] + + self.joint_damping = rigid_body_params["joint_damping"] + self.trans = rigid_body_params["trans"].reshape(1, 3) + self.rot_angles = rigid_body_params["rot_angles"].reshape(1, 3) + rot_angles_vals = self.rot_angles + roll = rot_angles_vals[0, 0] + pitch = rot_angles_vals[0, 1] + yaw = rot_angles_vals[0, 2] + self.fixed_rotation = (z_rot(yaw) @ y_rot(pitch)) @ x_rot(roll) + + # local joint axis (w.r.t. joint coordinate frame): + self.joint_axis = rigid_body_params["joint_axis"] + self.axis_idx = torch.nonzero(self.joint_axis.squeeze(0)) + if self.axis_idx.nelement() > 0: + self.axis_idx = self.axis_idx[0] + + if self.joint_axis[0, 0] == 1: + self.axis_rot_fn = x_rot + elif self.joint_axis[0, 1] == 1: + self.axis_rot_fn = y_rot + else: + self.axis_rot_fn = z_rot + self.joint_type = rigid_body_params["joint_type"] + self.joint_limits = rigid_body_params["joint_limits"] + + self.joint_pose = Frame(device=self._device) + self.joint_pose.set_translation(torch.reshape(self.trans, (1, 3))) + + # local velocities and accelerations (w.r.t. joint coordinate frame): + self.joint_vel = MotionVec(device=self._device) + self.joint_acc = MotionVec(device=self._device) + + self.update_joint_state( + torch.zeros([1, 1], device=self._device), + torch.zeros([1, 1], device=self._device), + ) + self.update_joint_acc(torch.zeros([1, 1], device=self._device)) + + self.reset() + + def reset(self): + ''' + Reset pose, velocity and acceleration to init state. + ''' + self.pose = Frame(device=self._device) + self.vel = MotionVec(device=self._device) + self.acc = MotionVec(device=self._device) + + def update_pose(self, pose_vec): + pose = Frame(device=self._device) + pose.set_pose(pose_vec) + self.pose = pose + + # Kinematic tree construction + def set_parent(self, link: "DifferentiableRigidBody"): + self._parent = link + + def add_child(self, link: "DifferentiableRigidBody"): + self._children.append(link) + + # Recursive algorithms + def forward_kinematics(self, q_dict, batch_size): + """Recursive forward kinematics + Computes transformations from self to all descendants. + + Returns: Dict[link_name, transform_from_self_to_link] + """ + # Compute joint pose + if self.name in q_dict: + q = q_dict[self.name] + + # bound q with limits + if self.joint_limits is not None: + q_clamped = torch.clamp(q, min=self.joint_limits['lower'], max=self.joint_limits['upper']) + else: + q_clamped = q + + if self.joint_type in ['revolute', 'continuous']: + if torch.abs(self.joint_axis[0, 0]) == 1: + rot = x_rot(torch.sign(self.joint_axis[0, 0]) * q_clamped) + elif torch.abs(self.joint_axis[0, 1]) == 1: + rot = y_rot(torch.sign(self.joint_axis[0, 1]) * q_clamped) + else: + rot = z_rot(torch.sign(self.joint_axis[0, 2]) * q_clamped) + + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1) @ rot, + trans=self.trans.repeat(batch_size, 1), + device=self._device, + ) + elif self.joint_type == 'prismatic': + trans = self.joint_axis * q_clamped + + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1), + trans=self.trans + trans, + device=self._device, + ) + else: + raise NotImplementedError() + else: + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1), + trans=self.trans.repeat(batch_size, 1), + device=self._device, + ) + if self.is_root: + joint_pose = self.pose.multiply_transform(joint_pose) + + # Compute forward kinematics of children + pose_dict = {self.name: self.pose} + for child in self._children: + pose_dict.update(child.forward_kinematics(q_dict, batch_size)) + + # Apply joint pose + result = {} + for body_name in pose_dict: + if self.is_root and body_name == self.name: + base_pose = Frame( + rot=self.pose.rotation.repeat(batch_size, 1, 1), + trans=self.pose.translation.repeat(batch_size, 1), + device=self._device, + ) + result[body_name] = base_pose + else: + result[body_name] = joint_pose.multiply_transform(pose_dict[body_name]) + return result + + # Get/set + def update_joint_state(self, q, qd): + batch_size = q.shape[0] + + # bound q with limits + if self.joint_limits is not None: + q_clamped = torch.clamp(q, min=self.joint_limits['lower'], max=self.joint_limits['upper']) + qd_clamped = qd # not clamping velocity for now + # qd_clamped = torch.clamp(qd, min=-self.joint_limits['velocity'], max=self.joint_limits['velocity']) + else: + q_clamped = q + qd_clamped = qd + + if self.joint_type in ['revolute', 'continuous']: + rot = self.axis_rot_fn(q_clamped) + # if torch.abs(self.joint_axis[0, 0]) == 1: + # rot = x_rot(torch.sign(self.joint_axis[0, 0]) * q_clamped) + # elif torch.abs(self.joint_axis[0, 1]) == 1: + # rot = y_rot(torch.sign(self.joint_axis[0, 1]) * q_clamped) + # else: + # rot = z_rot(torch.sign(self.joint_axis[0, 2]) * q_clamped) + + self.joint_pose.set_translation( + self.trans.repeat(batch_size, 1) + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1) @ rot) + elif self.joint_type == 'prismatic': + trans = self.joint_axis * q_clamped + self.joint_pose.set_translation( + self.trans + trans + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1)) + elif self.joint_type == 'fixed': + self.joint_pose.set_translation( + self.trans.repeat(batch_size, 1) + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1)) + else: + raise NotImplementedError(f'Joint Type: {self.joint_type}') + + joint_ang_vel = qd_clamped @ self.joint_axis + self.joint_vel = MotionVec( + torch.zeros_like(joint_ang_vel), joint_ang_vel + ) + + def update_joint_acc(self, qdd): + # local z axis (w.r.t. joint coordinate frame): + joint_ang_acc = qdd @ self.joint_axis + self.joint_acc = MotionVec( + torch.zeros_like(joint_ang_acc), joint_ang_acc + ) + return + + def get_joint_limits(self): + return self.joint_limits + + def get_joint_damping_const(self): + return self.joint_damping diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py new file mode 100644 index 0000000000000000000000000000000000000000..31ed40ab082407e22488eae52c2c248ea0694204 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py @@ -0,0 +1,492 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) Facebook, Inc. and its affiliates. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List, Tuple, Dict, Optional +from dataclasses import dataclass + +import torch +import numpy as np +from torch.autograd.functional import jacobian + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_axis_angles, q_div, q_convert_wxyz +from torch_robotics.torch_kinematics_tree.geometrics.skeleton import get_skeleton_from_model +from torch_robotics.torch_kinematics_tree.models.rigid_body import DifferentiableRigidBody +from torch_robotics.torch_kinematics_tree.models.utils import URDFRobotModel, MJCFRobotModel +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import MotionVec +from torch_robotics.torch_kinematics_tree.geometrics.utils import SE3_distance, link_pos_from_link_tensor, \ + link_quat_from_link_tensor, link_rot_from_link_tensor +from torch_robotics.torch_utils.torch_utils import to_torch, torch_intersect_1d + + +def convert_link_dict_to_tensor(link_dict, link_list): + return torch.stack([link_dict[name].get_transform_matrix() for name in link_list], dim=1) + + +class DifferentiableTree(torch.nn.Module): + + def __init__(self, model_path: str, name="", link_list=None, device='cpu'): + + super().__init__() + + self.name = name + self.link_list = link_list + + self._device = device + self.model_type = model_path.split('.')[-1] + if self.model_type == 'urdf': + self._model = URDFRobotModel(model_path=model_path, device=self._device) + elif self.model_type == 'xml': + self._model = MJCFRobotModel(model_path=model_path, device=self._device) + else: + raise NotImplementedError(f'{self.model_type} is not supported!') + self._bodies = torch.nn.ModuleList() + self._n_dofs = 0 + self._controlled_joints = [] + + # NOTE: making the joint a part of the rigid body + # while urdfs model joints and rigid bodies separately + # NOTE: joint is at the beginning of a link + self._name_to_idx_map = dict() + + for (i, link) in enumerate(self._model.links): + # Initialize body object + rigid_body_params = self._model.get_body_parameters(i, link) + body = DifferentiableRigidBody( + rigid_body_params=rigid_body_params, device=self._device + ) + if i == 0: + body.is_root = True # set root + + # Joint properties + body.joint_idx = None + if rigid_body_params["joint_type"] != "fixed": + body.joint_idx = self._n_dofs + self._n_dofs += 1 + self._controlled_joints.append(i) + + # Add to data structures + self._bodies.append(body) + self._name_to_idx_map[body.name] = i + + # Once all bodies are loaded, connect each body to its parent + for body in self._bodies[1:]: + parent_body_name = self._model.get_name_of_parent_body(body.name) + parent_body_idx = self._name_to_idx_map[parent_body_name] + body.set_parent(self._bodies[parent_body_idx]) + self._bodies[parent_body_idx].add_child(body) + + def reset(self): + '''Reset the robots FK after stateful update''' + for body in self._bodies: + body.reset() + + def update_base_pose(self, pose_vec): + self._bodies[0].update_pose(pose_vec) + + def update_kinematic_state(self, q: torch.Tensor, qd: torch.Tensor) -> None: + """ + Updates the kinematic state of the robots in a stateful way + Args: + q: joint angles [batch_size x n_dofs] + qd: joint velocities [batch_size x n_dofs] + + Returns: + + """ + assert q.ndim == 2 + assert qd.ndim == 2 + assert q.shape[1] == self._n_dofs + assert qd.shape[1] == self._n_dofs + + batch_size = q.shape[0] + + # update the state of the joints + for i in range(q.shape[1]): + idx = self._controlled_joints[i] + self._bodies[idx].update_joint_state( + q[:, i].unsqueeze(1), qd[:, i].unsqueeze(1) + ) + + # we assume a non-moving base + parent_body = self._bodies[0] + parent_body.vel = MotionVec( + torch.zeros((batch_size, 3), device=self._device), + torch.zeros((batch_size, 3), device=self._device), + ) + + # propagate the new joint state through the kinematic chain to update bodies position/velocities + for i in range(1, len(self._bodies)): + + body = self._bodies[i] + parent_name = self._model.get_name_of_parent_body(body.name) + # find the joint that has this link as child + parent_body = self._bodies[self._name_to_idx_map[parent_name]] + + # transformation operator from child link to parent link + childToParentT = body.joint_pose + # transformation operator from parent link to child link + parentToChildT = childToParentT.inverse() + + # the position and orientation of the body in world coordinates, with origin at the joint + body.pose = parent_body.pose.multiply_transform(childToParentT) + + # we rotate the velocity of the parent's body into the child frame + new_vel = parent_body.vel.transform(parentToChildT) + + # this body's angular velocity is combination of the velocity experienced at it's parent's link + # + the velocity created by this body's joint + body.vel = body.joint_vel.add_motion_vec(new_vel) + + return + + def compute_forward_kinematics( + self, q: torch.Tensor, qd: torch.Tensor, link_name: str, state_less: bool = False + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + + Args: + q: joint angles [batch_size x n_dofs] + link_name: name of link + + Returns: translation and rotation of the link frame + + """ + + assert q.ndim == 2 + + if state_less: + return self.compute_forward_kinematics_all_links(q, link_list=[link_name]) # return SE3 matrix + else: + self.update_kinematic_state(q, qd) + + pose = self._bodies[self._name_to_idx_map[link_name]].pose + pos = pose.translation + rot = pose.get_quaternion() + rot = q_convert_wxyz(rot) # pose.get_quaternion() returns a quaternion xyzw + return pos, rot # return tuple of translation and rotation + + def compute_forward_kinematics_and_geometric_jacobian( + self, q: torch.Tensor, qd: torch.Tensor, link_name: str + ) -> Tuple[torch.Tensor, torch.Tensor]: + r""" + Args: + link_name: name of link name for the jacobian + q: joint angles [batch_size x n_dofs] + qd: joint velocities [batch_size x n_dofs] + Returns: ee_pos, ee_rot and linear and angular jacobian + """ + batch_size = q.shape[0] + ee_pos, ee_rot = self.compute_forward_kinematics(q, qd, link_name) + + lin_jac = torch.zeros([batch_size, 3, self._n_dofs], device=self._device) + ang_jac = torch.zeros([batch_size, 3, self._n_dofs], device=self._device) + # any joints larger than this joint, will have 0 in the jacobian + # parent_name = self._urdf_model.get_name_of_parent_body(link_name) + parent_joint_id = self._model.find_joint_of_body(link_name) + + # TODO - implement as a tensor cross product + for i, idx in enumerate(self._controlled_joints): + if (idx - 1) > parent_joint_id: + continue + pose = self._bodies[idx].pose + axis_idx = self._bodies[idx].axis_idx + p_i = pose.translation + z_i = torch.index_select(pose.rotation, -1, axis_idx).squeeze(-1) + lin_jac[:, :, i] = torch.cross(z_i, ee_pos - p_i) + ang_jac[:, :, i] = z_i + + return ee_pos, ee_rot, lin_jac, ang_jac + + def compute_analytical_jacobian_all_links( + self, q: torch.Tensor + ): + # Batch computation of analytical jacobian for all links + if q.ndim == 1: + q = q.unsqueeze(0) + + def surrogate_fn(x): + # Surrogate function that sums over the batch size + links_tensor = self.compute_forward_kinematics_all_links(x) + pos = link_pos_from_link_tensor(links_tensor) + quat = link_quat_from_link_tensor(links_tensor) + return torch.cat((pos, quat), dim=-1).sum(0) + + links_jac = jacobian(surrogate_fn, q).movedim(-2, 0) + return links_jac + + def compute_forward_kinematics_all_links( + self, q: torch.Tensor, return_dict=False, link_list=None + ) -> [torch.Tensor, dict]: + """ + Stateless forward kinematics + Args: + q: joint angles [batch_size x n_dofs] + link_name: name of link + + Returns: dict link frame + + """ + if q.ndim == 1: + q = q.unsqueeze(0) + + batch_size = q.shape[0] + + if link_list is None: + link_list = self.link_list + + # Create joint state dictionary + q_dict = {} + for i, body_idx in enumerate(self._controlled_joints): + q_dict[self._bodies[body_idx].name] = q[:, i].unsqueeze(1) + + # Call forward kinematics on root node + pose_dict = self._bodies[0].forward_kinematics(q_dict, batch_size) + + if link_list is None: # link list is None again, output whole body + link_list = self.get_link_names() + if not return_dict: + return convert_link_dict_to_tensor(pose_dict, link_list) + else: + link_dict = {k: v for k, v in pose_dict.items() if k in link_list} + return link_dict + + def inverse_kinematics( + self, H_target, link_name='ee_link', + batch_size=1, + max_iters=1000, lr=1e-2, + se3_eps=1e-1, + q0=None, + q0_noise=torch.pi/8, + eps_joint_lim=torch.pi/100, + print_freq=50, + debug=False + ): + """ + Solve IK using Adam optimizer + Args: + H_target: target SE3 matrix [batch_size x 4 x 4] + num_iters: number of iterations + se3_eps: threshold for stopping the iteration + q0: initial guess for the joint angles + + Returns: joint angles + + """ + if H_target.ndim == 2: + H_target = H_target.unsqueeze(0) + + # Sample configurations uniformly + lower, upper, _, _ = self.get_joint_limit_array() + lower += eps_joint_lim + upper -= eps_joint_lim + lower = to_torch(lower, device=self._device) + upper = to_torch(upper, device=self._device) + if q0 is None: + q0 = torch.rand(batch_size, self._n_dofs, device=self._device) + q0 = lower + q0 * (upper - lower) + else: + # add some noise to get diverse solutions + q0 += torch.randn(batch_size, self._n_dofs, device=self._device) * q0_noise + q0 = torch.clamp(q0, lower, upper) + assert q0.shape[0] == batch_size + assert q0.shape[1] == self._n_dofs + + # Optimize + q = q0.clone().requires_grad_(True) + optimizer = torch.optim.Adam([q], lr=lr) + for i in range(max_iters): + optimizer.zero_grad() + # Check if all configurations respect the termination criteria + idx_valid = self.ik_termination( + q, H_target, link_name, + lower, upper, + se3_eps=se3_eps, + debug=debug + ) + if idx_valid.nelement() == batch_size: + print(f'\nIK converged for all joint configurations in {i} iterations') + break + + # TODO - optimize only the joint configurations that are not valid + + # loss and gradient step + err_per_q = self.loss_fn_ik_per_q( + q, H_target, link_name, + w_se3=1.0, + w_joint_limits=300.0, lower=lower, upper=upper, + w_q_rest=1., q_rest=None, + debug=debug + ) + if (i == 0 or (i % print_freq) == 0) and print_freq != -1: + print(f'\n---> Iter {i}/{max_iters}') + print(f'Error mean, std: {err_per_q.mean():.3f}, {err_per_q.std():.3f}') + print(f'idx_valid: {len(idx_valid)}/{batch_size}') + + # do not optimize configurations that are already valid + err_per_q.sum().backward() + optimizer.step() + + if i == max_iters - 1: + print(f'\nIK did not converge for all joint configurations!') + print(f'Error mean, std: {err_per_q.mean():.3f}, {err_per_q.std():.3f}') + print(f'idx_valid: {len(idx_valid)}/{batch_size}') + + return q, idx_valid + + def loss_fn_ik_per_q( + self, + q, H_target, link_name, + w_se3=1.0, + w_joint_limits=1.0, lower=None, upper=None, + w_q_rest=1., q_rest=None, + debug=False + ): + # EE cost + H = self.compute_forward_kinematics_all_links(q, link_list=[link_name]).squeeze(1) + err_se3 = SE3_distance(H, H_target, w_pos=1., w_rot=1.) + + # Joint limit cost + lower_mask = torch.where(q < lower, 1, 0) + err_joint_limit_lower = ((lower - q).pow(2) * lower_mask).sum(-1) + upper_mask = torch.where(q > upper, 1, 0) + err_joint_limit_upper = ((upper - q).pow(2) * upper_mask).sum(-1) + err_joint_limits = err_joint_limit_lower + err_joint_limit_upper + + # Rest configuration cost + err_q_rest = 0. + if q_rest is not None: + err_q_rest = torch.linalg.norm(q - q_rest, dim=-1) + + # Total error + if debug: + print(f'w_se3 * err_se3 : {w_se3 * err_se3}') + print(f'w_joint_limits * err_joint_limits: {w_joint_limits * err_joint_limits}') + print(f'w_q_rest * err_q_rest : {w_q_rest * err_q_rest}') + + err = w_se3 * err_se3 + w_joint_limits * err_joint_limits + w_q_rest * err_q_rest + return err + + def ik_termination( + self, + q, H_target, link_name, + lower, upper, + se3_eps=1e-1, + debug=False + ): + # Check joint limits + idx_valid_joints = torch.argwhere(torch.all(torch.logical_and(q >= lower, q <= upper), dim=-1)).squeeze() + idx_valid_joints = torch.atleast_1d(idx_valid_joints) + + # Check SE3 distance + H = self.compute_forward_kinematics_all_links(q, link_list=[link_name]).squeeze(1) + err_se3 = SE3_distance(H, H_target, w_pos=1., w_rot=1.) + idx_valid_se3 = torch.argwhere(err_se3 < se3_eps).squeeze() + idx_valid_se3 = torch.atleast_1d(idx_valid_se3) + + if debug: + print(f'#idx_valid_joints : {len(idx_valid_joints) if idx_valid_joints.nelement()>0 else 0}') + print(f'#idx_valid_se3 : {len(idx_valid_se3) if idx_valid_se3.nelement()>0 else 0}') + + idx_valid = torch_intersect_1d(idx_valid_joints, idx_valid_se3) + + return idx_valid + + def get_joint_limits(self) -> List[Dict[str, torch.Tensor]]: + """ + + Returns: list of joint limit dict, containing joint position, velocity and effort limits + + """ + limits = [] + for idx in self._controlled_joints: + limits.append(self._bodies[idx].get_joint_limits()) + return limits + + def get_joint_limit_array(self) -> Tuple[np.ndarray]: + """ + + Returns: list of joint limit dict, containing joint position, velocity and effort limits + + """ + lowers = [] + uppers = [] + vel_lowers = [] + vel_uppers = [] + for idx in self._controlled_joints: + data = self._bodies[idx].get_joint_limits() + lowers.append(data['lower']) + uppers.append(data['upper']) + vel_lowers.append(-data['velocity']) + vel_uppers.append(data['velocity']) + return np.array(lowers), np.array(uppers), np.array(vel_lowers), np.array(vel_uppers) + + def get_link_names(self) -> List[str]: + """ + + Returns: a list containing names for all links + + """ + link_names = [] + for i in range(len(self._bodies)): + link_names.append(self._bodies[i].name) + return link_names + + def print_link_names(self) -> None: + """ + + print the names of all links + + """ + names = self.get_link_names() + for i in range(len(names)): + print(names[i]) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py new file mode 100644 index 0000000000000000000000000000000000000000..34a6a281444a3af6e5f93768e16089eff5946a75 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py @@ -0,0 +1,133 @@ +from pathlib import Path +from typing import Optional, List +from xml.dom import minidom + +import numpy as np +from urdf_parser_py.urdf import URDF, Joint, Link, Visual, Collision, Box, Pose + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_euler +from torch_robotics.torch_kinematics_tree.models.robot_tree import DifferentiableTree +from torch_robotics.torch_kinematics_tree.utils.files import get_robot_path +from xml.etree import ElementTree as ET + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +class DifferentiableKUKAiiwa(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'kuka_iiwa' / 'urdf' / 'iiwa7.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_kuka_iiwa" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +def modidy_franka_panda_urdf_grasped_object(robot_file, grasped_object): + robot_urdf = URDF.from_xml_file(robot_file) + joint = Joint( + name='grasped_object_fixed_joint', + parent='panda_hand', + child='grasped_object', + joint_type='fixed', + origin=Pose(xyz=to_numpy(grasped_object.pos.squeeze()), + rpy=to_numpy(q_to_euler(grasped_object.ori).squeeze()) + ) + ) + robot_urdf.add_joint(joint) + + geometry_grasped_object = grasped_object.geometry_urdf + link = Link( + name='grasped_object', + visual=Visual(geometry_grasped_object), + # inertial=None, + collision=Collision(geometry_grasped_object), + origin=Pose(xyz=[0., 0., 0.], rpy=[0., 0., 0.]) + ) + robot_urdf.add_link(link) + + # replace the robots file + robot_file = Path(str(robot_file).replace('.urdf', '_grasped_object.urdf')) + xmlstr = minidom.parseString(ET.tostring(robot_urdf.to_xml())).toprettyxml(indent=" ") + with open(str(robot_file), "w") as f: + f.write(xmlstr) + + return robot_file + + +class DifferentiableFrankaPanda(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, gripper=False, device='cpu', grasped_object=None): + if gripper: + robot_file = get_robot_path() / 'franka_description' / 'robots' / 'panda_arm_hand.urdf' + else: + robot_file = get_robot_path() / 'franka_description' / 'robots' / 'panda_arm_no_gripper.urdf' + + # Modify the urdf to append the link of the grasped object + if grasped_object is not None: + robot_file = modidy_franka_panda_urdf_grasped_object(robot_file, grasped_object) + + self.model_path = robot_file.as_posix() + self.name = "differentiable_franka_panda" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableUR10(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, attach_gripper=False, device='cpu'): + robot_path = get_robot_path() + if attach_gripper: + robot_file = robot_path / 'ur10' / 'urdf' / 'ur10_suction.urdf' + else: + robot_file = robot_path / 'ur10' / 'urdf' / 'ur10.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_ur10" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableHabitatStretch(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_path = get_robot_path() + robot_file = robot_path / 'habitat_stretch' / 'urdf' / 'hab_stretch.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_stretch" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableTiagoDualHolo(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'tiago_dual_description' / 'tiago_dual_holobase_minimal.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_tiago_dual_holo" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableTiagoDualHoloMove(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'tiago_dual_description' / 'tiago_dual_holobase_minimal_holonomic.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_tiago_dual_holo_move" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + def get_link_names(self): # pop those hacky frames for moving base + return super().get_link_names()[3:] + + +class DifferentiableShadowHand(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'shadow_hand' / 'shadow_hand.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_shadow_hand" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableAllegroHand(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'allegro_hand' / 'allegro_hand.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_allegro_hand" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class Differentiable2LinkPlanar(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'planar_manipulators' / 'urdf' / '2_link_planar.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_2_link_planar" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..520cd34a81bf6e84e79e8f990a192d7ee4e7972b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py @@ -0,0 +1,315 @@ +import torch +from networkx.readwrite import json_graph +from urdf_parser_py.urdf import URDF + +from torch_robotics.torch_kinematics_tree.utils.files import get_mjcf_path + +torch.set_default_dtype(torch.float32) + +JOINT_NAME_MAP = { + 'hinge': 'revolute', + 'slide': 'prismatic' +} + + +class RobotModel(object): + + def __init__(self, model_path, device="cpu") -> None: + self.model_path = model_path + self._device = torch.device(device) + + def find_joint_of_body(self, body_name): + pass + + def get_name_of_parent_body(self, body_name): + pass + + def get_body_parameters(self, i, link): + pass + + +class MJCFRobotModel(RobotModel): + def __init__(self, model_path, device="cpu"): + super().__init__(model_path, device=device) + from dm_control import mjcf + self.robot = mjcf.from_file(self.model_path) + self._device = torch.device(device) + bodies = self.robot.find_all("body") + self.links = [e for e in bodies] + self._bodies = {e.name: e for e in bodies} + self._joints = {e.name: e for e in self.robot.find_all("joint")} + # TODO: implement default logics of mujoco + # TODO: tendon forward kinematic? + # NOTE: this class does not work correctly now + # self._actuators = {e.name: e for e in self.robot.find_all("actuator")} + node_list, edge_list = [], [] + for b in self.robot.worldbody.body: + self.build_topology(b, node_list, edge_list) + self.topology = json_graph.node_link_graph({'nodes': node_list, 'links': edge_list}, directed=True) + + def build_topology(self, body, node_list, edge_list): # since there is no parent tag for joint + node_list.append({'id': body.name}) + if not body.body: + return + for b in body.body: + edge_list.append({ + 'source': body.name, + 'target': b.name + }) + self.build_topology(b, node_list, edge_list) + + def find_joint_of_body(self, body_name): + if body_name not in self._bodies: + return -1 + return self._bodies[body_name].joint + + def get_name_of_parent_body(self, body_name): + if body_name not in self._bodies: + return + return [b for b in self.topology.predecessors(body_name)] + + def get_body_parameters(self, i, link): + body_params = {} + body_params["joint_id"] = i + body_params["link_name"] = link.name + + if i == 0: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = "base_joint" + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + link_name = link.name + joint = self.find_joint_of_body(link_name) + if not joint: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = link_name + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + joint = joint[0] + joint_name = joint.name + + rot_angles = torch.zeros(3, device=self._device) # joint angle is not specified in mujoco + if joint.pos is not None: + trans = torch.tensor( + joint.pos, dtype=torch.float32, device=self._device + ) + else: + trans = torch.zeros(3, device=self._device) + if joint.type is None: + joint_type = 'revolute' + else: + joint_type = JOINT_NAME_MAP[joint.type] + if joint.range is None: + joint_limits = None + else: + joint_limits = { + "lower": joint.range[0], + "upper": joint.range[1], + } + if joint.damping is None: + joint_damping = None + else: + joint_damping = torch.tensor([joint.damping], device=self._device) + joint_axis = torch.tensor( + joint.axis, dtype=torch.float32, device=self._device + ).reshape(1, 3) + + body_params["rot_angles"] = rot_angles + body_params["trans"] = trans + body_params["joint_name"] = joint_name + body_params["joint_type"] = joint_type + body_params["joint_limits"] = joint_limits + body_params["joint_damping"] = joint_damping + body_params["joint_axis"] = joint_axis + + if link.inertial is not None: + mass = torch.tensor( + [link.inertial.mass], dtype=torch.float32, device=self._device + ) + if link.pos is not None: + pos = link.pos + else: + pos = [0, 0, 0] + com = ( + torch.tensor( + pos, + dtype=torch.float32, + device=self._device, + ) + .reshape((1, 3)) + .to(self._device) + ) + if link.inertial.diaginertia is not None: + inert_mat = torch.zeros((3, 3), device=self._device) + inert_mat[0, 0] = link.inertial.diaginertia[0] + inert_mat[1, 1] = link.inertial.diaginertia[1] + inert_mat[2, 2] = link.inertial.diaginertia[2] + else: + print( + "Warning: No inertia information for link: {}, setting inertia matrix to identity.".format( + link.name + )) + inert_mat = torch.eye(3, device=self._device) + + inert_mat = inert_mat.unsqueeze(0) + body_params["mass"] = mass + body_params["com"] = com + body_params["inertia_mat"] = inert_mat + else: + body_params["mass"] = torch.ones((1,), device=self._device) + body_params["com"] = torch.zeros((1, 3), device=self._device) + body_params["inertia_mat"] = torch.eye(3, 3, device=self._device).unsqueeze( + 0 + ) + print( + "Warning: No dynamics information for link: {}, setting all inertial properties to 1.".format( + link.name + ) + ) + + return body_params + + +class URDFRobotModel(RobotModel): + def __init__(self, model_path, device="cpu"): + super().__init__(model_path, device=device) + self.robot = URDF.from_xml_file(self.model_path) + self.links = self.robot.links + self._device = torch.device(device) + + def find_joint_of_body(self, body_name): + for (i, joint) in enumerate(self.robot.joints): + if joint.child == body_name: + return i + return -1 + + def get_name_of_parent_body(self, link_name): + jid = self.find_joint_of_body(link_name) + joint = self.robot.joints[jid] + return joint.parent + + def get_body_parameters(self, i, link): + body_params = {} + body_params["joint_id"] = i + body_params["link_name"] = link.name + + if i == 0: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = "base_joint" + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + link_name = link.name + jid = self.find_joint_of_body(link_name) + joint = self.robot.joints[jid] + joint_name = joint.name + # find joint that is the "child" of this body according to urdf + + rot_angles = torch.tensor( + joint.origin.rotation, dtype=torch.float32, device=self._device + ) + trans = torch.tensor( + joint.origin.position, dtype=torch.float32, device=self._device + ) + joint_type = joint.type + joint_limits = None + joint_damping = torch.zeros(1, device=self._device) + if joint.axis is not None: + joint_axis = torch.tensor( + joint.axis, dtype=torch.float32, device=self._device + ).reshape(1, 3) + else: + joint_axis = torch.zeros((1, 3), device=self._device) + if joint_type != "fixed" and joint.limit is not None: + joint_limits = { + "effort": joint.limit.effort, + "lower": joint.limit.lower, + "upper": joint.limit.upper, + "velocity": joint.limit.velocity, + } + try: + joint_damping = torch.tensor( + [joint.dynamics.damping], + dtype=torch.float32, + device=self._device, + ) + except: + joint_damping = torch.zeros(1, device=self._device) + + body_params["rot_angles"] = rot_angles + body_params["trans"] = trans + body_params["joint_name"] = joint_name + body_params["joint_type"] = joint_type + body_params["joint_limits"] = joint_limits + body_params["joint_damping"] = joint_damping + body_params["joint_axis"] = joint_axis + + if link.inertial is not None: + mass = torch.tensor( + [link.inertial.mass], dtype=torch.float32, device=self._device + ) + if link.inertial.origin is not None: + pos = link.inertial.origin.position + else: + pos = [0, 0, 0] + com = ( + torch.tensor( + pos, + dtype=torch.float32, + device=self._device, + ) + .reshape((1, 3)) + .to(self._device) + ) + if link.inertial.inertia is not None: + inert_mat = torch.zeros((3, 3), device=self._device) + inert_mat[0, 0] = link.inertial.inertia.ixx + inert_mat[0, 1] = link.inertial.inertia.ixy + inert_mat[0, 2] = link.inertial.inertia.ixz + inert_mat[1, 0] = link.inertial.inertia.ixy + inert_mat[1, 1] = link.inertial.inertia.iyy + inert_mat[1, 2] = link.inertial.inertia.iyz + inert_mat[2, 0] = link.inertial.inertia.ixz + inert_mat[2, 1] = link.inertial.inertia.iyz + inert_mat[2, 2] = link.inertial.inertia.izz + else: + print( + "Warning: No inertia information for link: {}, setting inertia matrix to identity.".format( + link.name + )) + inert_mat = torch.eye(3, device=self._device) + + inert_mat = inert_mat.unsqueeze(0) + body_params["mass"] = mass + body_params["com"] = com + body_params["inertia_mat"] = inert_mat + else: + body_params["mass"] = torch.ones((1,), device=self._device) + body_params["com"] = torch.zeros((1, 3), device=self._device) + body_params["inertia_mat"] = torch.eye(3, 3, device=self._device).unsqueeze( + 0 + ) + print( + "Warning: No dynamics information for link: {}, setting all inertial properties to 1.".format( + link.name + ) + ) + + return body_params + + +if __name__ == '__main__': + hand = MJCFRobotModel(get_mjcf_path() / 'shadow_hand_series_e.xml') + print(hand.find_joint_of_body('forearm')) + print(hand.get_body_parameters(0, hand._bodies['thmiddle'])) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py new file mode 100644 index 0000000000000000000000000000000000000000..10cf9f2eaabaf37cf3aca93feee23a0327664f71 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py @@ -0,0 +1,64 @@ +import torch_robotics.torch_kinematics_tree +from pathlib import Path +import yaml + + +# get paths +def get_root_path(): + path = Path(torch_robotics.torch_kinematics_tree.__path__[0]).resolve() / '..' + return path + + +def get_data_path(): + path = get_root_path() / 'data' + return path + + +def get_json_path(): + path = get_data_path() / 'jsons' + return path + + +def get_imgs_path(): + path = get_data_path() / 'imgs' + return path + + +def get_urdf_path(): + path = get_data_path() / 'urdf' + return path + + +def get_mjcf_path(): + path = get_data_path() / 'mjcf' + return path + + +def get_usd_path(): + path = get_data_path() / 'usd' + return path + + +def get_robot_path(): + path = get_urdf_path() / 'robots' + return path + + +def get_objects_path(): + path = get_urdf_path() / 'objects' + return path + + +def get_configs_path(): + path = get_data_path() / 'configs' + return path + + +def load_yaml(filename): + with open(filename, 'r') as stream: + try: + config = yaml.safe_load(stream) + print(config) + return config + except yaml.YAMLError as exc: + print(exc) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5a190bf398fec5aa2021f1959380ec54ea453f1c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py @@ -0,0 +1,409 @@ +from abc import ABC, abstractmethod + +import einops +import torch +from matplotlib import pyplot as plt + +from torch_robotics.torch_kinematics_tree.geometrics.utils import SE3_distance +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +import torch.nn.functional as Functional + + +class DistanceField(ABC): + def __init__(self, tensor_args=None): + self.tensor_args = tensor_args + + def distances(self): + pass + + def compute_collision(self): + pass + + @abstractmethod + def compute_distance(self, *args, **kwargs): + pass + + def compute_cost(self, q, link_pos, *args, **kwargs): + q_orig_shape = q.shape + link_orig_shape = link_pos.shape + if len(link_orig_shape) == 2: + h = 1 + b, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b d -> b 1 d") # add dimension of task space link + elif len(link_orig_shape) == 3: + h = 1 + b, t, d = link_orig_shape + elif len(link_orig_shape) == 4: # batch, horizon, num_links, 3 # position tensor + b, h, t, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d -> (b h) t d") + elif len(link_orig_shape) == 5: # batch, horizon, num_links, 4, 4 # homogeneous transform tensor + b, h, t, d, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d d -> (b h) t d d") + else: + raise NotImplementedError + + # link_tensor_pos + # position: (batch horizon) x num_links x 3 + cost = self.compute_costs_impl(q, link_pos, *args, **kwargs) + + if cost.ndim == 1: + cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + # if len(link_orig_shape) == 4 or len(link_orig_shape) == 5: + # cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + return cost + + @abstractmethod + def compute_costs_impl(self, *args, **kwargs): + pass + + @abstractmethod + def zero_grad(self): + pass + + +def interpolate_points_v1(points, num_interpolated_points): + # https://github.com/SamsungLabs/RAMP/blob/c3bd23b2c296c94cdd80d6575390fd96c4f83d83/mppi_planning/cost/collision_cost.py#L89 + """ + Interpolates points to have num_interpolated_points between each pair of points. + """ + points = Functional.interpolate(points.transpose(-2, -1), size=num_interpolated_points, mode='linear', align_corners=True).transpose(-2, -1) + return points + + +# Old implementation +def interpolate_points_v2(points, num_interpolate, link_interpolate_range): + if num_interpolate > 0: + link_dim = points.shape[:-1] + alpha = torch.linspace(0, 1, num_interpolate + 2).type_as(points)[1:num_interpolate + 1] + alpha = alpha.view(tuple([1] * len(link_dim) + [-1, 1])) # 1 x 1 x 1 x ... x num_interpolate x 1 + X = points[..., link_interpolate_range[0]:link_interpolate_range[1] + 1, :].unsqueeze(-2) # batch_dim x num_interp_link x 1 x 3 + X_diff = torch.diff(X, dim=-3) # batch_dim x (num_interp_link - 1) x 1 x 3 + X_interp = X[..., :-1, :, :] + X_diff * alpha # batch_dim x (num_interp_link - 1) x num_interpolate x 3 + points = torch.cat([points, X_interp.flatten(-3, -2)], dim=-2) # batch_dim x (num_link + (num_interp_link - 1) * num_interpolate) x 3 + return points + + +class EmbodimentDistanceFieldBase(DistanceField): + + def __init__(self, + robot, + link_idxs_for_collision_checking=None, + num_interpolated_points=30, + collision_margins=0., + cutoff_margin=0.001, + field_type='sdf', clamp_sdf=True, + interpolate_link_pos=False, + **kwargs): + super().__init__(**kwargs) + assert robot is not None, "You need to pass a robot instance to the embodiment distance fields" + self.robot = robot + self.link_idxs_for_collision_checking = link_idxs_for_collision_checking + self.num_interpolated_points = num_interpolated_points + self.collision_margins = collision_margins + self.cutoff_margin = cutoff_margin + self.field_type = field_type + self.clamp_sdf = clamp_sdf + self.interpolate_link_pos = interpolate_link_pos + + def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + if field_type is None: + field_type = self.field_type + if field_type == 'rbf': + return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + margin = self.collision_margins + self.cutoff_margin # The cutoff margin is the self_collision_margin_vector in the self collision distance field. + # returns all distances from each link to the environment + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + if self.clamp_sdf: + if self.clamp_sdf is False: + raise ValueError("Debug: clamp intended to be True.") + clamped_sdf = torch.relu(margin_minus_sdf) + else: + clamped_sdf = margin_minus_sdf + if len(clamped_sdf.shape) == 3: # cover the multiple objects case + clamped_sdf = clamped_sdf.max(-2)[0] + # sum over link points for gradient computation + return clamped_sdf.sum(-1) + elif field_type == 'occupancy': + return self.compute_embodiment_collision(q, link_pos, **kwargs) + # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # return (distances < margin).sum(-1) + else: + raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + # def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + # if field_type is None: + # field_type = self.field_type + # if field_type == 'rbf': + # return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + # elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + # margin = self.collision_margins + self.cutoff_margin + # # computes sdf of all links to all objects (or sdf map) + # margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + # if self.clamp_sdf: + # clamped_sdf = torch.relu(margin_minus_sdf) + # else: + # clamped_sdf = margin_minus_sdf + # + # if len(clamped_sdf.shape) == 3: # cover the multiple objects case + # clamped_sdf = clamped_sdf.max(-2)[0] + # + # # sum over link points for gradient computation + # return clamped_sdf.sum(-1) + # + # elif field_type == 'occupancy': + # return self.compute_embodiment_collision(q, link_pos, **kwargs) + # # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # # return (distances < margin).sum(-1) + # else: + # raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + def compute_costs_impl(self, q, link_pos, **kwargs): + # position link_pos tensor # batch x num_links x 3 + # interpolate to approximate link spheres + if self.robot.grasped_object is not None: + n_grasped_object_points = self.robot.grasped_object.n_base_points_for_collision + link_pos_robot = link_pos[..., :-n_grasped_object_points, :] + link_pos_grasped_object = link_pos[..., -n_grasped_object_points:, :] + else: + link_pos_robot = link_pos + + link_pos_robot = link_pos_robot[..., self.link_idxs_for_collision_checking, :] + if self.interpolate_link_pos: + # select the robot links used for collision checking + link_pos = interpolate_points_v1(link_pos_robot, self.num_interpolated_points) + + # stack collision points from grasped object + # these points do not need to be interpolated + if self.robot.grasped_object is not None: + link_pos = torch.cat((link_pos, link_pos_grasped_object), dim=-2) + + embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) + return embodiment_cost + + def compute_distance(self, q, link_pos, **kwargs): + raise NotImplementedError + link_pos = interpolate_points_v1(link_pos, self.num_interpolated_points) + self_distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs).min(-1)[0] # batch_dim + return self_distances + + def zero_grad(self): + pass + # raise NotImplementedError + + @abstractmethod + def compute_embodiment_rbf_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_collision(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionSelfField(EmbodimentDistanceFieldBase): + + def __init__(self, *args, idxs_links_distance_matrix=None, **kwargs): + super().__init__(*args, collision_margins=0., **kwargs) + self.idxs_links_distance_matrix = idxs_links_distance_matrix + self.idxs_links_distance_matrix_tuple = tuple(zip(*idxs_links_distance_matrix)) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.cutoff_margin) + rbf_distance = torch.exp(torch.square(link_pos.unsqueeze(-2) - + link_pos.unsqueeze(-3)).sum(-1) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor + """ + Computes the distances between all pairs of links. + The distances are computed as the L2 norm between the link positions, with the position of each link being + a point in 3D space. + """ + if link_pos.shape[-2] == 1: + # if there is only one link, the self distance is very large + # implementation guarantees gradient computation + return torch.abs(link_pos).sum(-1) * 1e9 + dist_mat = torch.linalg.norm(link_pos.unsqueeze(-2) - link_pos.unsqueeze(-3), dim=-1) # batch_dim x links x links + + # select lower triangular -- distance between link points + # lower_indices = torch.tril_indices(dist_mat.shape[-1], dist_mat.shape[-1], offset=-1).unbind() + # distances = dist_mat[..., lower_indices[0], lower_indices[1]] # batch_dim x (links * (links - 1) / 2) + + # select only distances between pairs of specified links + distances = dist_mat[..., self.idxs_links_distance_matrix_tuple[0], self.idxs_links_distance_matrix_tuple[1]] + + return distances + + def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor + margin = kwargs.get('margin', self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs) + any_self_collision = torch.any(distances < margin, dim=-1) + return any_self_collision + + +def reshape_q(q): + q_orig_shape = q.shape + if len(q_orig_shape) == 2: + h = 1 + b, d = q_orig_shape + elif len(q_orig_shape) == 3: + b, h, d = q_orig_shape + q = einops.rearrange(q, "b h d -> (b h) d") + else: + raise NotImplementedError + return q, q_orig_shape, b, h, d + + +# class CollisionSelfFieldWrapperSTORM(EmbodimentDistanceFieldBase): +# +# def __init__(self, robot, weights_fname, n_joints, *args, **kwargs): +# super().__init__(robot, *args, collision_margins=0., interpolate_link_pos=False, **kwargs) +# from storm_kit.geom.nn_model.robot_self_collision import RobotSelfCollisionNet +# self.robot_self_collision_net = RobotSelfCollisionNet(n_joints) +# self.robot_self_collision_net.load_weights(weights_fname, self.tensor_args) +# +# def compute_costs_impl(self, q, link_pos, **kwargs): +# embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) +# return embodiment_cost +# +# def compute_embodiment_rbf_distances(self, *args, **kwargs): # position tensor +# raise NotImplementedError +# +# def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# return sdf_self_collision +# +# def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# if len(q_orig_shape) == 3: +# sdf_self_collision = einops.rearrange(sdf_self_collision, "(b h) 1 -> b h", b=b, h=h) +# +# any_self_collision = sdf_self_collision < -0.05 # trained on 0.02 +# return any_self_collision + + +class CollisionObjectBase(EmbodimentDistanceFieldBase): + + def __init__(self, *args, link_margins_for_object_collision_checking_tensor=None, **kwargs): + super().__init__(*args, collision_margins=link_margins_for_object_collision_checking_tensor, **kwargs) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.margin) + rbf_distance = torch.exp(torch.square(self.object_signed_distances(link_pos, **kwargs)) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): + return self.object_signed_distances(link_pos, **kwargs) + + def compute_embodiment_collision(self, q, link_pos, **kwargs): + # position tensor + margin = kwargs.get('margin', self.collision_margins + self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + signed_distances = self.object_signed_distances(link_pos, **kwargs) + collisions = signed_distances < margin + # reduce over points (dim -1) and over objects (dim -2) + any_collision = torch.any(torch.any(collisions, dim=-1), dim=-1) + return any_collision + + @abstractmethod + def object_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionObjectDistanceField(CollisionObjectBase): + + def __init__(self, + *args, + df_obj_list_fn=None, + **kwargs): + super().__init__(*args, **kwargs) + self.df_obj_list_fn = df_obj_list_fn + + def object_signed_distances(self, link_pos, **kwargs): + if self.df_obj_list_fn is None: + return torch.inf + df_obj_list = self.df_obj_list_fn() + link_dim = link_pos.shape[:-1] + link_pos = link_pos.reshape(-1, link_pos.shape[-1]) # flatten batch_dim and links + dfs = [] + for df in df_obj_list: + dfs.append(df.compute_signed_distance(link_pos).view(link_dim)) # df() returns batch_dim x links + return torch.stack(dfs, dim=-2) # batch_dim x num_sdfs x links + + +class CollisionWorkspaceBoundariesDistanceField(CollisionObjectBase): + + def __init__(self, *args, ws_bounds_min=None, ws_bounds_max=None, **kwargs): + super().__init__(*args, **kwargs) + self.ws_min = ws_bounds_min + self.ws_max = ws_bounds_max + + def object_signed_distances(self, link_pos, **kwargs): + signed_distances_bounds_min = link_pos - self.ws_min + signed_distances_bounds_min = torch.sign(signed_distances_bounds_min) * torch.abs(signed_distances_bounds_min) + signed_distances_bounds_max = self.ws_max - link_pos + signed_distances_bounds_max = torch.sign(signed_distances_bounds_max) * torch.abs(signed_distances_bounds_max) + signed_distances_bounds = torch.cat((signed_distances_bounds_min, signed_distances_bounds_max), dim=-1) + return signed_distances_bounds.transpose(-2, -1) # batch_dim x num_sdfs x links + + +class EESE3DistanceField(DistanceField): + + def __init__(self, target_H, w_pos=1., w_rot=1., square=True, **kwargs): + super().__init__(**kwargs) + self.target_H = target_H + self.square = square + self.w_pos = w_pos + self.w_rot = w_rot + + def update_target(self, target_H): + self.target_H = target_H + + def compute_distance(self, link_tensor): + # homogeneous transformation link_tensor # batch x num_links x 4 x 4 + # -1: get EE as last link # TODO - get EE from its name id + return SE3_distance(link_tensor[..., -1, :, :], self.target_H, w_pos=self.w_pos, w_rot=self.w_rot) + + def compute_costs_impl(self, q, link_tensor, **kwargs): # position tensor + dist = self.compute_distance(link_tensor).squeeze() + if self.square: + dist = torch.square(dist) + return dist + + def zero_grad(self): + raise NotImplementedError + +if __name__ == '__main__': + raise NotImplementedError + import time + mesh_file = 'models/chair.obj' + mesh = MeshDistanceField(mesh_file) + bounds = np.array(mesh.mesh.bounds) + print(np.linalg.norm(bounds[1] - bounds[0])) + print(mesh.mesh.centroid) + points = torch.rand(100, 3) + link_tensor = torch.rand(100, 10, 4, 4) + start = time.time() + distances = mesh.compute_distance(points) + costs = mesh.compute_cost(link_tensor) + print(time.time() - start) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/seed.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/seed.py new file mode 100644 index 0000000000000000000000000000000000000000..00e64763d2182e7cfe341f3009d6ab866bad958d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/seed.py @@ -0,0 +1,21 @@ +import random + +import numpy as np +import torch + + +def fix_random_seed(seed): + random.seed(seed) + + try: + np.random.seed(seed) + except NameError: + pass + + try: + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + # https://pytorch.org/docs/stable/notes/randomness.html#cuda-convolution-benchmarking + torch.backends.cudnn.benchmark = False + except NameError: + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py new file mode 100644 index 0000000000000000000000000000000000000000..85c113b5c1e0b7665b596f7b9d448ba993aa7f68 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py @@ -0,0 +1,109 @@ +# Adapted from https://github.com/brouberol/contexttimer +from time import perf_counter + +import torch + + +class TimerCUDA(object): + """ A timer as a context manager + + Wraps around a timer. A custom timer can be passed + to the constructor. The default timer is timeit.default_timer. + + Note that the latter measures wall clock time, not CPU time! + On Unix systems, it corresponds to time.time. + On Windows systems, it corresponds to time.clock. + + Keyword arguments: + output -- if True, print output after exiting context. + if callable, pass output to callable. + format -- str.format string to be used for output; default "took {} seconds" + prefix -- string to prepend (plus a space) to output + For convenience, if you only specify this, output defaults to True. + """ + + def __init__(self, timer=perf_counter, + output=None, fmt="took {:.3f} seconds", prefix="", + use_cuda_events=False + ): + self.timer = timer + self.output = output + self.fmt = fmt + self.prefix = prefix + self.start_time = None + self.end = None + + self.sync_cuda = True if torch.cuda.is_available() else False + if use_cuda_events: + assert self.sync_cuda, "CUDA must be available when using CUDA events" + self.use_cuda_events = use_cuda_events + self.factor_cuda_events = 1./1000. # transform to seconds + self.start_event = None + self.end_event = None + + def __call__(self): + """ Return the current time """ + if self.use_cuda_events: + self.end_event.record() + torch.cuda.synchronize() + return self.start_event.elapsed_time(self.end_event) * self.factor_cuda_events + else: + if self.sync_cuda: + torch.cuda.synchronize() + return self.timer() + + def __enter__(self): + """ Set the start time """ + if self.use_cuda_events: + self.start_event = torch.cuda.Event(enable_timing=True) + self.end_event = torch.cuda.Event(enable_timing=True) + self.start_event.record() + else: + self.start_time = self() + + self.start = self() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """ Set the end time """ + self.end = self() + + if self.prefix and self.output is None: + self.output = True + + if self.output: + output = " ".join([self.prefix, self.fmt.format(self.elapsed)]) + if callable(self.output): + self.output(output) + else: + print(output) + + def __str__(self): + return f'{self.elapsed:.3f}' + + @property + def elapsed(self): + """ Return the current elapsed time since start + + If the `elapsed` property is called in the context manager scope, + the elapsed time between start and property access is returned. + However, if it is accessed outside of the context manager scope, + it returns the elapsed time between entering and exiting the scope. + + The `elapsed` property can thus be accessed at different points within + the context manager scope, to time different parts of the block. + + """ + if self.end is None: + # if elapsed is called in the context manager scope + if self.use_cuda_events: + self() + else: + return self() - self.start + else: + # if elapsed is called out of the context manager scope + if self.use_cuda_events: + return self.end + else: + return self.end - self.start + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..11d79a18e4e6d36d3f79548a4bf0acfc3df2514b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py @@ -0,0 +1,162 @@ +import collections +import random +from typing import List + +import numpy as np +import torch + + +def get_torch_device(device='cuda'): + if 'cuda' in device and torch.cuda.is_available(): + device = 'cuda' + elif 'mps' in device: + device = 'mps' + else: + device = 'cpu' + return torch.device(device) + + +DEFAULT_TENSOR_ARGS = {'device': get_torch_device('cuda'), 'dtype': torch.float32} + +def dict_to_device(ob, device): + if isinstance(ob, collections.Mapping): + return {k: dict_to_device(v, device) for k, v in ob.items()} + else: + return ob.to(device) + + +def to_numpy(x, dtype=np.float32, clone=False): + if torch.is_tensor(x): + x = x.detach().cpu().numpy().astype(dtype) + return x + if isinstance(x, np.ndarray): + return x.astype(dtype, copy=clone) + return np.array(x).astype(dtype) + + +def to_torch(x, device='cpu', dtype=torch.float, requires_grad=False, clone=False): + if torch.is_tensor(x): + if clone: + x = x.clone() + return x.to(device=device, dtype=dtype) + return torch.tensor(x, dtype=dtype, device=device, requires_grad=requires_grad) + + +def to_torch_2d_min(variable): + if isinstance(variable, np.ndarray) or isinstance(variable, List): + tensor_var = to_torch(variable) + else: + tensor_var = variable + if len(tensor_var.shape) == 1: + return tensor_var.unsqueeze(0) + else: + return tensor_var + + +def freeze_torch_model_params(model): + for param in model.parameters(): + param.requires_grad = False + # If the model is frozen we do not save it again, since the parameters did not change + model.is_frozen = True + + +def batch_cov(points): + B, N, D = points.size() + mean = points.mean(dim=1).unsqueeze(1) + diffs = (points - mean).reshape(B * N, D) + prods = torch.bmm(diffs.unsqueeze(2), diffs.unsqueeze(1)).reshape(B, N, D, D) + bcov = prods.sum(dim=1) + if N > 1: + bcov = bcov / (N - 1) # Unbiased estimate + else: + bcov = bcov / N + return bcov # (B, D, D) + + +def batch_trace(covs): + return covs.diagonal(offset=0, dim1=-1, dim2=-2).sum(-1) + + +@torch.jit.script +def tensor_linspace_v1(start: torch.Tensor, end: torch.Tensor, steps: int = 10): + # https://github.com/zhaobozb/layout2im/blob/master/models/bilinear.py#L246 + """ + Vectorized version of torch.linspace. + Inputs: + - start: Tensor of any shape + - end: Tensor of the same shape as start + - steps: Integer + Returns: + - out: Tensor of shape start.size() + (steps,), such that + out.select(-1, 0) == start, out.select(-1, -1) == end, + and the other elements of out linearly interpolate between + start and end. + """ + assert start.size() == end.size() + view_size = start.size() + (1,) + w_size = (1,) * start.dim() + (steps,) + out_size = start.size() + (steps,) + + start_w = torch.linspace(1, 0, steps=steps).to(start) + start_w = start_w.view(w_size).expand(out_size) + end_w = torch.linspace(0, 1, steps=steps).to(start) + end_w = end_w.view(w_size).expand(out_size) + + start = start.contiguous().view(view_size).expand(out_size) + end = end.contiguous().view(view_size).expand(out_size) + + out = start_w * start + end_w * end + return out + + +@torch.jit.script +def torch_linspace_v2(start: torch.Tensor, stop: torch.Tensor, num: int): + """ + Creates a tensor of shape [num, *start.shape] whose values are evenly spaced from start to end, inclusive. + Replicates but the multi-dimensional bahaviour of numpy.linspace in PyTorch. + """ + # create a tensor of 'num' steps from 0 to 1 + steps = torch.arange(num, dtype=start.dtype, device=start.device) / (num - 1) + + # reshape the 'steps' tensor to [-1, *([1]*start.ndim)] to allow for broadcastings + # - using 'steps.reshape([-1, *([1]*start.ndim)])' would be nice here but torchscript + # "cannot statically infer the expected size of a list in this contex", hence the code below + for i in range(start.ndim): + steps = steps.unsqueeze(-1) + + # the output starts at 'start' and increments until 'stop' in each dimension + out = start[None] + steps * (stop - start)[None] + + return out + + +# @torch.jit.script +def batched_weighted_dot_prod(x: torch.Tensor, M: torch.Tensor, y: torch.Tensor, with_einsum: bool = False): + """ + Computes batched version of weighted dot product (distance) x.T @ M @ x + """ + assert x.ndim >= 2 + if with_einsum: + My = M.unsqueeze(0) @ y + r = torch.einsum('...ij,...ij->...j', x, My) + else: + r = x.transpose(-2, -1) @ M.unsqueeze(0) @ x + r = r.diagonal(dim1=-2, dim2=-1) + return r + + +def is_positive_semi_definite(mat): + # checks if mat is a positive semi-definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real >= 0).all()) + +def is_positive_definite(mat): + # checks if mat is a positive definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real > 0).all()) + + +def torch_intersect_1d(a, b): + a_cat_b, counts = torch.cat([a, b]).unique(return_counts=True) + intersection = a_cat_b[torch.where(counts.gt(1))] + return intersection + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/metrics.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..5a0350edbd4792db74d25a2115f4902457172cc2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/metrics.py @@ -0,0 +1,92 @@ +import numpy as np +import torch + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def compute_path_length(trajs, robot): + assert trajs.ndim == 3 # batch, horizon, state_dim + trajs_pos = robot.get_position(trajs) + return compute_path_length_from_pos(trajs_pos) + + +def compute_path_length_from_pos(trajs_pos): + assert trajs_pos.ndim == 3 + path_length = torch.linalg.norm(torch.diff(trajs_pos, dim=-2), dim=-1).sum(-1) + return path_length + +def compute_variance_waypoints(trajs, robot): + assert trajs.ndim == 3 # batch, horizon, state_dim + trajs_pos = robot.get_position(trajs) + parwise_distance_between_points_waypoints = torch.cdist(trajs_pos, trajs_pos, p=2) + + sum_var_waypoints = 0. + for via_points in trajs_pos.permute(1, 0, 2): # horizon, batch, position + parwise_distance_between_points_via_point = torch.cdist(via_points, via_points, p=2) + distances = torch.triu(parwise_distance_between_points_via_point, diagonal=1).view(-1) + sum_var_waypoints += torch.var(distances) + return sum_var_waypoints + + +def compute_smoothness(trajs, robot, trajs_vel=None): + if trajs_vel is None: + assert trajs.ndim == 3 + trajs_vel = robot.get_velocity(trajs) + else: + assert trajs_vel.ndim == 3 + smoothness = torch.linalg.norm(torch.diff(trajs_vel, dim=-2), dim=-1) + smoothness = smoothness.sum(-1) # sum over trajectory horizon + return smoothness + + +def compute_average_acceleration(trajs, robot, trajs_vel=None): + if trajs_vel is None: + assert trajs.ndim == 3 + trajs_vel = robot.get_velocity(trajs) + else: + assert trajs_vel.ndim == 3 + + return compute_average_acceleration_from_pos_vel(trajs, trajs_vel) + + +def compute_average_acceleration_from_pos_vel(trajs_pos, trajs_vel): + assert trajs_pos.ndim == 3 + assert trajs_vel.ndim == 3 + + # Compute the change in velocity (acceleration). + accelerations = torch.diff(trajs_vel, dim=-2) + + # Compute the norm of the accelerations (magnitude of acceleration) + acceleration_magnitudes = torch.linalg.norm(accelerations, dim=-1) + + # Average the acceleration magnitudes over the trajectory horizon (time steps) + average_acceleration = acceleration_magnitudes.mean(-1) + + return average_acceleration + + +def compute_average_velocity_from_pos(trajs_pos)->torch.Tensor: + assert trajs_pos.ndim >= 3 # at least [batch, horizon, state_dim] + + velocities = torch.diff(trajs_pos, dim=-2) + + # Compute the norm of the velocities (magnitude of velocity) + velocity_magnitudes:torch.Tensor = torch.linalg.norm(velocities, dim=-1) + + # Average the velocity magnitudes over the trajectory horizon (time steps) + average_velocity = velocity_magnitudes.mean(dim=-1) + + return average_velocity + +def compute_max_velocity_from_pos(trajs_pos:torch.Tensor)->torch.Tensor: + assert trajs_pos.ndim >= 3 # at least [batch, horizon, state_dim] + + velocities = torch.diff(trajs_pos, dim=-2) + + # Compute the norm of the velocities (magnitude of velocity) + velocity_magnitudes:torch.Tensor = torch.linalg.norm(velocities, dim=-1) + + # Average the velocity magnitudes over the trajectory horizon (time steps) + max_velocity = velocity_magnitudes.max(dim=-1).values + + return max_velocity diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0be9c3cc2654dc0f303294886883ef4cca3e5d92 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/trajectory/utils.py @@ -0,0 +1,100 @@ +import numpy as np +import torch +from scipy import interpolate + +from torch_robotics.torch_utils.torch_utils import to_torch, to_numpy + + +def smoothen_trajectory(traj_pos, n_support_points=30, dt=0.02, set_average_velocity=True, zero_velocity=False, + tensor_args=None): + """ + Smoothen a trajectory by interpolating it with a cubic spline and setting the average velocity. + """ + assert not (set_average_velocity and zero_velocity), "Either sets the average velocity or zero velocity" + traj_pos = to_numpy(traj_pos) + try: + # bc_type='clamped' for zero velocities at start and finish + spline_pos = interpolate.make_interp_spline(np.linspace(0, 1, traj_pos.shape[0]), traj_pos, k=3, + bc_type='clamped') + spline_vel = spline_pos.derivative(1) + except: + # Trajectory is too short to interpolate, so add last position again and interpolate + traj_pos = np.vstack((traj_pos, traj_pos[-1] + np.random.normal(0, 0.01))) + return smoothen_trajectory(traj_pos, n_support_points=n_support_points, dt=dt, + set_average_velocity=set_average_velocity, zero_velocity=zero_velocity, + tensor_args=tensor_args) + + pos = spline_pos(np.linspace(0, 1, n_support_points)) + vel = np.zeros_like(pos) + if zero_velocity: + pass + elif set_average_velocity: + avg_vel = (traj_pos[1] - traj_pos[0]) / (n_support_points * dt) + vel[1:-1, :] = avg_vel + else: + vel = spline_vel(np.linspace(0, 1, n_support_points)) + + return to_torch(pos, **tensor_args), to_torch(vel, **tensor_args) + + +def smoothen_trajectory_with_bc(traj_pos, grad_start, grad_goal, bc_weight=0.2, n_support_points=30, dt=0.02, + set_average_velocity=True, zero_velocity=False, tensor_args=None): + traj_pos = to_numpy(traj_pos).T # Shape is (q_pos_dim, H) + adjusted_points = np.copy(traj_pos) + if not grad_start is None and not grad_goal is None: + grad_start = to_numpy(grad_start) + grad_goal = to_numpy(grad_goal) + print(traj_pos.shape, grad_start.shape, grad_goal.shape) + # Adjust the control points based on derivative conditions + adjusted_points[:, 0] += grad_start * bc_weight # scale to adjust sensitivity + adjusted_points[:, -1] -= grad_goal * bc_weight # scale to adjust sensitivity + # Add the original start and end points back in. + adjusted_points = np.hstack((traj_pos[:, [0]], adjusted_points, traj_pos[:, [-1]])) + + # Create the spline + tck, u = interpolate.splprep([adjusted_points[i] for i in range(len(adjusted_points))], s=0.1) + + # Evaluate the spline + u_fine = np.linspace(0, 1, n_support_points) + x_fine, y_fine = interpolate.splev(u_fine, tck) + + pos = np.stack((x_fine, y_fine), axis=0) + vel = np.zeros_like(pos) + if zero_velocity: + pass + elif set_average_velocity: + avg_vel = (traj_pos[:, 1] - traj_pos[:, 0]) / (n_support_points * dt) + vel[:, 1:-1] = avg_vel[:, None] + else: + vel = interpolate.splev(u_fine, tck, der=1) + return to_torch(pos, **tensor_args).T, to_torch(vel, **tensor_args).T # Shape is (H, q_pos_dim) + + +def interpolate_traj_via_points(trajs, num_interpolation=10): + # Interpolates a trajectory linearly between waypoints + H, D = trajs.shape[-2:] + if num_interpolation > 0: + assert trajs.ndim > 1 + traj_dim = trajs.shape + alpha = torch.linspace(0, 1, num_interpolation + 2).type_as(trajs)[1:num_interpolation + 1] + alpha = alpha.view((1,) * len(traj_dim[:-1]) + (-1, 1)) + interpolated_trajs = trajs[..., 0:traj_dim[-2] - 1, None, :] * alpha + \ + trajs[..., 1:traj_dim[-2], None, :] * (1 - alpha) + interpolated_trajs = interpolated_trajs.view(traj_dim[:-2] + (-1, D)) + else: + interpolated_trajs = trajs + return interpolated_trajs + + +def finite_difference_vector(x, dt=1., method='forward'): + # finite differences with zero padding at the borders + diff_vector = torch.zeros_like(x) + if method == 'forward': + diff_vector[..., :-1, :] = torch.diff(x, dim=-2) / dt + elif method == 'backward': + diff_vector[..., 1:, :] = (x[..., 1:, :] - x[..., :-1, :]) / dt + elif method == 'central': + diff_vector[..., 1:-1, :] = (x[..., 2:, :] - x[..., :-2, :]) / (2 * dt) + else: + raise NotImplementedError + return diff_vector diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f90ff0ef592ed04ba7b27c18bea03ced9b708bd9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py @@ -0,0 +1,367 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation + +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.tasks.tasks_ensemble import PlanningTaskEnsemble +from .planning_visualizer import create_fig_and_axes +import matplotlib.collections as mcoll + + +class PlanningVisualizerEnsemble: + + def __init__(self, + task_ensmeble: PlanningTaskEnsemble, + planner=None): + self.task_ensmeble = task_ensmeble + self.env = self.task_ensmeble.env + self.robot = self.task_ensmeble.tasks[0].robot + self.planner = planner + + self.colors = {'collision': 'black', 'free': 'orange'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, + return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), **kwargs) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + print(qs.shape) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else + self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color] * len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps[ + 'free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S - 1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip( + [(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # # Start and goal + # if pos_start_state is not None: + # ax[0].scatter(0, pos_start_state[i], color='green') + # if vel_start_state is not None: + # ax[1].scatter(0, vel_start_state[i], color='green') + # if pos_goal_state is not None: + # ax[0].scatter(H - 1, pos_goal_state[i], color='purple') + # if vel_goal_state is not None: + # ax[1].scatter(H - 1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S - 1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames - 1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=90) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f2353b802d06d9ebd848800be45d27e5b1f82db6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py @@ -0,0 +1,397 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation +import matplotlib.collections as mcoll + +# Project includes. +from torch_robotics.torch_utils.torch_utils import to_numpy +from mmd.plotting.base import remove_borders, remove_axes_labels_ticks + +def create_fig_and_axes(dim=2): + fig = plt.figure(layout='tight') + if dim == 3: + ax = fig.add_subplot(projection='3d') + else: + ax = fig.add_subplot() + + return fig, ax + + +class PlanningVisualizer: + + def __init__(self, task=None, planner=None): + self.task = task + self.env = self.task.env + self.robot = self.task.robot + self.planner = planner + + # self.colors = {'collision': 'black', 'free': 'cornsilk'} + self.colors = {'collision': 'black', 'free': 'lightcyan'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, + show_robot_in_image=False, constraints_l=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, constraints_l=constraints_l, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), constraints_l=constraints_l, **kwargs) + + # Add the robot with tail at the third-way time step. + if show_robot_in_image: + t_mid = trajs.shape[1] // 5 + qs = trajs[:, t_mid, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + tail_length = min(5, t_mid) + for di in range(tail_length): + q_tail.append(trajs[:, t_mid - di, :].squeeze()) + kwargs['q_tail'] = q_tail + # Random color from colormap for the robot and tail. From cmap tab20(do c optionally). + color = plt.cm.tab20(np.random.randint(0, 20)) + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color]*len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + # if start_state is not None: + # self.robot.render(ax, start_state, color='green', cmap='Greens') + # if goal_state is not None: + # self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + # Remove the axes, frame, and title. TEST TEST TEST. + remove_borders(ax) + remove_axes_labels_ticks(ax) + ax.set_title('') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S-1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip([(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # Start and goal + if pos_start_state is not None: + ax[0].scatter(0, pos_start_state[i], color='green') + if vel_start_state is not None: + ax[1].scatter(0, vel_start_state[i], color='green') + if pos_goal_state is not None: + ax[0].scatter(H-1, pos_goal_state[i], color='purple') + if vel_goal_state is not None: + ax[1].scatter(H-1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S-1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames -1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=100) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..eaae21e271ddf8302631a2ea2e63f52d9bb83218 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py @@ -0,0 +1,32 @@ +from copy import copy + +import torch + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def plot_coordinate_frame(ax, frame, arrow_length=0.1, arrow_alpha=1.0, arrow_linewidth=1.0, tensor_args=None): + target_pos_np = to_numpy(frame.translation).squeeze() + frame_target_no_trans = copy(frame) + frame_target_no_trans.set_translation(torch.zeros_like(frame.translation)) + + x_basis = torch.tensor([1, 0, 0], **tensor_args) + y_basis = torch.tensor([0, 1, 0], **tensor_args) + z_basis = torch.tensor([0, 0, 1], **tensor_args) + + x_axis_target = to_numpy(frame_target_no_trans.transform_point(x_basis).squeeze()) + y_axis_target = to_numpy(frame_target_no_trans.transform_point(y_basis).squeeze()) + z_axis_target = to_numpy(frame_target_no_trans.transform_point(z_basis).squeeze()) + + # x-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + x_axis_target[0], x_axis_target[1], x_axis_target[2], + length=arrow_length, normalize=True, color='red', alpha=arrow_alpha, linewidth=arrow_linewidth) + # y-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + y_axis_target[0], y_axis_target[1], y_axis_target[2], + length=arrow_length, normalize=True, color='green', alpha=arrow_alpha, linewidth=arrow_linewidth) + # z-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + z_axis_target[0], z_axis_target[1], z_axis_target[2], + length=arrow_length, normalize=True, color='blue', alpha=arrow_alpha, linewidth=arrow_linewidth) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/environment.yml b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/environment.yml new file mode 100644 index 0000000000000000000000000000000000000000..fd7de265ea5acade5b7d23c312b146f309c39184 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/environment.yml @@ -0,0 +1,58 @@ +name: direct-mmd +channels: + - pytorch + - conda-forge +dependencies: + - python=3.8 + - pip + - patchelf + - pip: + # Change the torch, jax to match cuda version + # Index URLs for PyTorch CUDA and JAX CUDA wheels + - --extra-index-url https://download.pytorch.org/whl/cu124 + - --find-links https://storage.googleapis.com/jax-releases/jax_cuda_releases.html + - torch==2.4.0 + - torchvision==0.19.0 + - torchdiffeq + - torch-summary + - jax==0.4.13 + - jaxlib==0.4.13+cuda12.cudnn89 + - jaxopt==0.8.5 + - pyyaml + - setuptools==70.2.0 + - ml-collections==0.1.1 + - configargparse + - hydra==2.5 + - numpy==1.24.4 + - scipy==1.10.1 + - scikit-learn==1.3.2 + - scikit-image==0.19.3 + - pandas==2.0.3 + - matplotlib==3.7.5 + - plotly + - seaborn==0.13.2 + - opencv-python==4.10.0.84 + - pillow + - einops==0.6.1 + - tensorboard==2.14.0 + - wandb + - sentence-transformers + - transformers==4.46.3 + - casadi + - trimesh + - pyrender==0.1.45 + - urdfpy==0.0.22 + - urdf-parser-py + - pycollada + - mesh-to-sdf + - shapely + - pygame + - cholespy==2.0.0 + - gdown + - tqdm==4.43.0 + - networkx + - moviepy + - tabulate + - gitpython + - scikit-video + - joblib diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ba1d5b2b5aaa1f07c6c7a1d326066437a38f474f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/__init__.py @@ -0,0 +1 @@ +__version__ = '0.01' diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..77b429bee7e23497bde0afc61c10dde254a82992 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/__init__.py @@ -0,0 +1,7 @@ +from .conflicts import * +from .constraints import * +from .experiences import * +from .trajectory_utils import * +from .pretty_print import * +from .multi_agent_utils import * +from .eval_utils import * \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/argparse.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/argparse.py new file mode 100644 index 0000000000000000000000000000000000000000..e24bafd2fc58d4bb18fde3d6e2e4d63dd2a956a6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/argparse.py @@ -0,0 +1,73 @@ +import yaml +import re +import logging +from typing import List, Dict, Any + + + +## ========================== Parse and Load Config ==========================. +def load_config(config_path: str, overrides: Dict[str, Any] = None) -> dict: + """Load the YAML config and apply any dotted-key overrides.""" + with open(config_path, "r") as f: + config = yaml.safe_load(f) + + if overrides: + update_nested_config(config, overrides) + return config + +def parse_overrides(extra_args: List) -> Dict[str, Any]: + """ + Turn a list like ['--a.b=1', '--c.d', 'hello'] into {'a.b': 1, 'c.d': 'hello'}. + """ + overrides: Dict[str, Any] = {} + i = 0 + while i < len(extra_args): + token:str = extra_args[i] + if not token.startswith("--"): + logging.error(f"Unrecognized token {token!r}") + raise ValueError(f"Unrecognized token {token!r}") + + # split --key=val or consume next + if "=" in token: + key, raw_val = token[2:].split("=", 1) + else: + key = token[2:] + i += 1 + if i >= len(extra_args): + raise ValueError(f"Missing value for `{token}`") + raw_val = extra_args[i] + + # parse YAML literal so we get ints, bools, lists, etc. + try: + val = yaml.safe_load(raw_val) + # convert quoted scientific-notation strings to float + if isinstance(val, str): + if re.fullmatch(r"[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?", val): + val = float(val) + except yaml.YAMLError: + val = raw_val + + overrides[key] = val + i += 1 + + return overrides + +def update_nested_config(cfg: dict, overrides: Dict[str, Any]) -> dict: + """ + For each 'a.b.c': v in overrides, set cfg['a']['b']['c'] = v, creating dicts as needed. + """ + for dotted_key, v in overrides.items(): + parts = dotted_key.split(".") + d = cfg + for i, p in enumerate(parts[:-1]): + if p in d and isinstance(d[p], dict): + d = d[p] + else: + logging.error(f"Invalid override key: \'{'.'.join(parts[:(i+1)])}\'") + raise KeyError(f"Invalid override key: '{'.'.join(parts[:(i+1)])}'") + if parts[-1] in d: + d[parts[-1]] = v + else: + logging.error(f"Invalid override key: \'{dotted_key}\'") + raise KeyError(f"Invalid override key: '{dotted_key}'") + return cfg diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflict_conversion.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflict_conversion.py new file mode 100644 index 0000000000000000000000000000000000000000..74d85166a368047b26836056b480ca0a1107fc9c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflict_conversion.py @@ -0,0 +1,82 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from typing import List, Tuple, Dict, Type +# MMD imports. +from mmd.common.conflicts import Conflict, VertexConflict, EdgeConflict, PointConflict +from mmd.common.constraints import MultiPointConstraint, EdgeConstraint, VertexConstraint, Constraint +from mmd.config.mmd_params import MMDParams as params + + +def convert_conflicts_to_constraints(conflict: Conflict, + conflict_type_to_constraint_types: Dict[Type[Conflict], Type[Constraint]], + t_pad: int = 2, + ) -> List[Tuple[int, MultiPointConstraint]]: + """ + Convert a conflict to a list of constraints. + """ + # Get the type of the constraint to create for this type of conflict. + constraints = [] + if isinstance(conflict, PointConflict): + # def conflicts_to_constraints(self, conflicts: List[VertexConflict]) -> List[Tuple[int, Constraint]]: + """ + Convert a list of conflicts to a list of constraints. + """ + if MultiPointConstraint in conflict_type_to_constraint_types[PointConflict]: + for agent_id in conflict.agent_ids: + constraints.append( + (agent_id, MultiPointConstraint( + q_l=[conflict.agent_id_to_q[agent_id]], + t_range_l=[(conflict.t_from - t_pad, + conflict.t_to + t_pad)], + radius_l=[params.vertex_constraint_radius]) + ) + ) + + else: + raise NotImplementedError() + + elif isinstance(conflict, EdgeConflict): + if EdgeConstraint in conflict_type_to_constraint_types[EdgeConflict]: + for agent_id in conflict.agent_ids: + constraints.append( + (agent_id, EdgeConstraint( + q_from=conflict.agent_id_to_q_from[agent_id], + q_to=conflict.agent_id_to_q_to[agent_id], + t_from=conflict.t_from, + t_to=conflict.t_to))) + else: + raise NotImplementedError() + + elif isinstance(conflict, VertexConflict): + if VertexConstraint in conflict_type_to_constraint_types[VertexConflict]: + for agent_id in conflict.agent_ids: + constraints.append( + (agent_id, VertexConstraint( + q=conflict.q_map[agent_id], + t=conflict.t))) + else: + raise NotImplementedError() + + return constraints diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflicts.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflicts.py new file mode 100644 index 0000000000000000000000000000000000000000..cf709f26ecaef455621b5f36caefb09d97259d92 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/conflicts.py @@ -0,0 +1,106 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from typing import List, Tuple +from abc import ABC, abstractmethod + + +class Conflict(ABC): + """ + A class holding within it information for defining a conflict. + """ + def __init__(self): + self.time_interval = None # Should be two-element list. + + @abstractmethod + def get_t_range(self) -> Tuple[int, int]: + pass + + +class VertexConflict(Conflict): + """ + A class holding within it information for defining a conflict. + """ + def __init__(self, agent_ids: List, q_l: List, t: int): + super().__init__() + self.agent_ids = agent_ids + self.q_map = {agent_id: q for agent_id, q in zip(agent_ids, q_l)} + self.t = t + + def get_t_range(self) -> Tuple[int, int]: + return self.t, self.t + + def __repr__(self): + return (f"VertexConflict(agent_ids={self.agent_ids}, \n" + + f"q_l={self.q_map}, \n" + + f"t={self.t})") + + +class EdgeConflict(Conflict): + """ + A class holding within it information for defining a conflict. + """ + def __init__(self, agent_ids: List, + q_from_l: List, q_to_l: List, + t_from: int, t_to: int, + ): + super().__init__() + self.agent_ids = agent_ids + self.agent_id_to_q_from = {agent_id: q_from for agent_id, q_from in zip(agent_ids, q_from_l)} + self.agent_id_to_q_to = {agent_id: q_to for agent_id, q_to in zip(agent_ids, q_to_l)} + self.t_from = t_from + self.t_to = t_to + + def get_t_range(self) -> Tuple[int, int]: + return self.t_from, self.t_to + + def __repr__(self): + return (f"EdgeConflict(agent_ids={self.agent_ids}, \n" + + f" q_from_l={self.agent_id_to_q_from}, \n" + + f" q_to_l={self.agent_id_to_q_to}, \n" + + f" t_from={self.t_from}, \n" + + f" t_to={self.t_to})") + + +class PointConflict(Conflict): + """ + A class holding within it information for defining a conflict. + """ + def __init__(self, agent_ids: List, q_l: List, p_l: List, t_from: int, t_to: int): + super().__init__() + self.agent_ids = agent_ids + self.agent_id_to_p = {agent_id: p for agent_id, p in zip(agent_ids, p_l)} + self.agent_id_to_q = {agent_id: q for agent_id, q in zip(agent_ids, q_l)} + self.t_from = t_from + self.t_to = t_to + + def get_t_range(self) -> Tuple[int, int]: + return self.t_from, self.t_to + + def __repr__(self): + return (f"PointConflict(agent_ids={self.agent_ids}, \n" + f" q_l={self.agent_id_to_q}, \n" + + f" p_l={self.agent_id_to_p}, \n" + + f" t_from={self.t_from}, \n" + + f" t_to={self.t_to})") + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/constraints.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..a99dd1cb824e26d804b6039f9fff14739c7a8efa --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/constraints.py @@ -0,0 +1,144 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +# General includes. +from abc import ABC, abstractmethod +from typing import Tuple, List +import torch + +# Project includes. +from mmd.config import MMDParams as params + + +class Constraint(ABC): + """ + A class holding within it information for defining a constraint. + """ + def __init__(self): + pass + + @abstractmethod + def get_t_range_l(self): + pass + + +class MultiPointConstraint(Constraint): + """ + A class holding within it information for defining a batch of constraints. Each constraint has an associated time + range in which it is active, a configuration for its center, and a radius in configuration space. + """ + def __init__(self, q_l: List[torch.Tensor], + t_range_l: List[Tuple[int, int]], + radius_l: List[float] = None, + is_soft: bool = False): + super().__init__() + # The list of configurations that are constrained. + self.q_l = q_l + # The time range in which the constraint is active. Inclusive on boundary. + self.t_range_l = t_range_l + # The radius of the constraint, in configuration space. + self.radius_l = [params.vertex_constraint_radius] * len(q_l) if radius_l is None else radius_l + # The weight of the constraint. This may be used as the guide gradient scaling factor. + self.is_soft = is_soft + + def get_q_l(self) -> List[torch.Tensor]: + return self.q_l + + def get_t_range_l(self): + return self.t_range_l + + def get_radius_l(self): + return self.radius_l + + def get_is_soft(self): + return self.is_soft + + def get_copy(self): + constraint_new = MultiPointConstraint(self.q_l.copy(), self.t_range_l.copy(), self.radius_l.copy(), self.is_soft) + return constraint_new + + def __str__(self): + return f"q_l: {self.q_l}, t_range_l: {self.t_range_l}, radius_l: {self.radius_l}, is_soft: {self.is_soft}" + + def __repr__(self): + return f"q_l: {self.q_l}, t_range_l: {self.t_range_l}, radius_l: {self.radius_l}, is_soft: {self.is_soft}" + + +class VertexConstraint(Constraint): + """ + A class holding within it information for defining a vertex constraint. + """ + def __init__(self, q: torch.Tensor, + t: int) -> None: + super().__init__() + # The configuration that is constrained. + self.q = q + # The time at which the constraint is active. + self.t_range_l = [[t, t]] + + def get_q(self) -> torch.Tensor: + return self.q + + def get_t_range_l(self): + return self.t_range_l + + def get_copy(self): + constraint_new = VertexConstraint(self.q.clone(), self.t_range_l[0][0]) + return constraint_new + + def __repr__(self): + return f"VertexConstraint at q: {self.q}, t_range_l: {self.t_range_l}" + + +class EdgeConstraint(Constraint): + """ + A class holding within it information for defining an edge constraint. + """ + def __init__(self, q_from: torch.Tensor, t_from: int, + q_to: torch.Tensor, t_to: int) -> None: + super().__init__() + # The configurations that are constrained. + self.q_from = q_from + self.q_to = q_to + # The time at which the constraint is active. + self.t_range_l = [[t_from, t_to]] + + def get_q_from(self) -> torch.Tensor: + return self.q_from + + def get_q_to(self) -> torch.Tensor: + return self.q_to + + def get_t_range_l(self): + return self.t_range_l + + def get_copy(self): + constraint_new = EdgeConstraint(self.q_from.clone(), + self.t_range_l[0][0], + self.q_to.clone(), + self.t_range_l[0][1]) + return constraint_new + + def __repr__(self): + return f"EdgeConstraint from q: {self.q_from}, to q: {self.q_to}, t_range_l: {self.t_range_l}" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/eval_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/eval_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..85aa57b027f0021800fdd31395201c92403c2587 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/eval_utils.py @@ -0,0 +1,162 @@ +import numpy as np +import torch +from typing import Union, Literal, List, Tuple + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments import * + +def collision_detect( + trajs: np.ndarray, + safe_dist: float, + norm_order: Union[int, str] = 2, +)->np.ndarray: + """ + Check collision for batched trajectories and return a mask. + + Args: + traj (np.ndarray): A batch of trajectories with shape (..., player, horizon, D), + where D is the dimension of the state space. + safe_dist (float): The safe distance threshold for collision detection. + norm_order (Union[int, str]): The order of the norm to use for distance calculation. + Can be a positive integer or 'inf' + + Returns: + np.ndarray: A boolean array of shape (..., player, player, horizon) indicating pair-wise trajectory collision masks on all horizon steps. + """ + + trajs = np.asarray(trajs) + + assert trajs.ndim >= 3, "Trajectory must have at least 3 dimensions (player, horizon and state)." + if trajs.shape[-1] < 1: + raise ValueError("Trajectory must have at least 1 dimensions for collision detection.") + assert trajs.shape[-3] >= 2, "There must be at least 2 players in the trajectory array for collision detection." + + n_players = trajs.shape[-3] + horizon = trajs.shape[-2] + state_dim = trajs.shape[-1] + preceding_dims = trajs.shape[:-3] + + traj1 = trajs.reshape(preceding_dims + (n_players, 1, horizon, state_dim)) + traj2 = trajs.reshape(preceding_dims + (1, n_players, horizon, state_dim)) + + distances = np.linalg.norm(traj1 - traj2, ord=norm_order, axis=-1) + + for p in range(n_players): + distances[..., p, p, :] = safe_dist + 1 + + collision_masks = distances < safe_dist + + expected_shape = trajs.shape[:-3] + (n_players, n_players, horizon) + assert collision_masks.shape == expected_shape, ( + f"Expected collision mask shape {expected_shape}, got {collision_masks.shape}" + ) + + return collision_masks + + +def calc_data_adherence( + trajs: np.ndarray, + agent_model_ids_l: List[str], + agent_model_transforms_l: List[List], + start_time_l: List[int], + horizon: int, + tensor_args: dict, +) -> np.ndarray: + """ + Calautate data adherence. + + Args: + trajs (np.ndarray): A batch of trajectories with shape (..., n_agents, horizon, dimension). + agent_model_ids_l (List[str]): List of model IDs for each agent. + agent_model_transforms_l (List[List]): List of transforms for each agent's model. + start_time_l (List[int]): List of start times for each agent. + horizon (int): The horizon length of the trajectories. + tensor_args (dict): Arguments for tensor creation, e.g., device and dtype. + + Returns: + np.ndarray: Data adherence scores for each agent with shape (..., n_agents). + """ + B, I, N, H, D = trajs.shape + data_adherence = torch.zeros(*trajs.shape[:-2], **tensor_args) + traj_tensor = torch.from_numpy(trajs).to(**tensor_args) + for agent_id in range(N): + agent_data_adherence = 0.0 + for skeleton_step, agent_model_id in enumerate(agent_model_ids_l[agent_id]): + agent_model_transform = agent_model_transforms_l[agent_id][skeleton_step] + agent_start_time = start_time_l[agent_id] + single_tile_traj_len = horizon + agent_path_in_model_frame = ( + traj_tensor[..., agent_id, :, :].clone()[ + ..., + agent_start_time + skeleton_step * single_tile_traj_len: + agent_start_time + (skeleton_step + 1) * single_tile_traj_len, + :2 + ] - agent_model_transform + ) + model_env_name = agent_model_id.split('-')[0] + kwargs = {'tensor_args': tensor_args} + import torch_robotics.environments as _env_module + env_class = getattr(_env_module, model_env_name) + env_object = env_class(**kwargs) + assert hasattr(env_object, 'compute_traj_data_adherence_batch'), \ + "Environment object must implement `compute_traj_data_adherence_batch` method." + agent_data_adherence += env_object.compute_traj_data_adherence_batch(agent_path_in_model_frame) + agent_data_adherence /= len(agent_model_ids_l[agent_id]) + data_adherence[..., agent_id] = agent_data_adherence + + data_adherence = data_adherence.to('cpu').numpy() if isinstance(data_adherence, torch.Tensor) else data_adherence + + return data_adherence + + +def calc_velocity_vec( + trajs: np.ndarray, + dt: float, +) -> np.ndarray: + """ + Calculate the velocity of the trajectories. + + Args: + trajs (np.ndarray): A batch of trajectories with shape (..., horizon, D). + dt (float): The time step duration. + + Returns: + np.ndarray: The velocity of the trajectories with shape (..., horizon-1, D). + """ + trajs = np.asarray(trajs) + assert trajs.ndim >= 2, "Trajectory must have at least 2 dimensions (horizon and state)." + + velocities_vec = np.diff(trajs, axis=-2) / dt + + return velocities_vec + + +def calc_velocity( + trajs: np.ndarray, + dt: float, + norm_kwargs: dict = {} +) -> np.ndarray: + """ + Calculate the velocity of the trajectories. + + Args: + trajs (np.ndarray): A batch of trajectories with shape (..., horizon, D). + dt (float): The time step duration. + + Returns: + np.ndarray: The velocity of the trajectories with shape (..., horizon-1). + """ + trajs = np.asarray(trajs) + assert trajs.ndim >= 2, "Trajectory must have at least 2 dimensions (horizon and state)." + + default_norm_kwargs = { + "ord": 2, + "axis": -1, + "keepdims": False, + } + default_norm_kwargs.update(norm_kwargs) + + v_vec = calc_velocity_vec(trajs, dt) + velocities = np.linalg.norm(v_vec, **default_norm_kwargs) + + return velocities diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiences.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiences.py new file mode 100644 index 0000000000000000000000000000000000000000..560f894a6edff04bb6bd54e1dfebf4522674380c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiences.py @@ -0,0 +1,52 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +from abc import ABC +import torch + + +class Experience(ABC): + def __init__(self): + pass + + +class PathExperience(Experience): + def __init__(self, path: torch.Tensor): + super().__init__() + # This is the experience path. This object is a torch tensor commonly of shape (B, H, q_dim). + # B is the batch size, H is the horizon, and q_dim is the dimension of the configuration + # (including perhaps dynamics). + # A common case of disk robots this is (50, 64, 4). The final dimension is x, y, xdot, ydot. + self.path = path + + +class PathBatchExperience(Experience): + def __init__(self, path_b: torch.Tensor): + super().__init__() + # This is the experience path batch. This object is a torch tensor commonly of shape (B, H, q_dim). + # B is the batch size, H is the horizon, and q_dim is the dimension of the configuration + # (including perhaps dynamics). + # In the common case of disk robots this is (50, 64, 4). x, y, xdot, ydot. + self.path_b = path_b + \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..da98011809082fb9593aab7ba1d148479f15f422 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/__init__.py @@ -0,0 +1,2 @@ +from .experiments import * +from .experiment_utils import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiment_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiment_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8199d7b17dd9a600747806794faaaf2873ddf3d6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiment_utils.py @@ -0,0 +1,196 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import os +import pickle +from datetime import datetime +import time +from enum import Enum +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List +import concurrent.futures +# Project imports. +from mmd.common.experiments import * + + +def read_aggregated_trial_results_for_experiment(experiment_config: MultiAgentPlanningExperimentConfig) -> dict: + # Read all results for an experiment. Return a dictionary of the form: + # {instance_name: + # {num_agents: + # {multi_agent_planner_class: + # NO DISTINCTION BETWEEN SINGLE AGENT PLANNERS FOR NOW. + # [results] + # }}}} + results_dir = get_result_dir_from_time_str(experiment_config.time_str) + # Get all the results. + aggregated_results = {} + instance_name = experiment_config.instance_name + for num_agents in experiment_config.num_agents_l: + aggregated_results[num_agents] = {} + for multi_agent_planner_class in experiment_config.multi_agent_planner_class_l: + aggregated_results[num_agents][multi_agent_planner_class] = [] + for trial_number in range(experiment_config.num_trials_per_combination): + trial_config = MultiAgentPlanningSingleTrialConfig() + trial_config.instance_name = instance_name + trial_config.num_agents = num_agents + trial_config.multi_agent_planner_class = multi_agent_planner_class + trial_config.single_agent_planner_class = experiment_config.single_agent_planner_class + results_dir = get_result_dir_from_trial_config(trial_config, + time_str=experiment_config.time_str, + trial_number=trial_number) + # Read the results. + trial_result_fpath = os.path.join(results_dir, 'results.pkl') + # Check that this file exists. + if not os.path.exists(trial_result_fpath): + # print(f"Warning: {trial_result_fpath} does not exist.") + continue + + with open(trial_result_fpath, 'rb') as f: + trial_result = pickle.load(f) # Object type is MultiAgentPlanningSingleTrialResult. + aggregated_results[num_agents][multi_agent_planner_class].append(trial_result) + + return aggregated_results + + +def combine_and_save_results_for_experiment(experiment_config: MultiAgentPlanningExperimentConfig): + # Analyze all results for an experiment. Returns a dictionary of the form: + # {instance_name: + # {num_agents: + # {multi_agent_planner_class: + # avg_ct_expansions, + # success_rate, + # fail_rate_runtime_limit, + # fail_rate_no_solution, + # avg_num_collisions_in_solution, + # avg_data_adherence, + # avg_planning_time, + # avg_path_length_per_agent, + # avg_mean_path_acceleration_agent_avg + # }}}} + aggregated_results = read_aggregated_trial_results_for_experiment(experiment_config) + # Analyze the results. + analyzed_results = {} + for num_agents in experiment_config.num_agents_l: + analyzed_results[num_agents] = {} + for multi_agent_planner_class in experiment_config.multi_agent_planner_class_l: + analyzed_results[num_agents][multi_agent_planner_class] = {} + # Keep track of the number of successful trials to normalize some metrics based on that. + num_successful_trials = 0 + for trial_result in aggregated_results[num_agents][multi_agent_planner_class]: + if trial_result.success_status == TrialSuccessStatus.SUCCESS: + num_successful_trials += 1 + # Initialize the dictionary. + if 'avg_ct_expansions' not in analyzed_results[num_agents][multi_agent_planner_class]: + analyzed_results[num_agents][multi_agent_planner_class]['avg_data_adherence'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['avg_planning_time'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['avg_path_length_per_agent'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['avg_mean_path_acceleration_per_agent'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['success_rate'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_runtime_limit'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_no_solution'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_collision_agents'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['avg_num_collisions_in_solution'] = 0.0 + analyzed_results[num_agents][multi_agent_planner_class]['avg_ct_expansions'] = 0.0 + + # Add the metrics that need to be normalized by the total number of trials. + analyzed_results[num_agents][multi_agent_planner_class][ + 'success_rate'] += 1 if trial_result.success_status == TrialSuccessStatus.SUCCESS else 0 + analyzed_results[num_agents][multi_agent_planner_class][ + 'fail_rate_runtime_limit'] += 1 if trial_result.success_status == TrialSuccessStatus.FAIL_RUNTIME_LIMIT else 0 + analyzed_results[num_agents][multi_agent_planner_class][ + 'fail_rate_no_solution'] += 1 if trial_result.success_status == TrialSuccessStatus.FAIL_NO_SOLUTION else 0 + analyzed_results[num_agents][multi_agent_planner_class][ + 'fail_rate_collision_agents'] += 1 if trial_result.success_status == TrialSuccessStatus.FAIL_COLLISION_AGENTS else 0 + + # Add the metrics that need to be normalized by the total number of successful trials. + if trial_result.success_status == TrialSuccessStatus.SUCCESS: + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_ct_expansions'] += trial_result.num_ct_expansions + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_data_adherence'] += trial_result.data_adherence + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_planning_time'] += trial_result.planning_time + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_path_length_per_agent'] += trial_result.path_length_per_agent + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_mean_path_acceleration_per_agent'] += trial_result.mean_path_acceleration_per_agent + analyzed_results[num_agents][multi_agent_planner_class][ + 'avg_num_collisions_in_solution'] += trial_result.num_collisions_in_solution + + # Normalize the dictionary. + num_trials = len(aggregated_results[num_agents][multi_agent_planner_class]) + if num_trials > 0: + # Some metrics normalized by num_trials. + analyzed_results[num_agents][multi_agent_planner_class]['success_rate'] /= num_trials + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_runtime_limit'] /= num_trials + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_no_solution'] /= num_trials + analyzed_results[num_agents][multi_agent_planner_class]['fail_rate_collision_agents'] /= num_trials + # Normalize by the number of successful trials. + if num_successful_trials > 0: + analyzed_results[num_agents][multi_agent_planner_class]['avg_ct_expansions'] /= num_successful_trials + analyzed_results[num_agents][multi_agent_planner_class]['avg_data_adherence'] /= num_successful_trials + analyzed_results[num_agents][multi_agent_planner_class]['avg_planning_time'] /= num_successful_trials + analyzed_results[num_agents][multi_agent_planner_class]['avg_path_length_per_agent'] /= num_successful_trials + analyzed_results[num_agents][multi_agent_planner_class]['avg_mean_path_acceleration_per_agent'] /= num_successful_trials + analyzed_results[num_agents][multi_agent_planner_class]['avg_num_collisions_in_solution'] /= num_trials + + # Save to a CSV in the results directory. Create one CSV per number of agents. + results_dir = get_results_dir_from_experiment_config(experiment_config) + df_num_agents_l = [] + for num_agents in experiment_config.num_agents_l: + fpath_results_num_agents = os.path.join(results_dir, f'aggregated_results_{num_agents}_agents.csv') + # If this already exists, delete it. + if os.path.exists(fpath_results_num_agents): + os.remove(fpath_results_num_agents) + df = pd.DataFrame(analyzed_results[num_agents]) + # Add a num_agents row. + df.loc['num_agents'] = num_agents + # # Transpose the dataframe. + # df = df.T + df.to_csv(fpath_results_num_agents) + df_num_agents_l.append(df.T) + + # Now combine all csv files into one. For each, transpose and add a num_agents column. + fpath_results_all_agents = os.path.join(results_dir, 'aggregated_results_all_agents.csv') + # If this already exists, delete it. + if os.path.exists(fpath_results_all_agents): + os.remove(fpath_results_all_agents) + # Create a new dataframe. + df_all_agents = pd.concat(df_num_agents_l) + # This df treats the method names as index. We want them as a column with "method" as the column name. + df_all_agents.reset_index(inplace=True) + df_all_agents.rename(columns={'index': 'method'}, inplace=True) + + df_all_agents.to_csv(fpath_results_all_agents, index=False) + print(f"Results saved to file://{fpath_results_all_agents}") + + return analyzed_results diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiments.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiments.py new file mode 100644 index 0000000000000000000000000000000000000000..b4f1ad6717b83d1f83b239092c615abecb015974 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/experiments/experiments.py @@ -0,0 +1,476 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import os +import pickle +import yaml +import json +from datetime import datetime +import time +from enum import Enum +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List +import concurrent.futures + +# Project imports. +from torch_robotics.robots import * +from torch_robotics.environments.env_highways_2d import EnvHighways2D +from torch_robotics.environments.env_empty_2d import EnvEmpty2D +from torch_robotics.environments.env_conveyor_2d import EnvConveyor2D +from torch_robotics.environments.env_drop_region_2d import EnvDropRegion2D +from mmd.config.mmd_experiment_configs import get_planning_problem +from mmd.common.multi_agent_utils import get_start_goal_pos_batch_random_in_env +from torch_robotics.torch_utils.seed import fix_random_seed + + +class MultiAgentPlanningExperimentConfig: + seed: int = 18 + # The time string. The result directory is automatically created based on the config and this string. + time_str: str = None + # The instance. This dictates the map, starts and goals, and tile skeletons. + instance_name: str = None + # Numbers of agents to try. + num_agents_l: List[int] = [] + # Start and goal placement scheme. + # Stagger start times. t_init[0] = 0, t_init[1] = dt * 1, ... t_init[i] = dt * i. + stagger_start_time_dt = 0 + # Multi-agent planner classes to try. + multi_agent_planner_class_l: List[str] = [] + # Single agent planner class. + single_agent_planner_class: str = None + # Runtime limit. In seconds. + runtime_limit = params.runtime_limit + # Number of trials per num agents + planner combination. + num_trials_per_combination = 1 + # Whether to render animation or not. + render_animation = False + + def get_single_trial_configs_from_experiment_config(self): + cls_map = { + "EnvEmpty2DRobotPlanarDiskRandom": EnvEmpty2D, + "EnvHighways2DRobotPlanarDiskRandom": EnvHighways2D, + "EnvConveyor2DRobotPlanarDiskRandom": EnvConveyor2D, + "EnvDropRegion2DRobotPlanarDiskRandom": EnvDropRegion2D, + } + single_trial_configs = [] + for num_agents in self.num_agents_l: + fix_random_seed(self.seed) + # Get num_trials_per_combination x (starts and goals) that instances with different planners will use. + start_state_pos_l_l, goal_state_pos_l_l, global_model_ids_l_l, agent_skeleton_l_l = [], [], [], [] + env_cls = cls_map[self.instance_name] + tensor_args = { + 'device': 'cuda', + 'dtype': torch.float32, + } + start_st_pos_l_l_batch, goal_st_pos_l_l_batch = get_start_goal_pos_batch_random_in_env( + num_agents, + self.num_trials_per_combination, + env_cls, + tensor_args, + margin= 0.2, + # obstacle_margin= 0.11 # Empty & Highways + obstacle_margin=0.2 # Conveyor & DropRegion + ) + for _ in range(self.num_trials_per_combination): + start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l = get_planning_problem( + self.instance_name, num_agents) + start_state_pos_l_l.append(start_state_pos_l) + goal_state_pos_l_l.append(goal_state_pos_l) + global_model_ids_l_l.append(global_model_ids) + agent_skeleton_l_l.append(agent_skeleton_l) + + # # Stack the start and goal positions into batched tensors + # start_state_pos_tensor = torch.stack([torch.stack(pos_list) for pos_list in start_state_pos_l_l]) + # goal_state_pos_tensor = torch.stack([torch.stack(pos_list) for pos_list in goal_state_pos_l_l]) + # start_state_pos_batch_tensor = torch.stack([torch.stack(pos_list) for pos_list in start_st_pos_l_l_batch]) + # goal_state_pos_batch_tensor = torch.stack([torch.stack(pos_list) for pos_list in goal_st_pos_l_l_batch]) + # chk_start = torch.allclose(start_state_pos_tensor, start_state_pos_batch_tensor) + # chk_goal = torch.allclose(goal_state_pos_tensor, goal_state_pos_batch_tensor) + # import pdb; pdb.set_trace() + start_state_pos_l_l = start_st_pos_l_l_batch + goal_state_pos_l_l = goal_st_pos_l_l_batch + + # Create the single trial configs. + for multi_agent_planner_class in self.multi_agent_planner_class_l: + for trial_number in range(self.num_trials_per_combination): + single_trial_config = MultiAgentPlanningSingleTrialConfig() + single_trial_config.time_str = self.time_str + single_trial_config.trial_number = trial_number + single_trial_config.num_agents = num_agents + single_trial_config.stagger_start_time_dt = self.stagger_start_time_dt + single_trial_config.multi_agent_planner_class = multi_agent_planner_class + single_trial_config.single_agent_planner_class = self.single_agent_planner_class + single_trial_config.instance_name = self.instance_name + single_trial_config.runtime_limit = self.runtime_limit + single_trial_config.render_animation = self.render_animation + single_trial_config.start_state_pos_l, single_trial_config.goal_state_pos_l, \ + single_trial_config.global_model_ids, single_trial_config.agent_skeleton_l = \ + start_state_pos_l_l[trial_number], goal_state_pos_l_l[trial_number], \ + global_model_ids_l_l[trial_number], agent_skeleton_l_l[trial_number] + single_trial_configs.append(single_trial_config) + return single_trial_configs + + def get_single_trial_configs_from_experiment_config_bak(self): + single_trial_configs = [] + for num_agents in self.num_agents_l: + # Get num_trials_per_combination x (starts and goals) that instances with different planners will use. + start_state_pos_l_l, goal_state_pos_l_l, global_model_ids_l_l, agent_skeleton_l_l = [], [], [], [] + for _ in range(self.num_trials_per_combination): + start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l = get_planning_problem( + self.instance_name, num_agents) + start_state_pos_l_l.append(start_state_pos_l) + goal_state_pos_l_l.append(goal_state_pos_l) + global_model_ids_l_l.append(global_model_ids) + agent_skeleton_l_l.append(agent_skeleton_l) + # Create the single trial configs. + for multi_agent_planner_class in self.multi_agent_planner_class_l: + for trial_number in range(self.num_trials_per_combination): + single_trial_config = MultiAgentPlanningSingleTrialConfig() + single_trial_config.time_str = self.time_str + single_trial_config.trial_number = trial_number + single_trial_config.num_agents = num_agents + single_trial_config.stagger_start_time_dt = self.stagger_start_time_dt + single_trial_config.multi_agent_planner_class = multi_agent_planner_class + single_trial_config.single_agent_planner_class = self.single_agent_planner_class + single_trial_config.instance_name = self.instance_name + single_trial_config.runtime_limit = self.runtime_limit + single_trial_config.render_animation = self.render_animation + single_trial_config.start_state_pos_l, single_trial_config.goal_state_pos_l, \ + single_trial_config.global_model_ids, single_trial_config.agent_skeleton_l = \ + start_state_pos_l_l[trial_number], goal_state_pos_l_l[trial_number], \ + global_model_ids_l_l[trial_number], agent_skeleton_l_l[trial_number] + single_trial_configs.append(single_trial_config) + return single_trial_configs + + def save(self): + # Save the config. + results_dir = get_result_dir_from_time_str(self.time_str) + Path(results_dir).mkdir(parents=True, exist_ok=True) + with open(os.path.join(results_dir, 'experiment_config.pkl'), 'wb') as f: + pickle.dump(self, f) + + def __str__(self): + print_str = f""" + Experiment Config: + Time Str: {self.time_str} + Num Agents: {self.num_agents_l} + Stagger Start Time: {self.stagger_start_time_dt} + Multi-agent Planner Classes: {self.multi_agent_planner_class_l} + Single Agent Planner: {self.single_agent_planner_class} + Runtime Limit: {self.runtime_limit} + Num Trials Per Combination: {self.num_trials_per_combination} + Instance: {self.instance_name} + """ + return print_str + + +class MultiAgentPlanningSingleTrialConfig: + # The associated time str and trial number for saving purposes. + time_str = None + trial_number = 0 + # Runtime limit. In seconds. + runtime_limit = 10 + # Num agents. + num_agents = 1 + # Start and goal placement scheme. + # Stagger start times. t_init[0] = 0, t_init[1] = dt * 1, ... t_init[i] = dt * i. + stagger_start_time_dt = 0 + # Multi-agent planner class. Common options are CBS, ECBS, and PrioritizedPlanning. + multi_agent_planner_class = "" + # Single agent planner class. Common options are MPD and MPDEnsemble. + single_agent_planner_class = "" + # Whether to render animation or not. + render_animation = False + # The environment(s) to use for the multi-agent planning. + instance_name = "" + # The starts and goals, models ids, and skeletons. + start_state_pos_l = [] + goal_state_pos_l = [] + global_model_ids = [] + # The model coord skeleton for each agent. + agent_skeleton_l = [] + # Discrete time step + dt = 0.1 # seconds + + def save(self, results_dir: str): + # Save the config. + Path(results_dir).mkdir(parents=True, exist_ok=True) + # with open(os.path.join(results_dir, 'config.pkl'), 'wb') as f: + # pickle.dump(self, f) + + ## Save as yaml file + # Convert config to dictionary + config_dict = { + 'time_str': self.time_str, + 'trial_number': self.trial_number, + 'runtime_limit': self.runtime_limit, + 'num_agents': self.num_agents, + 'stagger_start_time_dt': self.stagger_start_time_dt, + 'multi_agent_planner_class': self.multi_agent_planner_class, + 'single_agent_planner_class': self.single_agent_planner_class, + 'render_animation': self.render_animation, + 'instance_name': self.instance_name, + 'start_state_pos_l': [pos.tolist() if torch.is_tensor(pos) else pos for pos in self.start_state_pos_l], + 'goal_state_pos_l': [pos.tolist() if torch.is_tensor(pos) else pos for pos in self.goal_state_pos_l], + 'global_model_ids': self.global_model_ids, + 'agent_skeleton_l': self.agent_skeleton_l, + 'dt': self.dt + } + + with open(os.path.join(results_dir, 'config.yaml'), 'w') as f: + yaml.dump(config_dict, f, default_flow_style=False) + + def __str__(self): + print_str = f""" + Trial Config: + Time Str: {self.time_str} + Trial Number: {self.trial_number} + Num Agents: {self.num_agents} + Dt: {self.dt} + Stagger Start Time: {self.stagger_start_time_dt} + Multi-agent Planner Class: {self.multi_agent_planner_class} + Single Agent Planner: {self.single_agent_planner_class} + Instance: {self.instance_name} + """ + return print_str + + +class TrialSuccessStatus(Enum): + UNKNOWN = -1 + SUCCESS = 0 + FAIL_RUNTIME_LIMIT = 1 + FAIL_COLLISION_AGENTS = 2 + FAIL_NO_SOLUTION = 3 + PARTIAL_SUCCESS = 4 + + def __bool__(self): + return self == TrialSuccessStatus.SUCCESS or self == TrialSuccessStatus.PARTIAL_SUCCESS + + +class MultiAgentPlanningSingleTrialResult: + # The associated experiment config. + trial_config: MultiAgentPlanningSingleTrialConfig = None + # The agent paths. Each entry is of shape (H, 4). + agent_path_l: List[torch.Tensor] = [] + # CT nodes expanded. + num_ct_expansions: int = 0 + # Success. Reasons to fail: runtime limit, any collision of any agent in the solution, failing to find a solution. + success_status: TrialSuccessStatus = TrialSuccessStatus.UNKNOWN + # Number of agent pairs in collision. + num_collisions_in_solution: int = 0 + # Our metric for determining how well a path is adhering to the data. Each agent is a yes/no for single maps + # or a fraction for ensemble. This metric is the average adherence to the data. + data_adherence: float = 0.0 + # Planning time. + planning_time = 0.0 + # Path length averaged over all agents. + path_length_per_agent = 0.0 + # Path smoothness. + mean_path_acceleration_per_agent = 0.0 + # Max velocities + max_velocity_l = [] + # Mean velocities + mean_velocity_l = [] + # Path Start and Goal. + start_state_pos_l = [] + goal_state_pos_l = [] + # The environment(s) to use for the multi-agent planning. + global_model_ids = [] + # The model coord skeleton for each agent. + agent_skeleton_l = [] + + def save(self, results_dir: str): + # Save the results. + Path(results_dir).mkdir(parents=True, exist_ok=True) + with open(os.path.join(results_dir, 'results.pkl'), 'wb') as f: + pickle.dump(self, f) + + # Save a human-readable version. + with open(os.path.join(results_dir, 'results.txt'), 'w') as f: + f.write(str(self)) + + def __str__(self): + print_str = f""" + Trial Config Summary: + Method: {self.trial_config.multi_agent_planner_class} + Num Agents: {self.trial_config.num_agents} + Instance: {self.trial_config.instance_name} + Stagger Start Time: {self.trial_config.stagger_start_time_dt} + Single Agent Planner: {self.trial_config.single_agent_planner_class} + Planning Problem: + start_state_pos_l: {self.start_state_pos_l} + goal_state_pos_l: {self.goal_state_pos_l} + global_model_ids: {self.global_model_ids} + agent_skeleton_l: {self.agent_skeleton_l} + Trial Results: + success_status: {self.success_status} + num_collisions_in_solution: {self.num_collisions_in_solution} + data_adherence: {self.data_adherence} + planning_time: {self.planning_time} + path_length_per_agent: {self.path_length_per_agent} + mean_path_acceleration_per_agent: {self.mean_path_acceleration_per_agent} + mean_velocity_all_agents: {self.mean_velocity_l} + max_velocity_all_agents: {self.max_velocity_l} + num_ct_expansions: {self.num_ct_expansions} + """ + return print_str + + def save_json(self, results_dir: str): + # Save the results as a JSON file. + Path(results_dir).mkdir(parents=True, exist_ok=True) + with open(os.path.join(results_dir, 'results.json'), 'w') as f: + # Create a clean dictionary with serializable data + result_dict = {} + + # Handle agent paths specially + if hasattr(self, 'agent_path_l') and self.agent_path_l: + if isinstance(self.agent_path_l, list): + result_dict['agent_path_l'] = [path.tolist() if isinstance(path, torch.Tensor) else path for path in self.agent_path_l] + else: + result_dict['agent_path_l'] = self.agent_path_l + + # Handle other common attributes + for attr_name in ['num_ct_expansions', 'num_collisions_in_solution', 'data_adherence', + 'planning_time', 'path_length_per_agent', 'mean_path_acceleration_per_agent']: + if hasattr(self, attr_name): + attr_val = getattr(self, attr_name) + # Convert numpy types to native Python types + if hasattr(attr_val, 'item') and callable(getattr(attr_val, 'item')): + attr_val = attr_val.item() + result_dict[attr_name] = attr_val + + # Handle success status + if hasattr(self, 'success_status'): + result_dict['success_status'] = self.success_status.name if self.success_status else "UNKNOWN" + + # Handle lists of values + for list_attr in ['max_velocity_l', 'mean_velocity_l', 'global_model_ids', 'agent_skeleton_l']: + if hasattr(self, list_attr) and getattr(self, list_attr): + attr_val = getattr(self, list_attr) + result_dict[list_attr] = [x.tolist() if hasattr(x, 'tolist') and callable(getattr(x, 'tolist')) + else (x.item() if hasattr(x, 'item') and callable(getattr(x, 'item')) else x) + for x in attr_val] + + # Handle position lists + for pos_attr in ['start_state_pos_l', 'goal_state_pos_l']: + result_dict[pos_attr] = getattr(self, pos_attr) + # for pos_attr in ['start_state_pos_l', 'goal_state_pos_l']: + # if hasattr(self, pos_attr) and getattr(self, pos_attr): + # pos_list = getattr(self, pos_attr) + # result_dict[pos_attr] = [pos.tolist() if hasattr(pos, 'tolist') and callable(getattr(pos, 'tolist')) + # else (pos.item() if hasattr(pos, 'item') and callable(getattr(pos, 'item')) else pos) + # for pos in pos_list] + + # Serialize to JSON + json.dump(result_dict, f, indent=4) + + +# Utility functions. +def get_result_dir_from_time_str(time_str: str): + # Create a results directory. + results_dir = os.path.join('./results', f'{time_str}') + results_dir = os.path.abspath(results_dir) + return results_dir + + +def bak_get_results_dir_from_experiment_config(experiment_config: MultiAgentPlanningExperimentConfig): + results_dir = get_result_dir_from_time_str(experiment_config.time_str) + # Create a results directory. + results_dir = os.path.join(results_dir, + f'instance_name___{experiment_config.instance_name}') + results_dir = os.path.abspath(results_dir) + return results_dir + +def get_results_dir_from_experiment_config(experiment_config: MultiAgentPlanningExperimentConfig): + results_dir = get_result_dir_from_time_str(experiment_config.time_str) + par = os.path.dirname(results_dir) + result_base = os.path.basename(results_dir) + # Create a results directory. + results_dir = os.path.join( + par, + f"{experiment_config.instance_name}", + result_base, + ) + results_dir = os.path.abspath(results_dir) + return results_dir + +def get_result_dir_from_trial_config(trial_config: MultiAgentPlanningSingleTrialConfig, + time_str: str = None, + trial_number: int = 0): + # If no time string is provided, use the current time. + if time_str is None: + raise ValueError("Time string must be provided.") + results_dir = get_result_dir_from_time_str(time_str) + par = os.path.dirname(results_dir) + result_base = os.path.basename(results_dir) + # Create a results directory. + results_dir = os.path.join( + par, + f"{trial_config.instance_name}", + result_base, + # f'instance_name___{trial_config.instance_name}', + f'num_agents___{trial_config.num_agents}', + f'planner___{trial_config.multi_agent_planner_class}', + f'single_agent_planner___{trial_config.single_agent_planner_class}', + str(trial_number) + ) + results_dir = os.path.abspath(results_dir) + return results_dir + +def get_result_dir_pcdiff( + trial_config: MultiAgentPlanningSingleTrialConfig, + planner_cfg: dict, + base_dir: str = './', + time_str: str = None, + trial_number: int = 0 +): + # If no time string is provided, use the current time. + if time_str is None: + raise ValueError("Time string must be provided.") + # results_dir = get_result_dir_from_time_str(time_str) + method = planner_cfg['single_agent']['planner_alg'] + if method in ['coupling', 'coupling_ps', 'pcd']: + cost_fn = planner_cfg['single_agent']['cost_func_key'] + else: + cost_fn = None + + results_dir = os.path.join( + base_dir, 'results', + f'{trial_config.instance_name}', + f'agent___{trial_config.num_agents}', + f'method___{planner_cfg["single_agent"]["planner_alg"]}', + f'cost___{str(cost_fn)}', + f'{time_str}', + str(trial_number) + ) + results_dir = os.path.abspath(results_dir) + return results_dir \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/multi_agent_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/multi_agent_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a2775f57140aad950d4669775b9ee39927e3016a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/multi_agent_utils.py @@ -0,0 +1,345 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import torch +from typing import List +from copy import deepcopy +import numpy as np +# Project imports. + + +def is_multi_agent_state_valid(reference_robot, + reference_task, + state_pos_l: List[torch.Tensor] + ) -> bool: + """ + Check if a state is valid. + :param reference_robot: Reference robot. + :param reference_task: Reference task. + :param state_pos_l: State position list of tensors. + :return: True if the state is valid, False otherwise. + """ + state_pos = torch.stack(state_pos_l) + collision_matrix, _ = reference_robot.check_rr_collisions(state_pos) + if torch.any(collision_matrix): + return False + world_collisions = reference_task.compute_collision(state_pos) + if torch.any(world_collisions): + return False + return True + + +def is_multi_agent_start_goal_states_valid(reference_robot, + reference_task, + start_state_pos_l: List[torch.Tensor], + goal_state_pos_l: List[torch.Tensor], + is_enforce_min_dist: bool = True + ) -> bool: + if is_enforce_min_dist: + for i in range(len(start_state_pos_l)): + for j in range(i + 1, len(goal_state_pos_l)): + # Start-start. + if torch.norm(start_state_pos_l[i] - start_state_pos_l[j]) < 0.15: + print('Start-start failed with i:', i, 'j:', j, " distance:", + torch.norm(start_state_pos_l[i] - start_state_pos_l[j])) + return False + # Goal-goal. + if torch.norm(goal_state_pos_l[i] - goal_state_pos_l[j]) < 0.15: + print('Goal-goal failed with i:', i, 'j:', j, " distance:", + torch.norm(goal_state_pos_l[i] - goal_state_pos_l[j])) + return False + + # Check for collisions with the world and between robots. + starts_collision_matrix, _ = reference_robot.check_rr_collisions(torch.stack(start_state_pos_l)) + if torch.any(starts_collision_matrix): + print('Start states are in collision.') + print("The collision matrix is", starts_collision_matrix) + return False + goals_collision_matrix, _ = reference_robot.check_rr_collisions(torch.stack(goal_state_pos_l)) + if torch.any(goals_collision_matrix): + print('Goal states are in collision.') + print("The collision matrix is", goals_collision_matrix) + return False + starts_world_collisions = reference_task.compute_collision(torch.stack(start_state_pos_l)) + if torch.any(starts_world_collisions): + print('Start states are in collision with the world.') + print("The collision matrix is", starts_world_collisions) + return False + goals_world_collisions = reference_task.compute_collision(torch.stack(goal_state_pos_l)) + if torch.any(goals_world_collisions): + print('Goal states are in collision with the world.') + print("The collision matrix is", goals_world_collisions) + return False + return True + + +def compute_collision_intensity(trajs_l, reference_robot, reference_task): + """ + Compute the collision intensity for the multi-agent trajectory. + Intensity is defined as the fraction of trajectory timesteps that are in collision. + :param trajs: List of trajectories. Each is a tensor of shape (H, n_dims). + """ + n_agents = len(trajs_l) + n_timesteps = trajs_l[0].shape[0] + # Make sure all trajectories are of the same length. + assert all(traj.shape[0] == n_timesteps for traj in trajs_l) + collision_intensity = torch.zeros(n_timesteps) + for t in range(n_timesteps): + state_pos_l = [trajs_l[agent_id][t] for agent_id in range(n_agents)] + step_valid = is_multi_agent_state_valid(reference_robot, reference_task, state_pos_l) + if not step_valid: + print("Collision at timestep", t) + collision_intensity[t] = 1 + print("num steps colliding", collision_intensity.sum()) + collision_intensity = collision_intensity.sum() / n_timesteps + # collision_intensity /= n_agents * (n_agents - 1) / 2 + return collision_intensity + + +def global_pad_paths_old(path_l: List[torch.Tensor], + start_time_l: List[int], + ) -> List[torch.Tensor]: + # Agents starting at time later than 0 will get repeated start state. + # Agents ending at time earlier than the max_t will get repeated last state. + # Find the maximum t among all paths. + path_l = deepcopy(path_l) + # Check if there is anything there. + if len(path_l) == 0: + return path_l + max_t = max([len(path) + start_time_l[agent_id] for agent_id, path in enumerate(path_l)]) + for agent_id, agent_path in enumerate(path_l): + if len(agent_path) + start_time_l[agent_id] < max_t: + # Repeat the last state. + agent_path = torch.cat( + [agent_path, agent_path[-1].repeat(max_t - len(agent_path) - start_time_l[agent_id], 1)] + ) + if start_time_l[agent_id] > 0: + # Repeat the start state. + agent_path = torch.cat( + [agent_path[0].repeat(start_time_l[agent_id], 1), agent_path] + ) + path_l[agent_id] = agent_path + return path_l + +def global_pad_paths(path_l: List[torch.Tensor], + start_time_l: List[int], + ) -> List[torch.Tensor]: + # Agents starting at time later than 0 will get repeated start state. + # Agents ending at time earlier than the max_t will get repeated last state. + # Find the maximum t among all paths. + path_l = deepcopy(path_l) + # Check if there is anything there. + if len(path_l) == 0: + return path_l + + n_agents = len(start_time_l) + grp_sz = len(path_l) // n_agents + ts = [] + for agent_id in range(n_agents): + for path in path_l[agent_id * grp_sz : (agent_id + 1) * grp_sz]: + ts.append(len(path) + start_time_l[agent_id]) + + if torch.all(torch.as_tensor(ts) - ts[0] == 0): + # All paths are of the same length, no need to pad. + return path_l + + max_t = max(ts) + for agent_id in range(n_agents): + for idx, agent_path in enumerate(path_l[agent_id * grp_sz : (agent_id + 1) * grp_sz]): + global_idx = agent_id * grp_sz + idx + if len(agent_path) + start_time_l[agent_id] < max_t: + # Repeat the last state. + agent_path = torch.cat( + [agent_path, agent_path[-1].repeat(max_t - len(agent_path) - start_time_l[agent_id], 1)] + ) + if start_time_l[agent_id] > 0: + # Repeat the start state. + agent_path = torch.cat( + [agent_path[0].repeat(start_time_l[agent_id], 1), agent_path] + ) + path_l[global_idx] = agent_path + + return path_l + + +def get_start_goal_pos_circle(num_agents: int, radius=0.8, + tensor_args={ + 'dtype': torch.float32, + 'device': 'cuda' + } + ): + # These are all in the local tile frame. + start_l = [torch.tensor([radius * np.cos(2 * torch.pi * i / num_agents), + radius * np.sin(2 * torch.pi * i / num_agents)], + **tensor_args) for i in range(num_agents)] + goal_l = [torch.tensor([radius * np.cos(2 * torch.pi * i / num_agents + torch.pi), + radius * np.sin(2 * torch.pi * i / num_agents + torch.pi)], + **tensor_args) for i in range(num_agents)] + return start_l, goal_l + + +def get_start_goal_pos_boundary(num_agents: int, dist=0.87): + # These are all in the local tile frame. + start_l = [torch.tensor([0.8 * np.cos(2 * torch.pi * i / num_agents), + 0.8 * np.sin(2 * torch.pi * i / num_agents)], + dtype=torch.float32, device='cuda') for i in range(num_agents)] + + # Snap the abs max x or y value to either -1 or 1. + for i in range(num_agents): + if abs(start_l[i][0]) > abs(start_l[i][1]): + start_l[i][0] = torch.sign(start_l[i][0]) * dist + else: + start_l[i][1] = torch.sign(start_l[i][1]) * dist + + goal_l = [torch.tensor([start_l[i][0] if abs(start_l[i][0]) < abs(start_l[i][1]) else -start_l[i][0], + start_l[i][1] if abs(start_l[i][1]) < abs(start_l[i][0]) else -start_l[i][1]], + dtype=torch.float32, device='cuda') for i in range(num_agents)] + return start_l, goal_l + + +def get_state_pos_column(num_agents: int, x_pos: float): + # These are all in the local tile frame. + state_l = [torch.tensor([x_pos, 0.8 * (1 - 2 * i / num_agents)], + dtype=torch.float32, device='cuda') for i in range(num_agents)] + return state_l + + +def get_start_goal_pos_random_in_env(num_agents, + env_class, + tensor_args, + margin=0.15, + obstacle_margin=0.16): # 0.08 + # In this map, agents can be place anywhere with x y in [-0.9, 0.9]. As long as they are not too close to + # each other. + start_state_pos_l = [] + goal_state_pos_l = [] + + # Get the obstacles in this map. + env = env_class(tensor_args=tensor_args) + # Get a distance field. + env_sdf = env.grid_map_sdf_obj_fixed + + # Get the starts. Get all of them together and then check if they are too close. + for i in [0, 1]: + # Start building the random state. + random_state = None + while True: + random_state = torch.rand(1, 2) * 1.9 - 0.95 + random_state = random_state.to(**tensor_args) + # Check if the state is not in an obstacle. + if torch.all(env_sdf(random_state) > obstacle_margin): + break + # Now add more point. For each point, check if it is not too close to the previous points and not in an + # obstacle. + state_b = random_state + for _ in range(num_agents - 1): + while True: + new_state = torch.rand(1, 2) * 1.9 - 0.95 + new_state = new_state.to(**tensor_args) + pairwise_distances = torch.sqrt(torch.sum((new_state - state_b) ** 2, dim=1)) + # Check if the state is not in an obstacle. + if torch.all(env_sdf(new_state) > obstacle_margin) and torch.all(pairwise_distances > margin): + break + state_b = torch.cat([state_b, new_state], dim=0) + if i == 0: + start_state_pos_l = [s for s in state_b] + else: + goal_state_pos_l = [s for s in state_b] + + return start_state_pos_l, goal_state_pos_l + + +def get_start_goal_pos_batch_random_in_env(num_agents, + batch_size, + env_class, + tensor_args, + margin=0.15, + obstacle_margin=0.16): # 0.08 + # A shared instance set, when one is requested, replaces the random draw, so every + # method plans the SAME problems. Without it each runner drew its own endpoints and + # a row of the table compared planners on different instances. See + # data_prep/shared_instances.py; inert unless + # SHARED_INSTANCES_NPZ and SHARED_INSTANCES_KEY are both set. + import sys as _sys, os as _os + if _os.path.join(_os.environ['MRMP_ROOT'], 'data_prep') not in _sys.path: + _sys.path.insert(0, _os.path.join(_os.environ['MRMP_ROOT'], 'data_prep')) + import shared_instances as _SI + _fixed = _SI.as_batch(num_agents, batch_size, tensor_args) + if _fixed is not None: + print(f"[shared-instances] {_os.environ['SHARED_INSTANCES_KEY']}: {batch_size} " + f"fixed instances loaded (random draw bypassed)", flush=True) + return _fixed + # The sampling box below was hardcoded to +/-0.95, which assumes the shipped robot + # radius of 0.05. Under ROBOT_R=0.1 (the difficulty axis for obstacle-free maps) a + # disk centred at 0.95 pokes outside the [-1, 1] workspace, so draws near the edge + # are rejected by reference_task.compute_collision -> 'Start states are in collision + # with the world'. min() keeps r=0.05 at exactly 0.95, byte-identical to shipped. + from mmd.config import MMDParams as _P + _half = min(0.95, 1.0 - float(_P.robot_planar_disk_radius)) + _span = 2.0 * _half + # In this map, agents can be place anywhere with x y in [-0.9, 0.9]. As long as they are not too close to + # each other. + start_state_pos_l = [] + goal_state_pos_l = [] + + # Get the obstacles in this map. + env = env_class(tensor_args=tensor_args) + # Get a distance field. + env_sdf = env.grid_map_sdf_obj_fixed + + start_batch = [] + goal_batch = [] + + # Get the starts. Get all of them together and then check if they are too close. + for b in range(batch_size): + for i in [0, 1]: + # Start building the random state. + random_state = None + while True: + random_state = torch.rand(1, 2) * _span - _half + random_state = random_state.to(**tensor_args) + # Check if the state is not in an obstacle. + if torch.all(env_sdf(random_state) > obstacle_margin): + break + # Now add more point. For each point, check if it is not too close to the previous points and not in an + # obstacle. + state_b = random_state + for _ in range(num_agents - 1): + while True: + new_state = torch.rand(1, 2) * _span - _half + new_state = new_state.to(**tensor_args) + pairwise_distances = torch.sqrt(torch.sum((new_state - state_b) ** 2, dim=1)) + # Check if the state is not in an obstacle. + if torch.all(env_sdf(new_state) > obstacle_margin) and torch.all(pairwise_distances > margin): + break + state_b = torch.cat([state_b, new_state], dim=0) + if i == 0: + start_state_pos_l = [s for s in state_b] + else: + goal_state_pos_l = [s for s in state_b] + #end for i in [0, 1]: + start_batch.append(start_state_pos_l) + goal_batch.append(goal_state_pos_l) + + return start_batch, goal_batch \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/pretty_print.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/pretty_print.py new file mode 100644 index 0000000000000000000000000000000000000000..c734610c0847ac9dd1f4aef1e5bb2b8eb547f449 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/pretty_print.py @@ -0,0 +1,32 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + +RED = '\033[91m' +CYAN = '\033[96m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +PURPLE = '\033[95m' +RESET = '\033[0m' diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/trajectory_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/trajectory_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e98f864a3ea807f272f93f50b7490c23e93a4396 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/common/trajectory_utils.py @@ -0,0 +1,93 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import torch +from scipy.signal import savgol_filter +# Project imports. +from mmd.config.mmd_params import MMDParams as params + + +def smooth_trajs(trajs, window_size=10, poly_order=2): + """ + Smooth the trajectories. + trajs: List of trajectories. Each is a tensor of shape (H, q_dim). + """ + if isinstance(trajs, torch.Tensor): + assert trajs.dim() == 3 # (B, H, q_dim) + smoothed_trajs = savgol_filter(trajs.cpu().numpy(), window_size, poly_order, axis=1) + return torch.tensor(smoothed_trajs).to(trajs.device) + + smoothed_trajs = [] + for traj in trajs: + traj = traj.cpu().numpy() + window_size_traj = min(window_size, traj.shape[0]) + if window_size_traj <= 2: + smoothed_trajs.append(torch.tensor(traj).to(**params.tensor_args)) + continue + smoothed_traj = savgol_filter(traj, window_size_traj, poly_order, axis=0) + smoothed_traj = torch.tensor(smoothed_traj).to('cuda') + smoothed_trajs.append(smoothed_traj) + return smoothed_trajs + + +def densify_trajs(trajs, n_points_interp=10): + """ + Densify the trajectories. + :param trajs: List of trajectories. Each is a tensor of shape (H, n_dims). + :param n_points_interp: Number of points to interpolate between each pair of points. + """ + densified_trajs = [] + for traj in trajs: + densified_traj = [] + for i in range(traj.shape[0] - 1): + densified_traj.append(traj[i]) + for j in range(1, n_points_interp): + densified_traj.append(traj[i] + j * (traj[i + 1] - traj[i]) / n_points_interp) + densified_traj.append(traj[-1]) + densified_traj = torch.stack(densified_traj) + densified_trajs.append(densified_traj) + return densified_trajs + + +def are_points_closer_than_margin(points_batch, margin): + """ + Check proximity between points. (Sometimes this is for Robot-robot collisions). + Args: + points_batch: (..., n_points, q_dim) + Returns: + collisions: (..., n_points, n_points), True if there is the pair of points is closer than the margin. + """ + # Check collisions between robots. + assert points_batch.dim() >= 2 + points_batch1 = points_batch.unsqueeze(-2) + points_batch2 = points_batch.unsqueeze(-3) + # Pairwise distances between robots. + robot_dq = points_batch1 - points_batch2 + # (..., n_robots, n_robots) + robot_dq_norm = torch.norm(robot_dq, dim=-1) + # (..., n_robots, n_robots) + collisions = robot_dq_norm < margin + # Set the trace to be False. + collisions = collisions & ~torch.eye(collisions.shape[-1], device=collisions.device, dtype=collisions.dtype) + return collisions diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..22ae2245f90d37f630ad8cb75ca56f199e6c0f02 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/__init__.py @@ -0,0 +1,2 @@ +from .mmd_params import * +from .mmd_experiment_configs import get_planning_problem diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_experiment_configs.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_experiment_configs.py new file mode 100644 index 0000000000000000000000000000000000000000..08b6b4c7f137dfb9fb4b1fb4d6cf5ad35ba77234 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_experiment_configs.py @@ -0,0 +1,286 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +from abc import ABC, abstractmethod +import numpy as np +import torch + +# Project imports. +from mmd.config.mmd_params import MMDParams as params +from mmd.common.multi_agent_utils import * +from torch_robotics.environments import * +from torch_robotics.environments.env_highways_2d import EnvHighways2D + + +def get_planning_problem(planning_problem_class_name: str, + num_agents: int): + # Get the planning problem. + planning_problem_class = globals()[planning_problem_class_name] + planning_problem = planning_problem_class() + return planning_problem.get_planning_problem(num_agents) + + +class MMDPlanningProblemConfig(ABC): + # Some parameters. + name = "" + + @abstractmethod + def get_planning_problem(self, num_agents): + pass + + +class EnvEmpty2DRobotPlanarDiskCircle(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvEmpty2D_RobotPlanarDisk_Circle" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_circle(num_agents, radius=0.8) + global_model_ids = [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvEmpty2DRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvEmpty2D_RobotPlanarDisk_Random" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvEmpty2D, + tensor_args=params.tensor_args, + # margin=0.15) + margin=0.2, + obstacle_margin=0.11 + ) + global_model_ids = [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvHighways2DRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvHighways2D_RobotPlanarDisk_Random" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvHighways2D, + tensor_args=params.tensor_args, + # margin=0.15) + margin=0.2, + obstacle_margin=0.11 + ) + global_model_ids = [['EnvHighways2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvEmpty2DRobotPlanarDiskBoundary(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvEmpty2D_RobotPlanarDisk_Circle" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_boundary(num_agents, dist=0.87) + global_model_ids = [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvConveyor2DRobotPlanarDiskBoundary(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvConveyor2D_RobotPlanarDisk_Boundary" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_boundary(num_agents, dist=0.87) + global_model_ids = [['EnvConveyor2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvConveyor2DRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvConveyor2D_RobotPlanarDisk_Random" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvConveyor2D, + tensor_args=params.tensor_args, + margin=0.15) + global_model_ids = [['EnvConveyor2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvDropRegion2DRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvDropRegion2D_RobotPlanarDisk_Random" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvDropRegion2D, + tensor_args=params.tensor_args, + margin=0.15) + global_model_ids = [['EnvDropRegion2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvHighways2DRobotPlanarDiskSmallCircle(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvHighways2D_RobotPlanarDisk_SmallCircle" + + def get_planning_problem(self, num_agents): + + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_circle(min(num_agents, 10), radius=0.45) + if num_agents > 10: + more_start_state_pos_l, more_goal_state_pos_l = get_start_goal_pos_circle(num_agents - 10, radius=0.65) + start_state_pos_l += more_start_state_pos_l + goal_state_pos_l += more_goal_state_pos_l + + global_model_ids = [['EnvHighways2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvDropRegion2DRobotPlanarDiskBoundary(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvDropRegion2D_RobotPlanarDisk_Boundary" + + def get_planning_problem(self, num_agents): + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_boundary(num_agents) + global_model_ids = [['EnvDropRegion2D-RobotPlanarDisk']] + agent_skeleton_l = [[[0, 0]]] * num_agents + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvTestTwoByTwoRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvTestTwoByTwo_RobotPlanarDisk_Random" + self.start_state_pos_l, self.goal_state_pos_l = get_start_goal_pos_circle(10, radius=0.45) + more_start_state_pos_l, more_goal_state_pos_l = get_start_goal_pos_circle(20, radius=0.65) + self.start_state_pos_l += more_start_state_pos_l + self.goal_state_pos_l += more_goal_state_pos_l + + self.global_model_ids = [['EnvEmptyNoWait2D-RobotPlanarDisk', 'EnvConveyor2D-RobotPlanarDisk'], + ['EnvHighways2D-RobotPlanarDisk', 'EnvHighways2D-RobotPlanarDisk']] + self.agent_skeleton_options = [[[0, 0], [0, 1], [1, 1]], + [[0, 0], [1, 0], [1, 1]], + [[1, 0], [0, 0], [1, 0]], + [[0, 0], [0, 1], [1, 1]], + [[0, 0], [0, 1], [0, 0]], + [[1, 1], [0, 1], [0, 0]], + [[1, 1], [0, 1], [0, 0]], + [[1, 0], [1, 1], [1, 0]], + [[1, 1], [1, 0], [0, 0]], + [[0, 0], [1, 0], [0, 0]], + [[1, 0], [0, 0], [1, 0]], + [[1, 1], [0, 1], [1, 1]], + [[1, 1], [1, 0], [1, 1]], + [[0, 0], [1, 0], [1, 1]], + [[1, 0], [1, 1], [1, 0]], + [[0, 0], [0, 1], [1, 1]], + [[1, 0], [0, 0], [0, 1]], + [[1, 0], [1, 1], [1, 0]], + [[1, 1], [1, 0], [0, 0]], + [[1, 1], [0, 1], [1, 1]], + [[1, 1], [1, 0], [1, 1]], + [[1, 0], [1, 1], [0, 1]], + [[1, 0], [0, 0], [1, 0]], + [[1, 1], [1, 0], [0, 0]], + [[1, 1], [0, 1], [0, 0]], + [[0, 0], [1, 0], [1, 1]], + [[0, 0], [0, 1], [0, 0]], + [[1, 0], [1, 1], [1, 0]], + [[1, 0], [1, 1], [1, 0]]] + + def get_planning_problem(self, num_agents): + global_model_ids = self.global_model_ids + agent_skeleton_l = [self.agent_skeleton_options[i] for i in range(num_agents)] + + # Get random starts and goals as if all start tiles and goal tiles are in highways. + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvHighways2D, + tensor_args=params.tensor_args, + margin=0.2, + obstacle_margin=0.2) + + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l + + +class EnvTestThreeByThreeRobotPlanarDiskRandom(MMDPlanningProblemConfig): + def __init__(self): + self.name = "EnvTestThreeByThree_RobotPlanarDisk_Random" + self.start_state_pos_l, self.goal_state_pos_l = get_start_goal_pos_circle(10, radius=0.45) + more_start_state_pos_l, more_goal_state_pos_l = get_start_goal_pos_circle(20, radius=0.65) + self.start_state_pos_l += more_start_state_pos_l + self.goal_state_pos_l += more_goal_state_pos_l + + self.global_model_ids = [['EnvEmptyNoWait2D-RobotPlanarDisk', 'EnvConveyor2D-RobotPlanarDisk', 'EnvDropRegion2D-RobotPlanarDisk'], + ['EnvHighways2D-RobotPlanarDisk', 'EnvHighways2D-RobotPlanarDisk', 'EnvHighways2D-RobotPlanarDisk'], + ['EnvConveyor2D-RobotPlanarDisk', 'EnvDropRegion2D-RobotPlanarDisk', 'EnvEmptyNoWait2D-RobotPlanarDisk']] + self.agent_skeleton_options = [[[1, 1], [2, 1], [2, 2]], + [[1, 2], [1, 1], [1, 2]], + [[1, 1], [1, 2], [1, 1]], + [[2, 2], [1, 2], [1, 1]], + [[1, 0], [1, 1], [1, 2]], + [[1, 1], [2, 1], [1, 1]], + [[1, 0], [2, 0], [1, 0]], + [[1, 1], [1, 0], [0, 0]], + [[1, 1], [1, 2], [2, 2]], + [[1, 2], [2, 2], [1, 2]], + [[2, 2], [2, 1], [2, 2]], + [[2, 2], [2, 1], [1, 1]], + [[1, 2], [1, 1], [1, 0]], + [[0, 0], [1, 0], [1, 1]], + [[0, 0], [0, 1], [1, 1]], + [[1, 0], [1, 1], [1, 0]], + [[2, 2], [1, 2], [2, 2]], + [[1, 1], [0, 1], [1, 1]], + [[1, 1], [1, 0], [1, 1]], + [[0, 0], [0, 1], [0, 0]], + [[1, 2], [0, 2], [1, 2]], + [[1, 0], [0, 0], [1, 0]], + [[0, 0], [1, 0], [0, 0]], + [[1, 1], [0, 1], [0, 0]]] + + def get_skeleton_options(self, optional_start_goal_coords, length=3, num_agents=3): + # Return a list of paths starting from the optional_start_goal_coords and ending there too of length length. + agent_skeleton_options = [] + for agent_id in range(num_agents): + agent_skeleton_options.append([]) + for i in range(length): + agent_skeleton_options[agent_id].append(optional_start_goal_coords[agent_id]) + + def get_planning_problem(self, num_agents): + + # Get random starts and goals as if all start tiles and goal tiles are in highways. + start_state_pos_l, goal_state_pos_l = get_start_goal_pos_random_in_env(num_agents=num_agents, + env_class=EnvHighways2D, + tensor_args=params.tensor_args, + margin=0.2, + obstacle_margin=0.2) + + global_model_ids = self.global_model_ids + agent_skeleton_l = [self.agent_skeleton_options[i] for i in range(num_agents)] + + return start_state_pos_l, goal_state_pos_l, global_model_ids, agent_skeleton_l diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_params.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_params.py new file mode 100644 index 0000000000000000000000000000000000000000..3d31ee849565275e91558ad6608c762a6b5089fe --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/config/mmd_params.py @@ -0,0 +1,105 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import torch + + +# A central location for aggregating the parameters used across files. +class MMDParams: + # Robot parameters. + # ROBOT_R is the difficulty axis for maps with no obstacles to inflate (Empty), + # the counterpart of OBS_SCALE in primitives.py. Mirrors smd/config/smd_params.py. + # Defaults to 0.05, so behaviour is unchanged when the variable is unset. + robot_planar_disk_radius = float(__import__('os').environ.get('ROBOT_R') or '0.05') # '' -> default, not a crash + # Single-agent planning parameters. + use_guide_on_extra_objects_only = False + # n_samples = 64 # Batch size. Number of trajectories generated together. + n_samples = 128 + horizon = 64 # Number of steps in the trajectory. + n_local_inference_noising_steps = 3 # Number of noising steps in local inference. + n_local_inference_denoising_steps = 3 # Number of denoising steps in local inference. + start_guide_steps_fraction = 1.0 # The fraction of the inference steps that are guided. + # n_guide_steps = 20 # The number of steps taken when applying conditioning at one diffusion step. + # n_diffusion_steps_without_noise = 1 # How many (at the end) diffusion steps get zero noise and guiding. + n_guide_steps = 1 # The number of steps taken when applying conditioning at one diffusion step. + n_diffusion_steps_without_noise = 0 # How many (at the end) diffusion steps get zero noise and guiding. + weight_grad_cost_collision = 1e-1 + weight_grad_cost_smoothness = 0 + weight_grad_cost_constraints = 2e-1 + weight_grad_cost_soft_constraints = 2e-2 + # weight_grad_cost_collision = 5e-2 + # weight_grad_cost_smoothness = 8e-2 + # weight_grad_cost_constraints = 2e-1 + # weight_grad_cost_soft_constraints = 2e-2 + factor_num_interpolated_points_for_collision = 0.0 + trajectory_duration = 5.0 + device = 'cuda' + debug = True + seed = 18 + results_dir = 'logs' + + # Multi-agent planning parameters. + vertex_constraint_radius = robot_planar_disk_radius * 2.4 + low_level_choose_path_from_batch_strategy = 'least_collisions' # 'least_collisions' or 'least_cost'. + + # Evaluation. + runtime_limit = 60 # 1 minute. + data_adherence_linear_deviation_fraction = 0.1 # Points closer to start-goal line than fraction * length adhere. + + # Torch. + tensor_args = {'device': device, 'dtype': torch.float32} + + # Trained models directory. + trained_models_dir_global_fpath = '~/mmd/data_trained_models' # Modify this. + + + + @classmethod + def to_dict(cls): + """ + Serializes the class attributes to a dictionary. + """ + params_dict = {} + for attr_name, attr_value in cls.__dict__.items(): + # Filter out private/protected attributes and methods. + if not attr_name.startswith('_') and not callable(attr_value) and not isinstance(attr_value, classmethod): + # Handle special types that are not directly JSON-serializable. + if isinstance(attr_value, dict): + # For dictionaries, serialize their values if needed. + params_dict[attr_name] = {k: str(v) if isinstance(v, torch.dtype) else v for k, v in attr_value.items()} + elif isinstance(attr_value, torch.dtype): + params_dict[attr_name] = str(attr_value) + else: + params_dict[attr_name] = attr_value + return params_dict + + @classmethod + def save(cls, save_dir: str): + """ + Saves the parameters to a YAML file in the specified directory. + """ + import yaml + params_dict = cls.to_dict() + with open(f"{save_dir}/mmd_params.yaml", 'w') as f: + yaml.dump(params_dict, f, default_flow_style=False) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/cost_function_implementation.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/cost_function_implementation.py new file mode 100644 index 0000000000000000000000000000000000000000..870b90ecd257ec1cabc483f713726e46328af913 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/cost_function_implementation.py @@ -0,0 +1,335 @@ +import torch +import torch.nn.functional as F +import numpy as np +import math + +def dummy_cost(*inputs, **kwargs): + """ + A dummy cost function that returns a tensor of zeros with the same shape as the input. + This is useful for testing or as a placeholder when no cost is needed. + + Args: + inputs: Input tensor, expected shape (B, H, d). + + Returns: + A tensor of zeros with the same shape as the input. + """ + ## Return a zero tensor of the same batch size as the first input + ## but keep the gradient flow. + if len(inputs) == 0: + raise ValueError("No input tensors provided to dummy_cost.") + x:torch.Tensor = inputs[0] + if x.dim() < 3: + raise ValueError(f"Input tensor x must be at least 3D (B, H, d). Got {x.dim()}D.") + return torch.zeros(x.shape[0], dtype=x.dtype, device=x.device, requires_grad=True) + + +def exp_decay_l2_sqr_cost( + x: torch.Tensor, + y: torch.Tensor, + decay_rate: float = 10.0, +) -> torch.Tensor: + """ + Calculates a cost that penalizes when x and y are close (in squared L2 norm) + at any time step, using an exponential decay function. + + The penalty is higher when the L2 distance is small, and decays + exponentially as the distance increases. + + Args: + x: Input tensor, expected shape (B, H, d). + y: Target tensor, expected shape (B, H, d). + decay_rate: The decay rate (B) of the exponential function, determining + how sharply the penalty drops off with distance. + + Returns: + A tensor of computed costs, shape (B,). + """ + if x.shape != y.shape: + raise ValueError("Input tensors x and y must have the same shape.") + if x.dim() < 3: + raise ValueError(f"Input tensors x and y must be 3D (B, H, d). Got {x.dim()}D.") + + sq_diff = (x - y) ** 2 + + sum_l2_sqr = sq_diff.sum(dim=(-2, -1)) + + total_penalty_cost = torch.exp(-decay_rate * sum_l2_sqr) + + return total_penalty_cost + + +def exp_decay_l2_cost( + x: torch.Tensor, + y: torch.Tensor, + decay_rate: float = 10.0, +) -> torch.Tensor: + """ + Calculates a cost that penalizes when x and y are close (in squared L2 norm) + at any time step, using an exponential decay function. + + The penalty is higher when the L2 distance is small, and decays + exponentially as the distance increases. + + Args: + x: Input tensor, expected shape (B, H, d). + y: Target tensor, expected shape (B, H, d). + decay_rate: The decay rate (B) of the exponential function, determining + how sharply the penalty drops off with distance. + + Returns: + A tensor of computed costs, shape (B,). + """ + if x.shape != y.shape: + raise ValueError("Input tensors x and y must have the same shape.") + if x.dim() < 3: + raise ValueError(f"Input tensors x and y must be 3D (B, H, d). Got {x.dim()}D.") + + l2 = torch.linalg.norm(x - y, dim=-1) + + total_penalty_cost = torch.exp(-decay_rate * l2.sum(dim=-1)) + + return total_penalty_cost + + +def sum_log_l2_cost( + *inputs: torch.Tensor, + alpha: float, + **kwargs, +) -> torch.Tensor: + """ + Vectorized version of sum_log_l2_cost. + + Args: + inputs: >=2 tensors, each of shape (B, H, d) + alpha: >0, small constant inside the log + + Returns: + (B,) tensor: summed log-penalty per batch element. + """ + assert len(inputs) >= 2, "At least two input tensors are required." + assert alpha > 0, "Alpha must be > 0." + x0 = inputs[0] + assert x0.dim() >= 3, f"Input tensor must be at least 3D (B, H, d). Got {x0.dim()}D." + + for y in inputs[1:]: + if y.shape != x0.shape or y.dim() < 3: + raise ValueError("Expected inputs of shape (..., H, d).") + + # Stack: (B, H, N, d) + X = torch.stack(inputs, dim=-2) # same as before but no cdist + N = len(inputs) + + # Upper-triangular pair indices (i torch.Tensor: + """ + Computes a summed, negative-logarithmic penalty on squared L2 distances + for batches of trajectories. At each time-step, the cost is + -log( sum_d (x_hd - y_hd) + 1 ) + which strongly penalizes small distances and grows more gently as + distance increases. + + Args: + inputs (torch.Tensor): Tensor of shape (B, H, d). + epsilon (float): Small constant added inside the log to avoid + singularity when distance is zero. + + Returns: + torch.Tensor: A tensor of shape (B,), where each element is + the total log-penalty cost for one trajectory pair. + """ + assert len(inputs) >= 2, "At least two input tensors are required for sum_log_l2_cost." + assert alpha > 0, "Alpha must be a positive constant to avoid singularity in log." + x = inputs[0] + assert x.dim() >= 3, f"Input tensor x must be at least 3D (B, H, d). Got {x.dim()}D." + for i, y in enumerate(inputs[1:]): + if x.shape != y.shape or y.dim() < 3: + raise ValueError("Expected inputs of shape (..., H, d).") + + # L2 norm per time-step -> (B, H) over every pair of inputs + per_step_costs = 0.0 + for i in range(len(inputs) - 1): + for j in range(i + 1, len(inputs)): + per_step_costs += -torch.log(alpha + torch.linalg.norm(inputs[i] - inputs[j], dim=-1)) + + # sum over time -> (B,) + return per_step_costs.sum(dim=-1) + + +def hinge_cub_l2_cost( + *inputs: torch.Tensor, + active_range: float = 1.0, # Radius for the collision cost + **kwargs, +) -> torch.Tensor: + assert len(inputs) >= 2, "At least two input tensors are required for sum_log_l2_cost." + assert active_range > 0, "Alpha must be a positive constant to avoid singularity in log." + x = inputs[0] + assert x.dim() >= 3, f"Input tensor x must be at least 3D (B, H, d). Got {x.dim()}D." + for i, y in enumerate(inputs[1:]): + if x.shape != y.shape or y.dim() < 3: + raise ValueError("Expected inputs of shape (..., H, d).") + + # L2 norm per time-step -> (B, H) over every pair of inputs + per_step_costs = 0.0 + for i in range(len(inputs) - 1): + for j in range(i + 1, len(inputs)): + l2_norm = torch.linalg.norm(inputs[i] - inputs[j], dim=-1) + active_mask = l2_norm <= active_range + per_step_costs += torch.where( + active_mask, + (active_range - l2_norm) ** 3, + torch.zeros_like(l2_norm) # No cost outside the active range + ) + + return per_step_costs.sum(dim=-1) + + +def hinge_sqr_l2_cost( + *inputs: torch.Tensor, + active_range: float = 1.0, # Radius for the collision cost + **kwargs, +) -> torch.Tensor: + """ + Vectorized hinge squared L2 cost. + + Args: + inputs: >=2 tensors, each of shape (B, H, d) + active_range: radius below which the hinge penalty is applied + + Returns: + (B,) tensor: summed hinge-squared penalty per batch element. + """ + assert len(inputs) >= 2, "At least two input tensors are required." + assert active_range > 0, "active_range must be > 0." + x0 = inputs[0] + assert x0.dim() >= 3, f"Input tensor must be at least 3D (B, H, d). Got {x0.dim()}D." + + for y in inputs[1:]: + if y.shape != x0.shape or y.dim() < 3: + raise ValueError("Expected inputs of shape (..., H, d).") + + X = torch.stack(inputs, dim=-2) # (..., H, N, d) + N = len(inputs) + + i_idx, j_idx = torch.triu_indices(N, N, offset=1, device=X.device) + Xi = X[..., i_idx, :] # (..., H, P, d) + Xj = X[..., j_idx, :] # (..., H, P, d) + + D = torch.linalg.norm(Xi - Xj, dim=-1) + pair_costs = F.relu(active_range - D) ** 2 + return pair_costs.sum(dim=-1).sum(dim=-1) + + +def hinge_sqr_l2_cost_loop( + *inputs: torch.Tensor, + active_range: float = 1.0, + **kwargs, +) -> torch.Tensor: + assert len(inputs) >= 2, "At least two input tensors are required for sum_log_l2_cost." + assert active_range > 0, "Alpha must be a positive constant to avoid singularity in log." + x = inputs[0] + assert x.dim() >= 3, f"Input tensor x must be at least 3D (B, H, d). Got {x.dim()}D." + for i, y in enumerate(inputs[1:]): + if x.shape != y.shape or y.dim() < 3: + raise ValueError("Expected inputs of shape (..., H, d).") + + # L2 norm per time-step -> (B, H) over every pair of inputs + per_step_costs = 0.0 + for i in range(len(inputs) - 1): + for j in range(i + 1, len(inputs)): + l2_norm = torch.linalg.norm(inputs[i] - inputs[j], dim=-1) + active_mask = l2_norm <= active_range + per_step_costs += torch.where( + active_mask, + (active_range - l2_norm) ** 2, + torch.zeros_like(l2_norm) # No cost outside the active range + ) + + return per_step_costs.sum(dim=-1) + + +def dpp_cost(x: torch.Tensor, y: torch.Tensor, eps: float = 1e-7, **kwargs) -> torch.Tensor: + """ + Computes a diversity score based on the Determinantal Point Process (DPP) + for pairs of trajectories (x_i, y_i). The score is the log-determinant + of the kernel matrix formed by each pair. A higher score corresponds to + more diverse pairs of trajectories. + + This function handles tensors with 3 or more dimensions, applying the DPP + calculation to the last two dimensions while preserving all leading dimensions. + + The group size is fixed to 2, with the groups being (x_i, y_i) for each + item i in the batch dimensions. + + Args: + x (torch.Tensor): The first batch of trajectories, shape (..., H, d). + y (torch.Tensor): The second batch of trajectories, shape (..., H, d). + eps (float): A small epsilon for numerical stability, used to clamp + the determinant before taking the logarithm. + + Returns: + torch.Tensor: A tensor of costs with shape (...), where each element is the + log-determinant for the corresponding pair in the batch. + This cost should be minimized for diversity. + """ + if x.shape != y.shape: + raise ValueError("Input tensors x and y must have the same shape.") + if x.dim() < 3: + raise ValueError(f"Input tensors must be at least 3D (..., H, d). Got {x.dim()}-D.") + + phi_x = torch.flatten(x, start_dim=-2) + phi_y = torch.flatten(y, start_dim=-2) + + # Stack to form groups of 2 for each item in the batch dimensions. + # phi will have a shape of (..., 2, H*d). + phi = torch.stack([phi_x, phi_y], dim=-2) + + # Normalize feature vectors along the feature dimension (the last dimension). + phi = torch.nn.functional.normalize(phi, p=2, dim=-1) + + S = phi @ phi.transpose(-1, -2) + assert S.shape[-1] == S.shape[-2] == 2, "Kernel matrix S should be of shape (..., 2, 2)." + + # Compute the determinant for each 2x2 kernel matrix. + # The result will have a shape of (...). + det = torch.linalg.det(S) + + det = torch.clamp(torch.abs(det), min=eps) + + # The cost is the log determinant. Minimizing this cost encourages + # diversity between the x and y trajectories. + cost = -torch.log(det) + + return cost + + +# Registry for cost functions +cost_registry = { + # "sum_recip_l2_sqr": sum_recip_l2_sqr_cost, + # "sum_recip_l2": sum_recip_l2_cost, + # "exp_decay_l2_sqr": exp_decay_l2_sqr_cost, + # "exp_decay_l2": exp_decay_l2_cost, + "dummy": dummy_cost, + "sum_log_l2": sum_log_l2_cost, + "hinge_cub_l2": hinge_cub_l2_cost, + "hinge_sqr_l2": hinge_sqr_l2_cost, + # "dpp": dpp_cost, + # "sum_l2": sum_l2_cost, +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/coupling_cost_functions.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/coupling_cost_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..1c2c1f88d8e0e722f15a69d694648d96fd9af651 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/coupling_costs/coupling_cost_functions.py @@ -0,0 +1,90 @@ +import einops +import numpy as np +import torch +from typing import List, Optional, Callable + +from mp_baselines.planners.costs.cost_functions import Cost +from mmd.coupling_costs.cost_function_implementation import cost_registry + +class DummyCost(Cost): + """ + Zero-valued placeholder cost that preserves shape and gradient flow. + """ + def __init__(self, *args, **kwargs): + pass + + def eval(self, trajs: torch.Tensor, **kwargs): + _ = kwargs + if len(trajs) == 0: + raise ValueError("No input tensors provided to DummyCost.") + if trajs.dim() < 3: + raise ValueError(f"Input tensor trajs must be at least 3D (B, H, d). Got {trajs.dim()}D.") + return torch.zeros_like(trajs) + trajs.sum() * 0 + + def get_linear_system(self, trajs, **kwargs): + pass + + +class RobotCollisionCost(Cost): + """ + Wrapper that splits batched trajectories into grouped robot inputs before calling a registered cost. + """ + def __init__(self, robot_radius: float, cost_func_key:str, **kwargs): + self.robot_radius = robot_radius + assert cost_func_key in cost_registry, f"Cost function key '{cost_func_key}' not found in registry." + self.cost_func:Callable = cost_registry[cost_func_key] + self.cost_func_key = cost_func_key + self.cost_fn_kwargs = {} + self.resolve_cost_fn_kwargs(**kwargs) + + def eval(self, trajs: torch.Tensor, **kwargs): + """ + Evaluate the collision cost for the given trajectories. + + Args: + trajs: Input tensor of shape (B, H, d) where B is the batch size, + H is the horizon, and d is the dimension of the trajectory; + by default assuming the 1st dim is the batch size and the + 2nd last dim is horizon and the last dim is state dimension. + kwargs: Additional keyword arguments, such as 'margin_coeff' and 'group_size'. + + Returns: + A tensor of collision costs for the robot's trajectory, shape (B // group_size,). + """ + if len(trajs) == 0: + raise ValueError("No input tensors provided to RobotCollisionCost.") + if trajs.dim() < 3: + raise ValueError(f"Input tensor trajs must be at least 3D (B, H, d). Got {trajs.dim()}D.") + + grp_sz = kwargs.pop('group_size', 2) + robot_trjs = torch.tensor_split(trajs[..., :2], grp_sz, dim=0) + + costs = self.cost_func(*robot_trjs, **self.cost_fn_kwargs) + + return costs + + def get_linear_system(self, trajs, **kwargs): + pass + + def resolve_cost_fn_kwargs(self, **kwargs): + if self.cost_func_key == 'sum_log_l2': + eta = kwargs.get('margin_coeff', 1.95) + self.cost_fn_kwargs = { + 'alpha': eta * self.robot_radius, + } + elif self.cost_func_key == 'hinge_sqr_l2': + eta = kwargs.get('radius_coeff', 4.0) + self.cost_fn_kwargs = { + 'active_range': eta * self.robot_radius, + } + elif self.cost_func_key == 'hinge_cub_l2': + eta = kwargs.get('radius_coeff', 4.0) + self.cost_fn_kwargs = { + 'active_range': eta * self.robot_radius, + } + else: + raise NotImplementedError( + f"Cost function '{self.cost_func_key}' not implemented for RobotCollisionCost." + ) + + return self.cost_fn_kwargs diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea7af0813b7efc976216cb764b3f7fd07dbc5544 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/__init__.py @@ -0,0 +1,2 @@ +from .trajectories import * + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/normalization.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/normalization.py new file mode 100644 index 0000000000000000000000000000000000000000..f9e0bfbc96015bdf1afaba31d540c36fb2149d84 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/normalization.py @@ -0,0 +1,207 @@ +""" +From Michael Janner +Cloned from https://github.com/jacarvalho/mpd-public +""" +import einops +import torch + + +#-----------------------------------------------------------------------------# +#--------------------------- multi-field normalizer --------------------------# +#-----------------------------------------------------------------------------# + +class DatasetNormalizer: + + def __init__(self, dataset, normalizer): + dataset = flatten(dataset) + + if type(normalizer) == str: + normalizer = eval(normalizer) + + self.normalizers = {} + for key, val in dataset.items(): + self.normalizers[key] = normalizer(val) + # try: + # self.normalizers[key] = normalizer(val) + # except: + # print(f'[ utils/normalization ] Skipping {key} | {normalizer}') + + def __repr__(self): + string = '' + for key, normalizer in self.normalizers.items(): + string += f'{key}: {normalizer}]\n' + return string + + def __call__(self, *args, **kwargs): + return self.normalize(*args, **kwargs) + + def normalize(self, x, key): + return self.normalizers[key].normalize(x) + + def unnormalize(self, x, key): + return self.normalizers[key].unnormalize(x) + + def get_field_normalizers(self): + return self.normalizers + + +# def flatten(dataset): +# ''' +# flattens dataset of { key: [ batch x length x dim ] } +# to { key : [ (batch * length) x dim ]} +# ''' +# flattened = {} +# for key, xs in dataset.items(): +# flattened[key] = einops.rearrange(xs, 'b h d -> (b h) d') +# return flattened + + +def flatten(dataset): + ''' + flattens dataset of { key: [ ... x dim ] } + to { key : [ (...) x dim ]} + ''' + flattened = {} + for key, xs in dataset.items(): + xs_new = xs + if xs.ndim == 2: + # environments (e d) + pass + elif xs.ndim == 3: + # trajectories in fixed environments + xs_new = einops.rearrange(xs, 'b h d -> (b h) d') + elif xs.ndim == 4: + # trajectories in variable environments + xs_new = einops.rearrange(xs, 'e b h d -> (e b h) d') + else: + raise NotImplementedError + flattened[key] = xs_new + return flattened + + + +#-----------------------------------------------------------------------------# +#-------------------------- single-field normalizers -------------------------# +#-----------------------------------------------------------------------------# +class Normalizer: + ''' + parent class, subclass by defining the `normalize` and `unnormalize` methods + ''' + + def __init__(self, X): + self.X = X + self.mins = X.min(dim=0).values + self.maxs = X.max(dim=0).values + if __import__('os').environ.get('RBT_ISO') == '1' and self.mins.numel() >= 2: + import torch as _t + def _iso(a, b): + mm = _t.maximum(self.mins[a].abs(), self.maxs[a].abs()) + if b < self.mins.numel(): + mm = _t.maximum(mm, _t.maximum(self.mins[b].abs(), self.maxs[b].abs())) + self.mins[b], self.maxs[b] = -mm, mm + self.mins[a], self.maxs[a] = -mm, mm + _iso(0, 1) + if self.mins.numel() >= 4: + _iso(2, 3) + + def __repr__(self): + return ( + f'''[ Normalizer ] dim: {self.mins.size}\n -: ''' + f'''{torch.round(self.mins, decimals=2)}\n +: {torch.round(self.maxs, decimals=2)}\n''' + ) + + def __call__(self, x): + return self.normalize(x) + + def normalize(self, *args, **kwargs): + raise NotImplementedError() + + def unnormalize(self, *args, **kwargs): + raise NotImplementedError() + + +class Identity(Normalizer): + def normalize(self, x): + return x + + def unnormalize(self, x): + return x + + +class GaussianNormalizer(Normalizer): + ''' + normalizes to zero mean and unit variance + ''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.means = self.X.mean(dim=0) + self.stds = self.X.std(dim=0) + self.z = 1 + + def __repr__(self): + return ( + f'''[ Normalizer ] dim: {self.mins.size}\n ''' + f'''means: {torch.round(self.means, decimals=2)}\n ''' + f'''stds: {torch.round(self.z * self.stds, decimals=2)}\n''' + ) + + def normalize(self, x): + return (x - self.means) / self.stds + + def unnormalize(self, x): + return x * self.stds + self.means + + +class LimitsNormalizer(Normalizer): + ''' + maps [ xmin, xmax ] to [ -1, 1 ] + ''' + + def normalize(self, x): + ## [ 0, 1 ] + x = (x - self.mins) / (self.maxs - self.mins) + ## [ -1, 1 ] + x = 2 * x - 1 + return x + + def unnormalize(self, x, eps=1e-4): + ''' + x : [ -1, 1 ] + ''' + if x.max() > 1 + eps or x.min() < -1 - eps: + # print(f'[ datasets/mujoco ] Warning: sample out of range | ({x.min():.4f}, {x.max():.4f})') + x = torch.clip(x, -1, 1) + + ## [ -1, 1 ] --> [ 0, 1 ] + x = (x + 1) / 2. + + return x * (self.maxs - self.mins) + self.mins + + +class SafeLimitsNormalizer(LimitsNormalizer): + ''' + functions like LimitsNormalizer, but can handle data for which a dimension is constant + ''' + + def __init__(self, *args, eps=1, **kwargs): + super().__init__(*args, **kwargs) + for i in range(len(self.mins)): + if self.mins[i] == self.maxs[i]: + print(f''' + [ utils/normalization ] Constant data in dimension {i} | ''' + f'''max = min = {self.maxs[i]}''' + ) + self.mins -= eps + self.maxs += eps + + +class FixedLimitsNormalizer(LimitsNormalizer): + ''' + functions like LimitsNormalizer, but with fixed limits not derived from the data + ''' + + def __init__(self, *args, min=-1, max=1, **kwargs): + super().__init__(*args, **kwargs) + self.mins = torch.ones_like(self.mins) * min + self.maxs = torch.ones_like(self.maxs) * max diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/trajectories.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/trajectories.py new file mode 100644 index 0000000000000000000000000000000000000000..eb9b5dd11962faabc845397c18a6e92eeb2d1893 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/datasets/trajectories.py @@ -0,0 +1,247 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import abc +import os.path + +import numpy as np +import torch +from torch.utils.data import Dataset + +from mmd.datasets.normalization import DatasetNormalizer +from mmd.utils.loading import load_params_from_yaml +from torch_robotics import environments, robots +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer + +dataset_base_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'data_trajectories') + + +class TrajectoryDatasetBase(Dataset, abc.ABC): + + def __init__(self, + dataset_subdir=None, + include_velocity=False, + normalizer='LimitsNormalizer', + use_extra_objects=False, + obstacle_cutoff_margin=None, + tensor_args=None, + **kwargs): + + self.tensor_args = tensor_args + + self.dataset_subdir = dataset_subdir + self.base_dir = os.path.join(dataset_base_dir, self.dataset_subdir) + # Get the args and metadata file from the '0' directory of this dataset. This includes obstacles. + self.args = load_params_from_yaml(os.path.join(self.base_dir, '0', 'args.yaml')) + self.metadata = load_params_from_yaml(os.path.join(self.base_dir, '0', 'metadata.yaml')) + + if obstacle_cutoff_margin is not None: + self.args['obstacle_cutoff_margin'] = obstacle_cutoff_margin + + # -------------------------------- Load env, robot, task --------------------------------- + # Environment + env_class = getattr( + environments, self.metadata['env_id'] + 'ExtraObjects' if use_extra_objects else self.metadata['env_id']) + self.env = env_class(tensor_args=tensor_args) + + # Robot + robot_class = getattr(robots, self.metadata['robot_id']) + self.robot = robot_class(tensor_args=tensor_args) + + # Task + self.task = PlanningTask(env=self.env, robot=self.robot, tensor_args=tensor_args, **self.args) + self.planner_visualizer = PlanningVisualizer(task=self.task) + + # -------------------------------- Load trajectories --------------------------------- + self.threshold_start_goal_pos = self.args['threshold_start_goal_pos'] + + self.field_key_traj = 'traj' + self.field_key_task = 'task' + self.fields = {} + + # load data + self.include_velocity = include_velocity + self.map_task_id_to_trajectories_id = {} + self.map_trajectory_id_to_task_id = {} + self.load_trajectories() + + # dimensions + b, h, d = self.dataset_shape = self.fields[self.field_key_traj].shape + self.n_trajs = b + self.n_support_points = h + self.state_dim = d # state dimension used for the diffusion model + self.trajectory_dim = (self.n_support_points, d) + + # normalize the data (for the diffusion model) + self.normalizer = DatasetNormalizer(self.fields, normalizer=normalizer) + self.normalizer_keys = [self.field_key_traj, self.field_key_task] + self.normalize_all_data(*self.normalizer_keys) + + def load_trajectories(self): + # load free trajectories + trajs_free_l = [] + task_id = 0 + n_trajs = 0 + for current_dir, subdirs, files in os.walk(self.base_dir, topdown=True): + if 'trajs-free.pt' in files: + trajs_free_tmp = torch.load( + os.path.join(current_dir, 'trajs-free.pt'), map_location=self.tensor_args['device']) + trajectories_idx = n_trajs + np.arange(len(trajs_free_tmp)) + self.map_task_id_to_trajectories_id[task_id] = trajectories_idx + for j in trajectories_idx: + self.map_trajectory_id_to_task_id[j] = task_id + task_id += 1 + n_trajs += len(trajs_free_tmp) + trajs_free_l.append(trajs_free_tmp) + + trajs_free = torch.cat(trajs_free_l) + trajs_free_pos = self.robot.get_position(trajs_free) + + if self.include_velocity: + trajs = trajs_free + else: + trajs = trajs_free_pos + self.fields[self.field_key_traj] = trajs + + # task: start and goal state positions [n_trajectories, 2 * state_dim] + task = torch.cat((trajs_free_pos[..., 0, :], trajs_free_pos[..., -1, :]), dim=-1) + self.fields[self.field_key_task] = task + + def normalize_all_data(self, *keys): + for key in keys: + self.fields[f'{key}_normalized'] = self.normalizer(self.fields[f'{key}'], key) + + def render(self, task_id=3, + render_joint_trajectories=False, + render_robot_trajectories=False, + **kwargs): + # -------------------------------- Visualize --------------------------------- + idxs = self.map_task_id_to_trajectories_id[task_id] + pos_trajs = self.robot.get_position(self.fields[self.field_key_traj][idxs]) + start_state_pos = pos_trajs[0][0] + goal_state_pos = pos_trajs[0][-1] + + fig1, axs1, fig2, axs2 = [None] * 4 + + if render_joint_trajectories: + fig1, axs1 = self.planner_visualizer.plot_joint_space_state_trajectories( + trajs=pos_trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), + ) + + if render_robot_trajectories: + fig2, axs2 = self.planner_visualizer.render_robot_trajectories( + trajs=pos_trajs, start_state=start_state_pos, goal_state=goal_state_pos, + ) + + return fig1, axs1, fig2, axs2 + + def __repr__(self): + msg = f'TrajectoryDataset\n' \ + f'n_trajs: {self.n_trajs}\n' \ + f'trajectory_dim: {self.trajectory_dim}\n' + return msg + + def __len__(self): + return self.n_trajs + + def __getitem__(self, index): + # Generates one sample of data - one trajectory and tasks + field_traj_normalized = f'{self.field_key_traj}_normalized' + field_task_normalized = f'{self.field_key_task}_normalized' + traj_normalized = self.fields[field_traj_normalized][index] + task_normalized = self.fields[field_task_normalized][index] + data = { + field_traj_normalized: traj_normalized, + field_task_normalized: task_normalized + } + + # build hard conditions + hard_conds = self.get_hard_conditions(traj_normalized, horizon=len(traj_normalized)) + data.update({'hard_conds': hard_conds}) + + return data + + def get_hard_conditions(self, traj, horizon=None, normalize=False): + raise NotImplementedError + + def get_unnormalized(self, index): + raise NotImplementedError + traj = self.fields[self.field_key_traj][index][..., :self.state_dim] + task = self.fields[self.field_key_task][index] + if not self.include_velocity: + task = task[self.task_idxs] + data = {self.field_key_traj: traj, + self.field_key_task: task, + } + if self.variable_environment: + data.update({self.field_key_env: self.fields[self.field_key_env][index]}) + + # hard conditions + # hard_conds = self.get_hard_conds(tasks) + hard_conds = self.get_hard_conditions(traj) + data.update({'hard_conds': hard_conds}) + + return data + + def unnormalize(self, x, key): + return self.normalizer.unnormalize(x, key) + + def normalize(self, x, key): + return self.normalizer.normalize(x, key) + + def unnormalize_trajectories(self, x): + return self.unnormalize(x, self.field_key_traj) + + def normalize_trajectories(self, x): + return self.normalize(x, self.field_key_traj) + + def unnormalize_tasks(self, x): + return self.unnormalize(x, self.field_key_task) + + def normalize_tasks(self, x): + return self.normalize(x, self.field_key_task) + + +class TrajectoryDataset(TrajectoryDatasetBase): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def get_hard_conditions(self, traj, horizon=None, normalize=False): + # start and goal positions + start_state_pos = self.robot.get_position(traj[0]) + goal_state_pos = self.robot.get_position(traj[-1]) + + if self.include_velocity: + # If velocities are part of the state, then set them to zero at the beggining and end of a trajectory + start_state = torch.cat((start_state_pos, torch.zeros_like(start_state_pos)), dim=-1) + goal_state = torch.cat((goal_state_pos, torch.zeros_like(goal_state_pos)), dim=-1) + else: + start_state = start_state_pos + goal_state = goal_state_pos + + if normalize: + start_state = self.normalizer.normalize(start_state, key=self.field_key_traj) + goal_state = self.normalizer.normalize(goal_state, key=self.field_key_traj) + + if horizon is None: + horizon = self.n_support_points + hard_conds = { + 0: start_state, + horizon - 1: goal_state + } + return hard_conds + + def get_single_pt_hard_conditions(self, state_position, idx, normalize=False): + state_pos = self.robot.get_position(state_position) + if self.include_velocity: + state = torch.cat((state_pos, torch.zeros_like(state_pos)), dim=-1) + else: + state = state_pos + if normalize: + state = self.normalizer.normalize(state, key=self.field_key_traj) + return {idx: state} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9c1180b3e1807faed3311ad39fa2d27aab8a2759 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/__init__.py @@ -0,0 +1 @@ +from .gaussian_diffusion_loss import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/gaussian_diffusion_loss.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/gaussian_diffusion_loss.py new file mode 100644 index 0000000000000000000000000000000000000000..d0c951f1933cf074370263a827e8bd9fef58a5c9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/losses/gaussian_diffusion_loss.py @@ -0,0 +1,28 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch + +from mmd.models import build_context + + +class GaussianDiffusionLoss: + + def __init__(self): + pass + + @staticmethod + def loss_fn(diffusion_model, input_dict, dataset, step=None): + """ + Loss function for training diffusion-based generative models. + """ + traj_normalized = input_dict[f'{dataset.field_key_traj}_normalized'] + + context = build_context(diffusion_model, dataset, input_dict) + + hard_conds = input_dict.get('hard_conds', {}) + loss, info = diffusion_model.loss(traj_normalized, context, hard_conds) + + loss_dict = {'diffusion_loss': loss} + + return loss_dict, info diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..10f7db7e272a4c062c3a5260fed8985be415c8dd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/__init__.py @@ -0,0 +1,4 @@ +from .layers import * +from .helpers import * +from .generic import * +from .diffusion_models import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..96e07c4ecd41da5c8d5cc619c20fefb295f81563 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/__init__.py @@ -0,0 +1,3 @@ +from .diffusion_model_base import * +from .diffusion_ensemble import * +from .temporal_unet import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..9e833cfa8e5fe8afe1103dd3e8cb4814645c3e42 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_ensemble.py @@ -0,0 +1,313 @@ +""" +Adapted from https://github.com/jacarvalho/mpd-public + +MIT License + +Copyright (c) 2024 Itamar Mishani + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +# General includes. +from copy import deepcopy +import einops +import torch +from typing import * +# Project includes. +from .diffusion_model_base import * +from mmd.config import MMDParams as params + + +class DiffusionsEnsemble(nn.Module): + def __init__(self, + models: Dict[int, GaussianDiffusionModel], + transforms: Dict[int, torch.Tensor], + context_model=None, + **kwargs): + super().__init__(**kwargs) + self.models = models + # Make sure all models have the same number of diffusion steps. + assert len(set([model.n_diffusion_steps for model in models.values()])) == 1 + self.n_diffusion_steps = models[0].n_diffusion_steps + # Make sure all predict the same values (predict epsilon or not). + assert len(set([model.predict_epsilon for model in models.values()])) == 1 + + self.transforms = transforms + + self.context_model = context_model + + @torch.no_grad() + def p_sample_loop(self, shape, hard_conds, cross_conds, + n_diffusion_steps=None, + contexts=None, return_chain=False, + sample_fn=ddpm_sample_fn, + n_diffusion_steps_without_noise=0, + warm_start_path_b: torch.Tensor = None, + **sample_kwargs): + device = self.models[0].betas.device + + batch_size = shape[0] + x = {} + chains = {} + for m in self.models.keys(): + if warm_start_path_b is not None: + x[m] = warm_start_path_b[:, m * params.horizon: (m+1) * params.horizon, :].clone() + # Move to range with transform. + x[m][:, :, :2] -= self.transforms[m] + print(CYAN, "Using warm start path in p_sample_loop. Steps (negative attempts to recon. t=0)", + [n_diffusion_steps, -n_diffusion_steps_without_noise], RESET) + else: + x[m] = torch.randn(shape, device=device) + print(CYAN, "Using random noise in p_sample_loop. Steps (negative attempts to recon. t=0)", + [n_diffusion_steps, -n_diffusion_steps_without_noise], RESET) + try: + x[m] = apply_hard_conditioning(x[m], hard_conds[m]) + except KeyError: + hard_conds[m] = {} + x = apply_cross_conditioning(x, cross_conds, self.transforms) + chains = {k: [x[k]] for k in self.models.keys()} if return_chain else None + + for i in reversed(range(-n_diffusion_steps_without_noise, + n_diffusion_steps)): + t = make_timesteps(batch_size, i, device) + for m in self.models.keys(): + ctx = deepcopy(contexts[m]) if contexts is not None else None + if ctx is not None: + for k, v in ctx.items(): + ctx[k] = einops.repeat(v, 'd -> b d', b=x[m].shape[0]) + x[m], values = sample_fn(self.models[m], x[m], hard_conds[m], + ctx, t, **sample_kwargs['sample_kwargs'][m]) + x[m] = apply_hard_conditioning(x[m], hard_conds[m]) + x = apply_cross_conditioning(x, cross_conds, self.transforms) + if return_chain: + for m in self.models.keys(): + chains[m].append(x[m]) + + if return_chain: + chains = {k: torch.stack(v, dim=1) for k, v in chains.items()} + return x, chains + + return x + + @torch.no_grad() + def ddim_sample( + self, shape, hard_conds, + n_diffusion_steps=None, + context=None, return_chain=False, + t_start_guide=torch.inf, + guide=None, + n_guide_steps=1, + **sample_kwargs, + ): + # Adapted from https://github.com/ezhang7423/language-control-diffusion/blob/63cdafb63d166221549968c662562753f6ac5394/src/lcd/models/diffusion.py#L226 + device = self.betas.device + batch_size = shape[0] + total_timesteps = n_diffusion_steps + sampling_timesteps = n_diffusion_steps // 5 + eta = 0. + + # [-1, 0, 1, 2, ..., T-1] when sampling_timesteps == total_timesteps + times = torch.linspace(0, total_timesteps - 1, steps=sampling_timesteps + 1, device=device) + times = torch.cat((torch.tensor([-1], device=device), times)) + times = list(reversed(times.int().tolist())) + time_pairs = list(zip(times[:-1], times[1:])) # [(T-1, T-2), (T-2, T-3), ..., (1, 0), (0, -1)] + + x = torch.randn(shape, device=device) + x = apply_hard_conditioning(x, hard_conds) + + chain = [x] if return_chain else None + + for time, time_next in time_pairs: + t = make_timesteps(batch_size, time, device) + t_next = make_timesteps(batch_size, time_next, device) + + model_out = self.model(x, t, context) + + x_start = self.predict_start_from_noise(x, t=t, noise=model_out) + pred_noise = self.predict_noise_from_start(x, t=t, x0=model_out) + + if time_next < 0: + x = x_start + x = apply_hard_conditioning(x, hard_conds) + if return_chain: + chain.append(x) + break + + alpha = extract(self.alphas_cumprod, t, x.shape) + alpha_next = extract(self.alphas_cumprod, t_next, x.shape) + + sigma = ( + eta * ((1 - alpha / alpha_next) * (1 - alpha_next) / (1 - alpha)).sqrt() + ) + c = (1 - alpha_next - sigma ** 2).sqrt() + + x = x_start * alpha_next.sqrt() + c * pred_noise + + # guide gradient steps before adding noise + if guide is not None: + if torch.all(t_next < t_start_guide): + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + **sample_kwargs + ) + + # add noise + noise = torch.randn_like(x) + x = x + sigma * noise + x = apply_hard_conditioning(x, hard_conds) + + if return_chain: + chain.append(x) + + if return_chain: + chain = torch.stack(chain, dim=1) + return x, chain + + return x + + @torch.no_grad() + def joint_conditional_sampling(self, + hard_conds: Dict[int, dict], + cross_conds: Dict[Tuple[int, int], Tuple[int, int]], + n_diffusion_steps: int = None, + batch_size: int = 1, + ddim: bool = False, + warm_start_path_b: torch.Tensor = None, + **sample_kwargs): + # The horizon, in this implementation, is the same for all models. + horizon = params.horizon + shape = (batch_size, horizon, self.models[0].state_dim) + if n_diffusion_steps is None: + raise ValueError("n_diffusion_steps must be provided.") + if ddim: + if warm_start_path_b is not None: + raise ValueError("warm_start_path_b is not supported for ddim sampling.") + return self.ddim_sample(shape, hard_conds, cross_conds, n_diffusion_steps=n_diffusion_steps, **sample_kwargs) + + return self.p_sample_loop(shape, hard_conds, cross_conds, n_diffusion_steps=n_diffusion_steps, + warm_start_path_b=warm_start_path_b, **sample_kwargs) + + def forward(self, *args, **kwargs): + pass + + @torch.no_grad() + def warmup(self, + horizon: int = 64, + device: str = "cuda", + context: List[dict] = None): + context = deepcopy(context) + if context is None: + context = [None] * len(self.models) + # Warmup all models + for model, ctx in zip(self.models, context): + model.warmup(horizon=horizon, device=device, context=ctx) + + @torch.no_grad() + def run_inference(self, + contexts: List[dict] = None, + hard_conds: Dict[int, dict] = None, + cross_conds: Dict[Tuple[int, int], Tuple[int, int]] = None, + n_samples: int = 1, + return_chain: bool = False, + # sample_kwargs: List[dict] = None, + **diffusion_kwargs): + """ + + :param contexts: + :param hard_conds: + :param cross_conds: Keys are tuples of model indices and values are tuples of trajectory timesteps. + For example, {(0, 1): {0, 64}} means that the value of the state 0 in the trajectory + of model 0 needs to be the same as the value of the state 64 in the trajectory of model 1. + :param n_samples: + :param return_chain: + :param diffusion_kwargs: + :return: + """ + contexts = deepcopy(contexts) + hard_conds = deepcopy(hard_conds) + cross_conds = deepcopy(cross_conds) + if contexts is None: + contexts = [None] * len(self.models) + for m, c_dict in hard_conds.items(): + for k, v in c_dict.items(): + # k is the key of the condition, usually state index in the trajectory + hard_conds[m][k] = einops.repeat(v, 'd -> b d', b=n_samples) + + samples, chains = self.joint_conditional_sampling(hard_conds=hard_conds, + cross_conds=cross_conds, + n_diffusion_steps=self.n_diffusion_steps, + contexts=contexts, + batch_size=n_samples, + return_chain=return_chain, + **diffusion_kwargs) + + for k_chain, v_chain in chains.items(): + chains[k_chain] = einops.rearrange(v_chain, 'b diffsteps h d -> diffsteps b h d') + + if return_chain: + return chains + + return {k: v[-1] for k, v in chains.items()} + + def run_local_inference(self, seed_trajectory_b: torch.Tensor, + n_noising_steps: int, # If None, then pass that and later on a true noise sample will be used. + n_denoising_steps: int, # Must be a real value that can be used for denoising. + contexts: List[dict] = None, + hard_conds: Dict[int, dict] = None, + cross_conds: Dict[Tuple[int, int], Tuple[int, int]] = None, + n_samples: int = 1, + return_chain: bool = False, + **diffusion_kwargs): + + contexts = deepcopy(contexts) + hard_conds = deepcopy(hard_conds) + cross_conds = deepcopy(cross_conds) + if contexts is None: + contexts = [None] * len(self.models) + for m, c_dict in hard_conds.items(): + for k, v in c_dict.items(): + # k is the key of the condition, usually state index in the trajectory + hard_conds[m][k] = einops.repeat(v, 'd -> b d', b=n_samples) + + # Noise the given seed trajectory for n_noising_steps. + if n_noising_steps is None: + seed_trajectory_b_noised = None + else: + B = seed_trajectory_b.shape[0] + t = make_timesteps(B, n_noising_steps, seed_trajectory_b.device) # Shape: (B,). + seed_trajectory_b_noised = self.models[0].q_sample(x_start=seed_trajectory_b, t=t) + + samples, chains = self.joint_conditional_sampling(hard_conds=hard_conds, + cross_conds=cross_conds, + n_diffusion_steps=n_denoising_steps, + contexts=contexts, + batch_size=n_samples, + return_chain=return_chain, + warm_start_path_b=seed_trajectory_b_noised, + **diffusion_kwargs) + + for k_chain, v_chain in chains.items(): + chains[k_chain] = einops.rearrange(v_chain, 'b diffsteps h d -> diffsteps b h d') + + if return_chain: + return chains + + return {k: v[-1] for k, v in chains.items()} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_model_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_model_base.py new file mode 100644 index 0000000000000000000000000000000000000000..f4fc41145c6c9522fdeed32e14f8e0e455e97f80 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/diffusion_model_base.py @@ -0,0 +1,483 @@ +""" +Adapted from https://github.com/jannerm/diffuser +Cloned from https://github.com/jacarvalho/mpd-public +""" +import abc +import time +from collections import namedtuple +from copy import copy + +import einops +import matplotlib.pyplot as plt +import numpy as np +import torch +import torch.nn as nn +from abc import ABC + +from torch.nn import DataParallel + +from mmd.models.diffusion_models.helpers import cosine_beta_schedule, Losses, exponential_beta_schedule +from mmd.models.diffusion_models.sample_functions import extract, apply_hard_conditioning, apply_cross_conditioning, guide_gradient_steps, \ + ddpm_sample_fn +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def make_timesteps(batch_size, i, device): + t = torch.full((batch_size,), i, device=device, dtype=torch.long) + return t + + +def build_context(model, dataset, input_dict): + # input_dict is already normalized + context = None + if model.context_model is not None: + context = dict() + # (normalized) features of variable environments + if dataset.variable_environment: + env_normalized = input_dict[f'{dataset.field_key_env}_normalized'] + context['env'] = env_normalized + + # tasks + task_normalized = input_dict[f'{dataset.field_key_task}_normalized'] + context['tasks'] = task_normalized + return context + + +class GaussianDiffusionModel(nn.Module, ABC): + + def __init__(self, + model=None, + variance_schedule='exponential', + n_diffusion_steps=100, + clip_denoised=True, + predict_epsilon=False, + loss_type='l2', + context_model=None, + **kwargs): + super().__init__() + + self.model = model + + self.context_model = context_model + + self.n_diffusion_steps = n_diffusion_steps + + self.state_dim = self.model.state_dim + + if variance_schedule == 'cosine': + betas = cosine_beta_schedule(n_diffusion_steps, s=0.008, a_min=0, a_max=0.999) + elif variance_schedule == 'exponential': + betas = exponential_beta_schedule(n_diffusion_steps, beta_start=1e-4, beta_end=1.0) + else: + raise NotImplementedError + + alphas = 1. - betas + alphas_cumprod = torch.cumprod(alphas, axis=0) + alphas_cumprod_prev = torch.cat([torch.ones(1), alphas_cumprod[:-1]]) + + self.clip_denoised = clip_denoised + self.predict_epsilon = predict_epsilon + + self.register_buffer('betas', betas) + self.register_buffer('alphas_cumprod', alphas_cumprod) + self.register_buffer('alphas_cumprod_prev', alphas_cumprod_prev) + + # calculations for diffusion q(x_t | x_{t-1}) and others + self.register_buffer('sqrt_alphas_cumprod', torch.sqrt(alphas_cumprod)) + self.register_buffer('sqrt_one_minus_alphas_cumprod', torch.sqrt(1. - alphas_cumprod)) + self.register_buffer('log_one_minus_alphas_cumprod', torch.log(1. - alphas_cumprod)) + self.register_buffer('sqrt_recip_alphas_cumprod', torch.sqrt(1. / alphas_cumprod)) + self.register_buffer('sqrt_recipm1_alphas_cumprod', torch.sqrt(1. / alphas_cumprod - 1)) + + # calculations for posterior q(x_{t-1} | x_t, x_0) + posterior_variance = betas * (1. - alphas_cumprod_prev) / (1. - alphas_cumprod) + self.register_buffer('posterior_variance', posterior_variance) + + ## log calculation clipped because the posterior variance + ## is 0 at the beginning of the diffusion chain + self.register_buffer('posterior_log_variance_clipped', + torch.log(torch.clamp(posterior_variance, min=1e-20))) + self.register_buffer('posterior_mean_coef1', + betas * np.sqrt(alphas_cumprod_prev) / (1. - alphas_cumprod)) + self.register_buffer('posterior_mean_coef2', + (1. - alphas_cumprod_prev) * np.sqrt(alphas) / (1. - alphas_cumprod)) + + ## get loss coefficients and initialize objective + self.loss_fn = Losses[loss_type]() + + # More parameters. + self.visualize_local_inference = False + + # ------------------------------------------ sampling ------------------------------------------# + def predict_noise_from_start(self, x_t, t, x0): + """ + if self.predict_epsilon, model output is (scaled) noise; + otherwise, model predicts x0 directly + """ + if self.predict_epsilon: + return x0 + else: + return ( + extract(self.sqrt_recip_alphas_cumprod, t, x_t.shape) * x_t - x0 + ) / extract(self.sqrt_recipm1_alphas_cumprod, t, x_t.shape) + + def predict_start_from_noise(self, x_t, t, noise): + ''' + if self.predict_epsilon, model output is (scaled) noise; + otherwise, model predicts x0 directly + ''' + if self.predict_epsilon: + return ( + extract(self.sqrt_recip_alphas_cumprod, t, x_t.shape) * x_t - + extract(self.sqrt_recipm1_alphas_cumprod, t, x_t.shape) * noise + ) + else: + return noise + + def q_posterior(self, x_start, x_t, t): + posterior_mean = ( + extract(self.posterior_mean_coef1, t, x_t.shape) * x_start + + extract(self.posterior_mean_coef2, t, x_t.shape) * x_t + ) + posterior_variance = extract(self.posterior_variance, t, x_t.shape) + posterior_log_variance_clipped = extract(self.posterior_log_variance_clipped, t, x_t.shape) + return posterior_mean, posterior_variance, posterior_log_variance_clipped + + def p_mean_variance(self, x, hard_conds, context, t): + if context is not None: + context = self.context_model(context) + + x_recon = self.predict_start_from_noise(x, t=t, noise=self.model(x, t, context)) + + if self.clip_denoised: + x_recon.clamp_(-1., 1.) + else: + assert RuntimeError() + + model_mean, posterior_variance, posterior_log_variance = self.q_posterior(x_start=x_recon, x_t=x, t=t) + return model_mean, posterior_variance, posterior_log_variance + + @torch.no_grad() + def p_sample_loop(self, shape, + hard_conds, + n_diffusion_steps: int, + context=None, return_chain=False, + sample_fn=ddpm_sample_fn, + n_diffusion_steps_without_noise=0, + warm_start_path_b : torch.Tensor=None, + **sample_kwargs): + """ + Diffusion model decoder. + Starting from a random noise, samples the diffusion model for n_diffusion_steps. + :param shape: shape of the output tensor. + :param hard_conds: hard conditions to apply. I.e., (time, state) pairs where the state will be overridden to the + given value at the given time. + :param n_diffusion_steps: number of steps to denoise sample. + :param context: if a context is given then a context model must is used. + :param return_chain: if True, returns the whole chain of samples alongside the output. + :param sample_fn: function to sample from the diffusion model. + :param n_diffusion_steps_without_noise: number of steps to sample without noise. Always keeping t=0. + """ + device = self.betas.device + + batch_size = shape[0] + if warm_start_path_b is not None: + x = warm_start_path_b + else: + x = torch.randn(shape, device=device) + x = apply_hard_conditioning(x, hard_conds) + + chain = [x] if return_chain else None + + timeit = sample_kwargs.get('timeit', False) + if timeit: + dict_add = lambda _d1, _d2: {k: _d1.get(k, 0) + _d2.get(k, 0) for k in (set(_d1.keys()) | set(_d2.keys()))} + total_time_elapsed = {} # 'diffuse', 'coupling', 'project' + for i in reversed(range(-n_diffusion_steps_without_noise, n_diffusion_steps)): + t = make_timesteps(batch_size, i, device) + if timeit: + x, values, time_elapsed_iter = sample_fn(self, x, hard_conds, context, t, **sample_kwargs) + total_time_elapsed = dict_add(total_time_elapsed, time_elapsed_iter) + else: + x, values = sample_fn(self, x, hard_conds, context, t, **sample_kwargs) + x = apply_hard_conditioning(x, hard_conds) + + if return_chain: + chain.append(x) + + if return_chain: + chain = torch.stack(chain, dim=1) + if timeit: + return x, chain, total_time_elapsed + else: + return x, chain + + if timeit: + return x, total_time_elapsed + return x + + @torch.no_grad() + def ddim_sample( + self, shape, hard_conds, + n_diffusion_steps: int, + context=None, + return_chain=False, + t_start_guide=torch.inf, + guide=None, + n_guide_steps=1, + **sample_kwargs, + ): + # Adapted from https://github.com/ezhang7423/language-control-diffusion/blob/63cdafb63d166221549968c662562753f6ac5394/src/lcd/models/diffusion.py#L226 + device = self.betas.device + batch_size = shape[0] + total_timesteps = n_diffusion_steps + sampling_timesteps = n_diffusion_steps // 5 + eta = 0. + + # [-1, 0, 1, 2, ..., T-1] when sampling_timesteps == total_timesteps + times = torch.linspace(0, total_timesteps - 1, steps=sampling_timesteps + 1, device=device) + times = torch.cat((torch.tensor([-1], device=device), times)) + times = list(reversed(times.int().tolist())) + time_pairs = list(zip(times[:-1], times[1:])) # [(T-1, T-2), (T-2, T-3), ..., (1, 0), (0, -1)] + + x = torch.randn(shape, device=device) + x = apply_hard_conditioning(x, hard_conds) + + chain = [x] if return_chain else None + + for time, time_next in time_pairs: + t = make_timesteps(batch_size, time, device) + t_next = make_timesteps(batch_size, time_next, device) + + model_out = self.model(x, t, context) + + x_start = self.predict_start_from_noise(x, t=t, noise=model_out) + pred_noise = self.predict_noise_from_start(x, t=t, x0=model_out) + + if time_next < 0: + x = x_start + x = apply_hard_conditioning(x, hard_conds) + if return_chain: + chain.append(x) + break + + alpha = extract(self.alphas_cumprod, t, x.shape) + alpha_next = extract(self.alphas_cumprod, t_next, x.shape) + + sigma = ( + eta * ((1 - alpha / alpha_next) * (1 - alpha_next) / (1 - alpha)).sqrt() + ) + c = (1 - alpha_next - sigma ** 2).sqrt() + + x = x_start * alpha_next.sqrt() + c * pred_noise + + # guide gradient steps before adding noise + if guide is not None: + if torch.all(t_next < t_start_guide): + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + **sample_kwargs + ) + + # add noise + noise = torch.randn_like(x) + x = x + sigma * noise + x = apply_hard_conditioning(x, hard_conds) + + if return_chain: + chain.append(x) + + if return_chain: + chain = torch.stack(chain, dim=1) + return x, chain + + return x + + @torch.no_grad() + def conditional_sample(self, hard_conds, n_diffusion_steps: int, horizon=None, batch_size=1, ddim=False, + warm_start_path_b: torch.Tensor=None, **sample_kwargs): + """ + hard conditions : hard_conds : { (time, state), ... } + """ + horizon = horizon or self.horizon + shape = (batch_size, horizon, self.state_dim) + + if ddim: + if warm_start_path_b is not None: + raise ValueError("warm_start_path_b is not supported for ddim sampling.") + return self.ddim_sample(shape, hard_conds, n_diffusion_steps=n_diffusion_steps, **sample_kwargs) + + return self.p_sample_loop(shape, hard_conds, n_diffusion_steps=n_diffusion_steps, + warm_start_path_b=warm_start_path_b, **sample_kwargs) + + def forward(self, cond, *args, **kwargs): + raise NotImplementedError + return self.conditional_sample(cond, *args, **kwargs) + + @torch.no_grad() + def warmup(self, horizon=64, device='cuda'): + shape = (2, horizon, self.state_dim) + x = torch.randn(shape, device=device) + t = make_timesteps(2, 1, device) + self.model(x, t, context=None) + + @torch.no_grad() + def run_inference(self, context=None, hard_conds=None, n_samples=1, return_chain=False, **diffusion_kwargs): + # context and hard_conds must be normalized + hard_conds = copy(hard_conds) + context = copy(context) + + # repeat hard conditions and contexts for n_samples + for k, v in hard_conds.items(): + # new_state = einops.repeat(v, 'd -> b d', b=n_samples) + if isinstance(v, torch.Tensor): + new_state = v.unsqueeze(0).expand(n_samples, *v.shape) + else: + new_state = v + hard_conds[k] = new_state + + if context is not None: + for k, v in context.items(): + context[k] = einops.repeat(v, 'd -> b d', b=n_samples) + + # Sample from diffusion model + timeit = diffusion_kwargs.get('timeit', False) + if timeit: + samples, chain, time_elapsed = self.conditional_sample( + hard_conds, n_diffusion_steps=self.n_diffusion_steps, context=context, batch_size=n_samples, + return_chain=True, **diffusion_kwargs + ) + else: + samples, chain = self.conditional_sample( + hard_conds, n_diffusion_steps=self.n_diffusion_steps, context=context, batch_size=n_samples, return_chain=True, **diffusion_kwargs + ) + + # chain: [ n_samples x (n_diffusion_steps + 1) x horizon x (state_dim)] + # extract normalized trajectories + trajs_chain_normalized = chain + + # trajs: [ (n_diffusion_steps + 1) x n_samples x horizon x state_dim ] + trajs_chain_normalized = einops.rearrange(trajs_chain_normalized, 'b diffsteps h d -> diffsteps b h d') + + if return_chain: + if timeit: + return trajs_chain_normalized, time_elapsed + return trajs_chain_normalized + + # return the last denoising step + if timeit: + return trajs_chain_normalized[-1], time_elapsed + return trajs_chain_normalized[-1] + + def run_local_inference(self, seed_trajectory_b: torch.Tensor, + n_noising_steps: int, # If None, then pass that and later on a true noise sample will be used. + n_denoising_steps: int, # Must be a real value that can be used for denoising. + context=None, + hard_conds=None, + n_samples=1, + return_chain=False, + **diffusion_kwargs): + """ + Run local inference on a seed trajectory batch. This noises the seed trajectory for a few steps and then denoises + it under all given conditions and constraints (guides). + """ + # Context and hard_conds should be normalized. Make a copy to avoid modifying the original. + hard_conds = copy(hard_conds) + context = copy(context) + + # Repeat hard conditions and contexts for n_samples. + for k, v in hard_conds.items(): + new_state = einops.repeat(v, 'd -> b d', b=n_samples) + hard_conds[k] = new_state + + if context is not None: + for k, v in context.items(): + context[k] = einops.repeat(v, 'd -> b d', b=n_samples) + + # Noise the given seed trajectory for n_noising_steps. + if n_noising_steps is None: + seed_trajectory_b_noised = None + else: + B = seed_trajectory_b.shape[0] + t = make_timesteps(B, n_noising_steps, seed_trajectory_b.device) # Shape: (B,). + seed_trajectory_b_noised = self.q_sample(x_start=seed_trajectory_b, t=t) + + # Sample from diffusion model for the same number of steps as the noising steps. + samples, chain = self.conditional_sample( + hard_conds, + n_diffusion_steps=n_denoising_steps, + context=context, + batch_size=n_samples, + return_chain=True, + warm_start_path_b=seed_trajectory_b_noised, + **diffusion_kwargs + ) + + # chain: [ n_samples x (n_diffusion_steps + 1) x horizon x (state_dim)] + # extract normalized trajectories + trajs_chain_normalized = chain + + # trajs: [ (n_diffusion_steps + 1) x n_samples x horizon x state_dim ] + trajs_chain_normalized = einops.rearrange(trajs_chain_normalized, 'b diffsteps h d -> diffsteps b h d') + + if self.visualize_local_inference: + # Visualize the seed trajectory and the noised trajectory. + fig, ax = plt.subplots(1, 2) + ax[0].plot(to_numpy(seed_trajectory_b[0, :, 0]), to_numpy(seed_trajectory_b[0, :, 1]), 'r') + ax[0].plot(to_numpy(seed_trajectory_b_noised[0, :, 0]), to_numpy(seed_trajectory_b_noised[0, :, 1]), 'b') + ax[0].set_title("Seed Trajectory (Red) and Noised Trajectory (Blue)") + # Visualize the seed trajectory and the new trajectory. + ax[1].plot(to_numpy(seed_trajectory_b[0, :, 0]), to_numpy(seed_trajectory_b[0, :, 1]), 'r') + ax[1].plot(to_numpy(trajs_chain_normalized[-1, 0, :, 0]), to_numpy(trajs_chain_normalized[-1, 0, :, 1]), 'b') + ax[1].set_title("Seed Trajectory (Red) and New Trajectory (Blue)") + plt.show() + + + if return_chain: + return trajs_chain_normalized + + # return the last denoising step + return trajs_chain_normalized[-1] + + # ------------------------------------------ training ------------------------------------------# + + def q_sample(self, x_start, t, noise=None): + if noise is None: + noise = torch.randn_like(x_start) + + sample = ( + extract(self.sqrt_alphas_cumprod, t, x_start.shape) * x_start + + extract(self.sqrt_one_minus_alphas_cumprod, t, x_start.shape) * noise + ) + return sample + + def p_losses(self, x_start, context, t, hard_conds): + noise = torch.randn_like(x_start) + + x_noisy = self.q_sample(x_start=x_start, t=t, noise=noise) + x_noisy = apply_hard_conditioning(x_noisy, hard_conds) + + # context model + if context is not None: + context = self.context_model(context) + + # diffusion model + x_recon = self.model(x_noisy, t, context) + x_recon = apply_hard_conditioning(x_recon, hard_conds) + + assert noise.shape == x_recon.shape + + if self.predict_epsilon: + loss, info = self.loss_fn(x_recon, noise) + else: + loss, info = self.loss_fn(x_recon, x_start) + + return loss, info + + def loss(self, x, context, *args): + batch_size = x.shape[0] + t = torch.randint(0, self.n_diffusion_steps, (batch_size,), device=x.device).long() + return self.p_losses(x, context, t, *args) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/guides.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/guides.py new file mode 100644 index 0000000000000000000000000000000000000000..69423f8c8465800d39feadcbdc399e3ecb60846f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/guides.py @@ -0,0 +1,585 @@ +""" +Adapted from https://github.com/jacarvalho/mpd-public +""" +import abc +import time + +import einops +import numpy as np +import torch +from torch import nn + +from mp_baselines.planners.costs.cost_functions import CostGPTrajectory +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from torch_robotics.torch_planning_objectives.fields.distance_fields import interpolate_points_v1 +from torch_robotics.torch_utils.torch_utils import to_torch + + +class GuideManagerTrajectories(nn.Module): + + def __init__(self, dataset, cost, clip_grad=False, clip_grad_rule='norm', max_grad_norm=1., max_grad_value=0.1, + interpolate_trajectories_for_collision=False, + num_interpolated_points_for_collision=128, + use_velocity_from_finite_difference=False, + start_state_pos=None, + goal_state_pos=None, + num_steps=100, + robot=None, + n_samples=1, + tensor_args=None, + **kwargs): + super().__init__() + self.cost = cost + self.dataset = dataset + + self.interpolate_trajectories_for_collision = interpolate_trajectories_for_collision + self.num_interpolated_points_for_collision = num_interpolated_points_for_collision + + self.clip_grad = clip_grad + self.clip_grad_rule = clip_grad_rule + self.max_grad_norm = max_grad_norm + self.max_grad_value = max_grad_value + + # velocity + self.use_velocity_from_finite_difference = use_velocity_from_finite_difference + self.robot = robot + self.start_state_pos = start_state_pos + self.goal_state_pos = goal_state_pos + # initialize velocity trajectory with a constant velocity + self.velocity = self.robot.get_velocity( + MultiMPPrior.const_vel_trajectory( + start_state_pos, + goal_state_pos, + robot.dt, + num_steps, + self.robot.q_dim, + set_initial_final_vel_to_zero=True, + tensor_args=tensor_args) + ) + self.velocity = to_torch(self.velocity, **tensor_args) + self.velocity = einops.repeat(self.velocity, "H D -> B H D", B=n_samples) + + def forward(self, x_pos_normalized): + x_pos = x_pos_normalized.clone() + with torch.enable_grad(): + x_pos.requires_grad_(True) + self.velocity.requires_grad_(True) + + # unnormalize x + # x is normalized, but the guides are defined on unnormalized trajectory space + x_pos = self.dataset.unnormalize_trajectories(x_pos) + + if self.interpolate_trajectories_for_collision: + # finer interpolation of trajectory for better collision avoidance + x_interpolated = interpolate_points_v1(x_pos, num_interpolated_points=self.num_interpolated_points_for_collision) + else: + x_interpolated = x_pos + + # compute costs + # append the current velocity trajectory to the position trajectory only for non-interpolated trajectories + if self.use_velocity_from_finite_difference: + x_vel = self.robot.get_velocity(x_pos) + x_pos_vel = torch.cat((x_pos, x_vel), dim=-1) + else: + x_pos_vel = torch.cat((x_pos, self.velocity), dim=-1) + + cost_l, weight_grad_cost_l = self.cost(x_pos_vel, x_interpolated=x_interpolated, return_invidual_costs_and_weights=True) + grad = 0 + grad_velocity = 0 + for cost, weight_grad_cost in zip(cost_l, weight_grad_cost_l): + if torch.is_tensor(cost): + # y.sum() is a surrogate to compute gradients of independent quantities over the batch dimension + # x are the support points. Compute gradients wrt x, not x_interpolated + if self.use_velocity_from_finite_difference: + grad_cost = torch.autograd.grad([cost.sum()], [x_pos], retain_graph=True)[0] + else: + grad_cost, grad_cost_velocity = torch.autograd.grad([cost.sum()], [x_pos, self.velocity]) + + # clip gradients + grad_cost_clipped = self.clip_gradient(grad_cost) + if not self.use_velocity_from_finite_difference: + grad_cost_velocity_clipped = self.clip_gradient(grad_cost_velocity) + + # zeroing gradients at start and goal + grad_cost_clipped[..., 0, :] = 0. + grad_cost_clipped[..., -1, :] = 0. + if not self.use_velocity_from_finite_difference: + grad_cost_velocity_clipped[..., 0, :] = 0. + grad_cost_velocity_clipped[..., -1, :] = 0. + + # combine gradients + grad_cost_clipped_weighted = weight_grad_cost * grad_cost_clipped + grad += grad_cost_clipped_weighted + + if not self.use_velocity_from_finite_difference: + grad_cost_velocity_clipped_weighted = weight_grad_cost * grad_cost_velocity_clipped + grad_velocity += grad_cost_velocity_clipped_weighted + + # Update the velocity + if not self.use_velocity_from_finite_difference: + self.velocity = self.velocity - grad_velocity + + # gradient ascent + grad = -1. * grad + return grad + + def clip_gradient(self, grad): + if self.clip_grad: + if self.clip_grad_rule == 'norm': + return self.clip_grad_by_norm(grad) + elif self.clip_grad_rule == 'value': + return self.clip_grad_by_value(grad) + else: + raise NotImplementedError + else: + return grad + + def clip_grad_by_norm(self, grad): + # clip gradient by norm + if self.clip_grad: + grad_norm = torch.linalg.norm(grad + 1e-6, dim=-1, keepdims=True) + scale_ratio = torch.clip(grad_norm, 0., self.max_grad_norm) / grad_norm + grad = scale_ratio * grad + return grad + + def clip_grad_by_value(self, grad): + # clip gradient by value + if self.clip_grad: + grad = torch.clip(grad, -self.max_grad_value, self.max_grad_value) + return grad + + +class GuideManagerTrajectoriesWithVelocity(nn.Module): + + def __init__(self, dataset, cost, clip_grad=False, clip_grad_rule='norm', max_grad_norm=1., max_grad_value=0.1, + interpolate_trajectories_for_collision=False, + num_interpolated_points_for_collision=128, + start_state_pos=None, + goal_state_pos=None, + num_steps=100, + robot=None, + n_samples=1, + tensor_args=None, + **kwargs): + super().__init__() + self.cost = cost # CostComposite. Will be complemented with the Cost objects in extra_costs. + self.dataset = dataset + + self.interpolate_trajectories_for_collision = interpolate_trajectories_for_collision + self.num_interpolated_points_for_collision = num_interpolated_points_for_collision + + self.clip_grad = clip_grad + self.clip_grad_rule = clip_grad_rule + self.max_grad_norm = max_grad_norm + self.max_grad_value = max_grad_value + + # Extra costs. These are extra cost components that will be evaluated alongside those in self.cost. + self.extra_cost_l = [] + self.extra_costs_grad_weight_l = [] + + def forward(self, x_normalized, **kwargs): # This is called by the model when asking for guidance. + x = x_normalized.clone() + with torch.enable_grad(): + x.requires_grad_(True) + + # unnormalize x + # x is normalized, but the guides are defined on unnormalized trajectory space + x = self.dataset.unnormalize_trajectories(x) + + if self.interpolate_trajectories_for_collision: + # finer interpolation of trajectory for better collision avoidance + x_interpolated = interpolate_points_v1(x, num_interpolated_points=self.num_interpolated_points_for_collision) + else: + x_interpolated = x + + self.cost.cost_l.extend(self.extra_cost_l) + self.cost.weight_cost_l.extend(self.extra_costs_grad_weight_l) + + # compute costs + # append the current velocity trajectory to the position trajectory only for non-interpolated trajectories + # self.cost calls the eval() method for each guide in the cost function. + cost_l, weight_grad_cost_l = self.cost(x, x_interpolated=x_interpolated, return_invidual_costs_and_weights=True, **kwargs) + + self.cost.cost_l = self.cost.cost_l[:len(cost_l) - len(self.extra_cost_l)] + self.cost.weight_cost_l = self.cost.weight_cost_l[:len(weight_grad_cost_l) - len(self.extra_costs_grad_weight_l)] + + grad = 0 + for cost, weight_grad_cost in zip(cost_l, weight_grad_cost_l): + if torch.is_tensor(cost): + # y.sum() is a surrogate to compute gradients of independent quantities over the batch dimension + # x are the support points. Compute gradients wrt x, not x_interpolated + grad_cost = torch.autograd.grad([cost.sum()], [x], retain_graph=True)[0] + + # clip gradients + grad_cost_clipped = self.clip_gradient(grad_cost) + + # zeroing gradients at start and goal + grad_cost_clipped[..., 0, :] = 0. + grad_cost_clipped[..., -1, :] = 0. + + # combine gradients + grad_cost_clipped_weighted = weight_grad_cost * grad_cost_clipped + grad += grad_cost_clipped_weighted + + # gradient ascent + grad = -1. * grad + return grad + + def add_extra_costs(self, extra_costs, extra_costs_grad_weights): + self.extra_cost_l.extend(extra_costs) + self.extra_costs_grad_weight_l.extend(extra_costs_grad_weights) + + def reset_extra_costs(self): + self.extra_cost_l = [] + self.extra_costs_grad_weight_l = [] + + def clip_gradient(self, grad): + if self.clip_grad: + if self.clip_grad_rule == 'norm': + return self.clip_grad_by_norm(grad) + elif self.clip_grad_rule == 'value': + return self.clip_grad_by_value(grad) + else: + raise NotImplementedError + else: + return grad + + def clip_grad_by_norm(self, grad): + # clip gradient by norm + if self.clip_grad: + grad_norm = torch.linalg.norm(grad + 1e-6, dim=-1, keepdims=True) + scale_ratio = torch.clip(grad_norm, 0., self.max_grad_norm) / grad_norm + grad = scale_ratio * grad + return grad + + def clip_grad_by_value(self, grad): + # clip gradient by value + if self.clip_grad: + grad = torch.clip(grad, -self.max_grad_value, self.max_grad_value) + return grad + + +class GuideBase(nn.Module, abc.ABC): + def __init__(self, scale=1e-3, tensor_args=None, **kwargs): + super().__init__() + self.tensor_args = tensor_args + self.scale = scale + + @abc.abstractmethod + def forward(self, x): + raise NotImplementedError + + def gradients(self, x): + x.requires_grad_() + y = self(x) + # y.sum() is a surrogate to compute gradients of independent quantities over the batch dimension + grad = torch.autograd.grad([y.sum()], [x])[0] + x.detach() + return y, grad + + +class GuideCollisionAvoidance(GuideBase): + """ + Computes the collision sdf for all points in a trajectory + """ + def __init__(self, env, **kwargs): + super().__init__(**kwargs) + self.env = env + + def forward(self, x): + collision_cost = self.env.compute_collision_cost(x, field_type='sdf') + cost = collision_cost.sum(-1) + return -1 * cost # maximize + + +class GuideSmoothnessFiniteDifferenceVelocity(GuideBase): + """ + Smoothness cost as the central finite difference of velocity, aka acceleration + """ + def __init__(self, env, method='central', **kwargs): + super().__init__(**kwargs) + self.env = env + self.method = method + + def forward(self, x): + if self.method == 'central': + vel = self.env.get_q_velocity(x) + acc = 0.5 * (vel[..., 1:, :] - vel[..., :-1, :]) + # minimize sum of accelerations along trajectory + cost = torch.linalg.norm(acc, dim=-1).sum(-1) + else: + raise NotImplementedError + return -1 * cost # maximize + + +class GuideSmoothnessGPPrior(GuideBase): + """ + Smoothness cost as a GP Prior + """ + def __init__(self, n_dofs, n_support_points_des, start_state, dt, cost_sigmas, **kwargs): + super().__init__(**kwargs) + # self.cost_prior = CostGP(n_dofs, n_support_points_des, start_state, dt, cost_sigmas, **kwargs) + self.cost_prior = CostGPTrajectory(n_dofs, n_support_points_des, start_state, dt, cost_sigmas, **kwargs) + + def forward(self, x): + cost = self.cost_prior(x) + return -1 * cost # maximize + + +class GuideStateGoal(GuideBase): + """ + State desired cost + """ + def __init__(self, state_des, idx, **kwargs): + super().__init__(**kwargs) + self.state_des = state_des + self.idx = idx + + def forward(self, x): + assert x.ndim >= 3 + cost = torch.linalg.norm(x[..., self.idx, :] - self.state_des, dim=-1) + return -1 * cost # maximize + + +class GuideTrajectorySmoothnessFirstOrder(GuideBase): + """ + Computes the first-order smoothness of a trajectory + d(q_0,...,q_{H-1}) = sum_{i=1}^{H-1}(||q_i-q_{i-1}||^2) + """ + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def forward(self, x): + # x: [batch, horizon, dim] + position_diff = torch.diff(x, dim=1) + distance = torch.linalg.norm(position_diff, dim=-1).sum(-1) + return -1. * distance # maximize the negative distance = minimize distance + + +class GuideSE3OrientationGoal(GuideBase): + """ + End effector orientation goal + """ + def __init__(self, env, rot_des=None, **kwargs): + super().__init__(**kwargs) + self.env = env + if rot_des is not None: + self.rot_des = rot_des.to(**self.tensor_args) + else: + self.rot_des = torch.eye(1).to(**self.tensor_args) + + self.w_rot = 1. + + def forward(self, q): + b = 1 + h = 1 + if q.ndim == 1: + q = q.unsqueeze(0).unsqueeze(0) + elif q.ndim == 2: + b = q.shape[0] + q = q.unsqueeze(0) + elif q.ndim == 3: + b = q.shape[0] + h = q.shape[1] + elif q.ndim > 3: + raise NotImplementedError + + # batch, trajectory length, q dimension + q = einops.rearrange(q, 'b h d -> (b h) d') + # link_tensor = self.diff_panda.compute_forward_kinematics_all_links(q) + link_tensor_EE = self.env.diff_panda.compute_forward_kinematics_link_list(q, link_list=[self.env.link_name_ee]) + # reshape to batch, trajectory, link poses + link_tensor_EE = einops.rearrange(link_tensor_EE, '(b h) 1 d1 d2 -> b h d1 d2', b=b, h=h) + + H_des = link_tensor_EE.clone() + H_des[..., :3, :3] = self.rot_des + + cost = SE3_distance(link_tensor_EE, H_des, w_rot=self.w_rot) + cost = cost.sum(-1) + return -1 * cost # maximize + + +class GuideTrajectoryLastPoint(GuideBase): + """ + https://arxiv.org/pdf/2301.06015.pdf + """ + def __init__(self, goal_state, **kwargs): + super().__init__(**kwargs) + self._goal_state = goal_state + + def forward(self, x): + # x: [batch, horizon, dim] + l1_dist_goal_state = torch.abs(self._goal_state - x).sum(-1) + # Equation A5 + # objective = torch.exp(1/l1_dist_goal_state).sum(-1) + # Equation A8 + objective = -1. * l1_dist_goal_state.sum(-1) + return objective + + +class GuideTrajectoryObstacleAvoidanceMultiSphere(GuideBase): + """ + Computes the sdf for all points in a trajectory + """ + def __init__(self, obstacles, tensor_args=None, **kwargs): + super().__init__(**kwargs) + obst_params = dict(obst_type='sdf') + self.shape = MultiSphere(tensor_args, **obst_params) + self.convert_obstacles_to_shape(obstacles) + self._max_sdf = 0.02 + + def convert_obstacles_to_shape(self, obstacles): + centers = [] + radii = [] + for obstacle in obstacles: + assert isinstance(obstacle, ObstacleSphere), "Only accepts circles for now" + centers.append(obstacle.get_center()) + radii.append(obstacle.radius) + centers = np.array(centers) + radii = np.array(radii) + self.shape.set_obst(centers=centers, radii=radii) + + def forward(self, x): + sdf_points = self.shape.compute_cost(x) + cost_points = torch.relu(self._max_sdf - sdf_points) + # cost_points = smooth_distance_penalty(sdf_points) + cost_trajectory = cost_points.sum(-1) + return -1 * cost_trajectory # maximize + + +def smooth_distance_penalty(d, max_sdf=0.1): + # TODO this does not allow the gradient to backpropagate because of the torch.where operator + raise NotImplementedError + # https://www.ri.cmu.edu/pub_files/2009/5/icra09-chomp.pdf + # Figure 2 + d_new = torch.zeros_like(d) + idxs1 = torch.where(d < 0) + d[idxs1] = -d[idxs1] + 0.5*max_sdf + idxs2 = torch.where(torch.logical_and(d >= 0, d <= max_sdf)) + d[idxs2] = 1/(2*max_sdf)*(d[idxs2] - max_sdf)**2 + return d_new + + +class GuideStochGPMP(GuideBase): + + def __init__(self, env, start_state, goal_state, tensor_args, **kwargs): + super().__init__(**kwargs) + + self.env = env + self.start_state = start_state + self.goal_state = goal_state + + self.tensor_args = tensor_args + + def forward(self, x): + pass + + def gradients(self, x): + # One step of local StochGPMP + y, grad = self.plan_sgpmp(x) + return y, grad + + def plan_sgpmp(self, prior_traj, sgpmp_opt_iters=1, step_size=0.05, return_trajs=False): + prior_traj = to_torch(prior_traj, **self.tensor_args) + + b, h, d = prior_traj.shape + n_support_points_des = h + + # SGPMP planner + n_dofs = self.env.n_dofs + # Add velocities to state + start_state = torch.cat((self.start_state, torch.zeros(n_dofs, **self.tensor_args))) + multi_goal_states = einops.repeat(torch.cat((self.goal_state, torch.zeros(n_dofs, **self.tensor_args))), + 'd -> b d', b=b) + + dt = 0.001 * 64 / n_support_points_des + num_particles_per_goal = 1 + num_samples = 50 + sgpmp_opt_iters = sgpmp_opt_iters + + # Construct cost functions + cost_sigmas = dict( + sigma_start=0.0001, + sigma_gp=0.3, + ) + cost_prior = CostGP( + n_dofs, n_support_points_des, start_state, dt, + cost_sigmas, self.tensor_args + ) + + sigma_goal_prior = 0.0001 + cost_goal_prior = CostGoalPrior( + n_dofs, + n_support_points_des, + multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + sigma_goal_prior=sigma_goal_prior, + tensor_args=self.tensor_args + ) + + sigma_coll = 1e-5 + cost_obst_2D = CostCollision( + n_dofs, n_support_points_des, + field=self.env.obstacle_map, + sigma_coll=sigma_coll, + tensor_args=self.tensor_args + ) + cost_func_list = [cost_prior, cost_goal_prior, cost_obst_2D] + cost_composite = CostComposite(n_dofs, n_support_points_des, cost_func_list) + + # Prior mean trajectory + initial_particle_means = None + if prior_traj is not None: + # Interpolate and smooth to desired trajectory length + # prior_traj = smoothen_trajectory(prior_traj, n_support_points_des, self.tensor_args, smooth_factor=0.01) + # Reshape for sgpmp interface + prior_traj = einops.repeat(prior_traj, 'b h d -> b n h d', n=num_particles_per_goal) + # Add velocities to state + initial_particle_means = torch.cat((prior_traj, torch.zeros_like(prior_traj)), dim=-1) + + sgpmp_params = dict( + start_state=start_state, + multi_goal_states=multi_goal_states, + cost=cost_composite, + initial_particle_means=initial_particle_means, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + n_support_points=n_support_points_des, + dt=dt, + n_dof=n_dofs, + opt_iters=sgpmp_opt_iters, + temp=0.1, + step_size=step_size, + sigma_start_init=1e-3, + sigma_goal_init=1e-3, + sigma_gp_init=5., + sigma_start_sample=1e-3, + sigma_goal_sample=1e-3, + sigma_gp_sample=5., + tensor_args=self.tensor_args, + ) + + sgpmp_planner = StochGPMP(**sgpmp_params) + obs = {} + + # Optimize + sgpmp_time_start = time.time() + _, _, _, _, costs, grad = sgpmp_planner.optimize(**obs) + + # costs of all particles + costs = costs.sum(1) + + # gradient includes velocities + grad = grad[..., :2] + + sgpmp_time_finish = time.time() + + controls, _, trajectories, trajectory_means, weights = sgpmp_planner.get_recent_samples() + + # print(f'SGPMP time: {sgpmp_time_finish - sgpmp_time_start:.4f} sec') + + if return_trajs: + return trajectories + + return costs, grad diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/helpers.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..a218f1f91f8c19010cce66d310d1683136ee2c67 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/helpers.py @@ -0,0 +1,108 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import numpy as np +import torch +from torch import nn +import torch.nn.functional as F + +from torch_robotics.torch_utils.torch_utils import to_torch + + +#-----------------------------------------------------------------------------# +#---------------------------- variance schedules -----------------------------# +#-----------------------------------------------------------------------------# + +def linear_beta_schedule(n_diffusion_steps, beta_start=0.0001, beta_end=0.02): + return torch.linspace(beta_start, beta_end, n_diffusion_steps) + + +def quadratic_beta_schedule(n_diffusion_steps, beta_start=0.0001, beta_end=0.02): + return torch.linspace(beta_start**0.5, beta_end**0.5, n_diffusion_steps) ** 2 + + +def sigmoid_beta_schedule(n_diffusion_steps, beta_start=0.0001, beta_end=0.02): + betas = torch.linspace(-6, 6, n_diffusion_steps) + return torch.sigmoid(betas) * (beta_end - beta_start) + beta_start + + +def cosine_beta_schedule(n_diffusion_steps, s=0.008, a_min=0, a_max=0.999, dtype=torch.float32): + """ + cosine schedule + as proposed in https://openreview.net/forum?id=-NEXDKk8gZ + """ + steps = n_diffusion_steps + 1 + x = np.linspace(0, steps, steps) + alphas_cumprod = np.cos(((x / steps) + s) / (1 + s) * np.pi * 0.5) ** 2 + alphas_cumprod = alphas_cumprod / alphas_cumprod[0] + betas = 1 - (alphas_cumprod[1:] / alphas_cumprod[:-1]) + betas_clipped = np.clip(betas, a_min=a_min, a_max=a_max) + return to_torch(betas_clipped, dtype=dtype) + + +def exponential_beta_schedule(n_diffusion_steps, beta_start=1e-4, beta_end=1.0): + # exponential increasing noise from t=0 to t=T + x = torch.linspace(0, n_diffusion_steps, n_diffusion_steps) + beta_start = to_torch(beta_start) + beta_end = to_torch(beta_end) + a = 1 / n_diffusion_steps * torch.log(beta_end / beta_start) + return beta_start * torch.exp(a * x) + + +def constant_fraction_beta_schedule(n_diffusion_steps): + # exponential increasing noise from t=0 to t=T + x = torch.linspace(0, n_diffusion_steps, n_diffusion_steps) + return 1 / (n_diffusion_steps-x+1) + + +def variance_preserving_beta_schedule(n_diffusion_steps, beta_start=1e-4, beta_end=1.0): + # Works only with a small number of diffusion steps + # https://arxiv.org/abs/2112.07804 + # https://openreview.net/pdf?id=AHvFDPi-FA + x = torch.linspace(0, n_diffusion_steps, n_diffusion_steps) + alphas = torch.exp(-beta_start*(1/n_diffusion_steps) - 0.5*(beta_end-beta_start)*(2*x-1)/(n_diffusion_steps**2)) + betas = 1 - alphas + return betas + + +#-----------------------------------------------------------------------------# +#---------------------------------- losses -----------------------------------# +#-----------------------------------------------------------------------------# + +class WeightedLoss(nn.Module): + + def __init__(self, weights=None): + super().__init__() + self.register_buffer('weights', weights) + + def forward(self, pred, targ): + ''' + pred, targ : tensor + [ batch_size x horizon x transition_dim ] + ''' + loss = self._loss(pred, targ) + if self.weights is not None: + weighted_loss = (loss * self.weights).mean() + else: + weighted_loss = loss.mean() + return weighted_loss, {} + + +class WeightedL1(WeightedLoss): + + def _loss(self, pred, targ): + return torch.abs(pred - targ) + + +class WeightedL2(WeightedLoss): + + def _loss(self, pred, targ): + return F.mse_loss(pred, targ, reduction='none') + + +Losses = { + 'l1': WeightedL1, + 'l2': WeightedL2, +} + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/mlp_score_model.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/mlp_score_model.py new file mode 100644 index 0000000000000000000000000000000000000000..4b8d0c6b270c34f317fa37f8f83856cac2584954 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/mlp_score_model.py @@ -0,0 +1,224 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import numpy as np +import torch +import torch.nn as nn + +from mmd.models.layers.layers import GaussianFourierProjection, Downsample1d, Conv1dBlock, Upsample1d, \ + ResidualTemporalBlock, TimeEncoder, MLP +from mmd.models.layers.layers_attention import SpatialTransformer +from mmd.models.helpers.marginal_prob import reshape_std +from .score_model_base import ScoreModelBase + + +class MLPScoreModel(ScoreModelBase): + ''' + Simple model using dictionaries + ''' + + def __init__(self, marginal_prob_get_std=None, + input_dim=None, hidden_dim=None, context_embed_dim=0, + time_embed_dim=10, + act='relu', + input_field='x', + output_field='dx', + context_field='c', + n_layers=4, **kwargs): + """ + Args: + in_dim: + out_dim: + hidden_dim: + marginal_prob_std: + time_embed_dim: + act: + input_field: + context_field: Context to be used (e.g. state) if None then it's excluded + """ + super(MLPScoreModel, self).__init__( + marginal_prob_get_std, + input_dim, + context_embed_dim, + input_field, + output_field, + context_field + ) + activations = {'relu': nn.ReLU, 'sigmoid': nn.Sigmoid, 'tanh': nn.Tanh, 'leaky_relu': nn.LeakyReLU, + 'elu': nn.ELU, 'prelu': nn.PReLU, 'softplus': nn.Softplus} + + self.act_func = activations[act] + + # Gaussian random feature embedding layer for time + self.time_embedding = nn.Sequential(GaussianFourierProjection(embed_dim=time_embed_dim), + nn.Linear(time_embed_dim, time_embed_dim), self.act_func()) + + self.n_layers = n_layers + + layers = [nn.Linear(time_embed_dim + context_embed_dim + np.prod(input_dim), hidden_dim), self.act_func()] + for n in range(n_layers): + layers.append(nn.Linear(hidden_dim, hidden_dim)) + layers.append(self.act_func()) + layers.append(nn.Linear(hidden_dim, np.prod(input_dim))) + self._net = nn.Sequential(*layers) + self.flatten = nn.Flatten() + + def compute_unnormalized_score(self, input_dict): + x = self.flatten(input_dict[self.input_field]) + t = input_dict['t'] + ## Embed time + # Obtain the Gaussian random feature embedding for t + time_embed = self.time_embedding(t) + ## Embed Input + if self.context_field in input_dict: + context = input_dict[self.context_field] + x_t = torch.cat((x, context, time_embed), dim=1) + else: + x_t = torch.cat((x, time_embed), dim=1) + out = self._net(x_t) + + return out + + +class ContextScoreModel(MLPScoreModel): + def __init__(self, + env_model=None, + task_model=None, + initial_config_field=None, + initial_config_dim=0, + **kwargs): + context_embed_dim = env_model.out_dim + task_model.out_dim + initial_config_dim + super(ContextScoreModel, self).__init__(**kwargs, context_embed_dim=context_embed_dim) + self.env_model = env_model + self.task_model = task_model + + self.initial_config_field = initial_config_field + self.initial_config_dim = initial_config_dim + + def forward_old(self, input): + env = self.env_model(input)[self.env_model.output_field] + task = self.task_model(input)[self.task_model.output_field] + # env = input[self.env_model.output_field] + # tasks = input[self.task_model.output_field] + start = input[self.initial_config_field] + context_embed = torch.cat((env, start, task), dim=1) + + score_model_input = {**input, self.context_field: context_embed} + return self._forward(score_model_input) + + def compute_unnormalized_score(self, input_dict): + x = self.flatten(input_dict[self.input_field]) + t = input_dict['t'] + + # Embed Context + env = self.env_model(input_dict)[self.env_model.output_field] + task = self.task_model(input_dict)[self.task_model.output_field] + start = input_dict[self.initial_config_field] + + ## Embed time + # Obtain the Gaussian random feature embedding for t + time_embed = self.time_embedding(t) + + xt = torch.cat((x, env, task, start, time_embed), dim=1) + out = self._net(xt) + + return {self.output_field: out} + + +class SDFScoreModel(MLPScoreModel): + def __init__(self, + env_model=None, + task_model=None, + sdf_model=None, + initial_config_field=None, + initial_config_dim=0, + use_sdf_value=False, + n_support_points=20, + **kwargs): + context_embed_dim = env_model.out_dim + task_model.out_dim + \ + (n_support_points * sdf_model.out_dim if use_sdf_value else 0) + super(SDFScoreModel, self).__init__(**kwargs, context_embed_dim=context_embed_dim) + self.env_model = env_model + self.task_model = task_model + self.sdf_model = sdf_model + + self.use_sdf = use_sdf_value + self.n_support_points = n_support_points + + self.initial_config_field = initial_config_field + self.initial_config_dim = initial_config_dim + + def compute_unnormalized_score(self, input_dict): + orig_shape = input_dict[self.input_field].shape + x = self.flatten(input_dict[self.input_field]) + t = input_dict['t'] + + if self.sdf_model.sdf_location_field not in input_dict: + batch_size, flat = x.shape + locations = x.reshape(batch_size, flat // 2, 2)[:, 0, :].squeeze(1) + input_dict[self.sdf_model.sdf_location_field] = locations + + # Embed Context + env = self.env_model(input_dict)[self.env_model.output_field] + task = self.task_model(input_dict)[self.task_model.output_field] + # start = input_dict[self.initial_config_field] + + # Run SDF + sdf = self.sdf_model({**input_dict, self.sdf_model.input_field: env}) + + ## Embed time + # Obtain the Gaussian random feature embedding for t + time_embed = self.time_embedding(t) + + xt = torch.cat((x, env, task, time_embed), dim=1) + out = self._net(xt) + out = out.reshape(orig_shape) + + return {**sdf, self.output_field: out} + + +class SharedFeatureSDFScoreModel(MLPScoreModel): + def __init__(self, + env_model=None, + task_model=None, + sdf_model=None, + initial_config_field=None, + initial_config_dim=0, + use_sdf_value=False, + n_support_points=20, + **kwargs): + context_embed_dim = sdf_model.hidden_dim + task_model.out_dim + initial_config_dim + \ + (n_support_points * sdf_model.out_dim if use_sdf_value else 0) + super(SharedFeatureSDFScoreModel, self).__init__(**kwargs, context_embed_dim=context_embed_dim) + self.env_model = env_model + self.task_model = task_model + self.sdf_model = sdf_model + + self.use_sdf = use_sdf_value + self.n_support_points = n_support_points + + self.initial_config_field = initial_config_field + self.initial_config_dim = initial_config_dim + + def compute_unnormalized_score(self, input_dict): + x = self.flatten(input_dict[self.input_field]) + t = input_dict['t'] + + # Embed Context + env = self.env_model(input_dict)[self.env_model.output_field] + task = self.task_model(input_dict)[self.task_model.output_field] + start = input_dict[self.initial_config_field] + + # Run SDF + sdf = self.sdf_model({**input_dict, self.sdf_model.input_field: env, + self.sdf_model.sdf_location_field: x}) + sdf_features = sdf[self.sdf_model.feature_field] + + ## Embed time + # Obtain the Gaussian random feature embedding for t + time_embed = self.time_embedding(t) + + xt = torch.cat((x, sdf_features, task, start, time_embed), dim=1) + out = self._net(xt) + + return {**sdf, self.output_field: out} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sample_functions.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sample_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..60daf83a5df0d4e0d40bc8603c6aa2f2df512767 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sample_functions.py @@ -0,0 +1,237 @@ +""" +Modified from +- https://github.com/jacarvalho/mpd-public +- https://github.com/yoraish/mmd +""" +import torch +from typing import Union, List, Tuple, Dict +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +from mmd.models.projection.projectors import BaseProjector + + +def apply_hard_conditioning_old(x, conditions): + for t, val in conditions.items(): + x[:, t, :] = val.clone() + # Set angle (dx dy) to be in the direction of the position finite difference. + # dir_finite_diff = x[:, 1:, 0:2] - x[:, 0:-1, 0:2] + # x[:, 1:, 2:4] = dir_finite_diff / torch.norm(dir_finite_diff, dim=-1, keepdim=True) + return x + +def apply_hard_conditioning( + x:torch.Tensor, + conditions:Dict[Union[int, str], torch.Tensor] +) -> torch.Tensor: + """ + Support groupwise hard conditioning, where the conditions are given in groups. + """ + if "group_size" not in conditions: + # Fall back to the old version where all conditions are applied to all samples. + for t, val in conditions.items(): + x[:, t, :] = val.clone() + return x + + grp_sz = conditions.get("group_size", x.shape[0]) + grp_inds = torch.arange(x.shape[0], device=x.device).split(grp_sz) + for t, val in conditions.items(): + if t == 'group_size': + continue + for g, b in enumerate(grp_inds): + x[b, t, :] = val[:b.size(0), g, :].clone() # hard_conds is in shape (B, G, D), where G is the group size. + return x + +def apply_cross_conditioning(x, conditions, transforms): + for (m1, m2), (ind1, ind2) in conditions.items(): + transform_m1 = transforms[m1] + transform_m2 = transforms[m2] + relative_transform = transform_m2 - transform_m1 + if x[m1].shape[2] > relative_transform.shape[0]: + relative_transform = torch.cat([relative_transform, + torch.zeros(x[m1].shape[2] - relative_transform.shape[0], + device=relative_transform.device)]) + boundary = relative_transform / torch.norm(relative_transform, keepdim=True) + # if element in boundary is 0, set it to be very big + boundary[boundary == 0] = 1e6 + x[m1][:, ind1, :] = torch.min(x[m2][:, ind2, :] + relative_transform, boundary) + x[m2][:, ind2, :] = torch.max(x[m1][:, ind1, :] - relative_transform, -boundary) + return x + + +def extract(a, t, x_shape): + b, *_ = t.shape + out = a.gather(-1, t) + return out.reshape(b, *((1,) * (len(x_shape) - 1))) + + +@torch.no_grad() +def ddpm_sample_fn( + model, x, hard_conds, context, t, + guide=None, + n_guide_steps=1, + scale_grad_by_std=False, + t_start_guide=torch.inf, + noise_std_extra_schedule_fn=None, # 'linear' + **kwargs +): + t_single = t[0] + # By convention, a time less than zero gets zero noise. That is, sampling to update mean is computed to until t=0 and then only guidance and sampling, without updating the mean, is done. + if t_single < 0: + t = torch.zeros_like(t) + + model_mean, _, model_log_variance = model.p_mean_variance(x=x, hard_conds=hard_conds, context=context, t=t) + x = model_mean + + model_log_variance = extract(model.posterior_log_variance_clipped, t, x.shape) + model_std = torch.exp(0.5 * model_log_variance) + model_var = torch.exp(model_log_variance) + + if guide is not None and t_single < t_start_guide: + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + n_guide_steps=n_guide_steps, + scale_grad_by_std=scale_grad_by_std, + model_var=model_var, + ) + + noise = torch.randn_like(x) + # No noise when t = 0. + noise[t == 0] = 0 + + # For smoother results, we can decay the noise standard deviation throughout the diffusion + # this is roughly equivalent to using a temperature in the prior distribution + if noise_std_extra_schedule_fn is None: + noise_std = 1.0 + else: + noise_std = noise_std_extra_schedule_fn(t_single) + + values = None + return x + model_std * noise * noise_std, values + + +def guide_gradient_steps( + x, + hard_conds=None, + guide=None, + n_guide_steps=1, scale_grad_by_std=False, + model_var=None, + **kwargs +): + for _ in range(n_guide_steps): + grad_scaled = guide(x, **kwargs) + + if scale_grad_by_std: + grad_scaled = model_var * grad_scaled + + x = x + grad_scaled + x = apply_hard_conditioning(x, hard_conds) + + return x + + +@torch.no_grad() +def projected_coupling_ddpm_sample_fn( + model, x, hard_conds, context, t, + guide=None, + projector:BaseProjector=None, + guide_kwargs={}, + project_params=None, + n_guide_steps=1, + scale_grad_by_std=False, + t_start_guide=torch.inf, + noise_std_extra_schedule_fn=None, # 'linear' + **kwargs +): + t_single = t[0] + # By convention, a time less than zero gets zero noise. That is, sampling to update mean is computed to until t=0 and then only guidance and sampling, without updating the mean, is done. + if t_single < 0: + raise ValueError(f"t must be non-negative for sampling. Got t = {t_single}.") + t = torch.zeros_like(t) + + if t_single == 0: + ## Not doing inpainting at t==0 to enforce projection constraints. + t_stps = [] + for k in hard_conds.keys(): + if isinstance(k, int) and k > 0 and k == x.shape[-2]-1: # x[..., H, D] + t_stps.append(k) + max_t = max(t_stps) if t_stps else None + if max_t is not None: + max_t_hard_cond = { + max_t: hard_conds.pop(max_t) + } + + with TimerCUDA() as timer_diffuse: + model_mean, _, model_log_variance = model.p_mean_variance(x=x, hard_conds=hard_conds, context=context, t=t) + t_diffuse = timer_diffuse.elapsed + + x = model_mean + model_log_variance = extract(model.posterior_log_variance_clipped, t, x.shape) + model_std = torch.exp(0.5 * model_log_variance) + model_var = torch.exp(model_log_variance) + + with TimerCUDA() as timer_coupling: + if guide is not None and t_single < t_start_guide: + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + n_guide_steps=n_guide_steps, + scale_grad_by_std=scale_grad_by_std, + model_var=model_var, + **guide_kwargs + ) + t_coupling = timer_coupling.elapsed + + noise = torch.randn_like(x) + # No noise when t = 0. + noise[t == 0] = 0 + + # For smoother results, we can decay the noise standard deviation throughout the diffusion + # this is roughly equivalent to using a temperature in the prior distribution + if noise_std_extra_schedule_fn is None: + noise_std = 1.0 + else: + noise_std = noise_std_extra_schedule_fn(t_single) + + x = x + model_std * noise * noise_std + + ##---------------------- Projector step ----------------------## + t_project = 0.0 + x_before_proj = x.detach().cpu().clone() if kwargs.get('intermediates_log') is not None else None + if projector is not None: + assert project_params is not None, "Projector parameters must be provided." + assert 'dx_max' in project_params, "Projector parameters must include 'dx_max'." + assert 'pos_init' in project_params, "Projector parameters must include 'pos_init'." + with TimerCUDA() as timer_project: + x_1_on_pos = projector.project( + x[:, 1:, :2], + params=project_params, + ) + t_project = timer_project.elapsed + x[:, 1:, :2] = x_1_on_pos + + # Optionally log intermediates for visualization/debugging + intermediates_log = kwargs.get('intermediates_log', None) + if intermediates_log is not None: + step_data = { + 't': int(t_single), + 'x': x.detach().cpu().clone(), + } + if projector is not None: + step_data['x_before_proj'] = x_before_proj + step_data['x_after_proj'] = x.detach().cpu().clone() + intermediates_log.append(step_data) + + values = None + time_elapsed = { + 'diffuse': t_diffuse, + 'coupling': t_coupling, + 'project': t_project, + } + + if 'timeit' in kwargs and kwargs['timeit']: + return x, values, time_elapsed + else: + return x, values diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sbm_likelihood.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sbm_likelihood.py new file mode 100644 index 0000000000000000000000000000000000000000..c264a3ff312cf1336233c620f9a574f5adeca290 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/sbm_likelihood.py @@ -0,0 +1,97 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import numpy as np +import torch +from scipy import integrate + + +def prior_likelihood(z, sigma): + """The likelihood of a Gaussian distribution with mean zero and + standard deviation sigma.""" + shape = z.shape + N = np.prod(shape[1:]) + return -N / 2. * torch.log(2 * np.pi * sigma ** 2) - torch.sum(z ** 2, dim=(1, 2, 3)) / (2 * sigma ** 2) + + +def ode_likelihood(x, + score_model, + marginal_prob_std, + diffusion_coeff, + batch_size=64, + device='cuda', + eps=1e-5): + """Compute the likelihood with probability flow ODE. + + Args: + x: Input data. + score_model: A PyTorch model representing the score-based model. + marginal_prob_std: A function that gives the standard deviation of the + perturbation kernel. + diffusion_coeff: A function that gives the diffusion coefficient of the + forward SDE. + batch_size: The batch size. Equals to the leading dimension of `x`. + device: 'cuda' for evaluation on GPUs, and 'cpu' for evaluation on CPUs. + eps: A `float` number. The smallest time step for numerical stability. + + Returns: + z: The latent code for `x`. + bpd: The log-likelihoods in bits/dim. + """ + + # Draw the random Gaussian sample for Skilling-Hutchinson's estimator. + epsilon = torch.randn_like(x) + + def divergence_eval(sample, time_steps, epsilon): + """Compute the divergence of the score-based model with Skilling-Hutchinson.""" + with torch.enable_grad(): + sample.requires_grad_(True) + score_e = torch.sum(score_model(sample, time_steps) * epsilon) + grad_score_e = torch.autograd.grad(score_e, sample)[0] + return torch.sum(grad_score_e * epsilon, dim=(1, 2, 3)) + + shape = x.shape + + def score_eval_wrapper(sample, time_steps): + """A wrapper for evaluating the score-based model for the black-box ODE solver.""" + sample = torch.tensor(sample, device=device, dtype=torch.float32).reshape(shape) + time_steps = torch.tensor(time_steps, device=device, dtype=torch.float32).reshape((sample.shape[0],)) + with torch.no_grad(): + score = score_model(sample, time_steps) + return score.cpu().numpy().reshape((-1,)).astype(np.float64) + + def divergence_eval_wrapper(sample, time_steps): + """A wrapper for evaluating the divergence of score for the black-box ODE solver.""" + with torch.no_grad(): + # Obtain x(t) by solving the probability flow ODE. + sample = torch.tensor(sample, device=device, dtype=torch.float32).reshape(shape) + time_steps = torch.tensor(time_steps, device=device, dtype=torch.float32).reshape((sample.shape[0],)) + # Compute likelihood. + div = divergence_eval(sample, time_steps, epsilon) + return div.cpu().numpy().reshape((-1,)).astype(np.float64) + + def ode_func(t, x): + """The ODE function for the black-box solver.""" + time_steps = np.ones((shape[0],)) * t + sample = x[:-shape[0]] + logp = x[-shape[0]:] + g = diffusion_coeff(torch.tensor(t)).cpu().numpy() + sample_grad = -0.5 * g ** 2 * score_eval_wrapper(sample, time_steps) + logp_grad = -0.5 * g ** 2 * divergence_eval_wrapper(sample, time_steps) + return np.concatenate([sample_grad, logp_grad], axis=0) + + init = np.concatenate([x.cpu().numpy().reshape((-1,)), np.zeros((shape[0],))], axis=0) + # Black-box ODE solver + res = integrate.solve_ivp(ode_func, (eps, 1.), init, rtol=1e-5, atol=1e-5, method='RK45') + zp = torch.tensor(res.y[:, -1], device=device) + z = zp[:-shape[0]].reshape(shape) + delta_logp = zp[-shape[0]:].reshape(shape[0]) + sigma_max = marginal_prob_std(1.) + prior_logp = prior_likelihood(z, sigma_max) + bpd = -(prior_logp + delta_logp) / np.log(2) + N = np.prod(shape[1:]) + bpd = bpd / N + 8. + return z, bpd + + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/score_model_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/score_model_base.py new file mode 100644 index 0000000000000000000000000000000000000000..a11c019855601f9df5c07096dfa84144b02dd3fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/score_model_base.py @@ -0,0 +1,45 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch.nn as nn +from abc import ABC +from mmd.models.helpers.marginal_prob import reshape_std + + +class ScoreModelBase(nn.Module, ABC): + + def __init__(self, + marginal_prob_get_std=None, + input_dim=None, + context_dim=0, + input_field='x', + output_field='grad_x_log_p_x', + context_field='context', + **kwargs): + + super().__init__() + + self.input_dim = input_dim + self.context_dim = context_dim + self.output_dim = input_dim # by default, score models output's dimension is the same as the input + self.marginal_prob_get_std = marginal_prob_get_std + self.input_field = input_field + self.output_field = output_field + self.context_field = context_field + + def forward(self, input_dict): + out = self.compute_unnormalized_score(input_dict) + if type(out) is dict: + out_score = out[self.output_field] + else: + out_score = out + out = {} + # Normalize output + t = input_dict['t'] + std = reshape_std(self.marginal_prob_get_std(t), out_score) + out_score = out_score / std + return {**out, self.output_field: out_score} + + def compute_unnormalized_score(self, data): + raise NotImplementedError + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/temporal_unet.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/temporal_unet.py new file mode 100644 index 0000000000000000000000000000000000000000..2f961c5c625e883dabb0fa8aba0df65f6046a8d5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/diffusion_models/temporal_unet.py @@ -0,0 +1,367 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import einops +import numpy as np +import torch +import torch.nn as nn +from abc import ABC +from einops import rearrange +from torch.nn import DataParallel + +from mmd.models.layers.layers import GaussianFourierProjection, Downsample1d, Conv1dBlock, Upsample1d, \ + ResidualTemporalBlock, TimeEncoder, MLP, group_norm_n_groups, LinearAttention, PreNorm, Residual, TemporalBlockMLP +from mmd.models.layers.layers_attention import SpatialTransformer + + +UNET_DIM_MULTS = { + 0: (1, 2, 4), + 1: (1, 2, 4, 8) +} + + +class TemporalUnet(nn.Module): + + def __init__( + self, + n_support_points=None, + state_dim=None, + unet_input_dim=32, + dim_mults=(1, 2, 4, 8), + time_emb_dim=32, + self_attention=False, + conditioning_embed_dim=4, + conditioning_type=None, + attention_num_heads=2, + attention_dim_head=32, + **kwargs + ): + super().__init__() + + self.state_dim = state_dim + input_dim = state_dim + + # Conditioning + if conditioning_type is None or conditioning_type == 'None': + conditioning_type = None + elif conditioning_type == 'concatenate': + if self.state_dim < conditioning_embed_dim // 4: + # Embed the state in a latent space HxF if the conditioning embedding is much larger than the state + state_emb_dim = conditioning_embed_dim // 4 + self.state_encoder = MLP(state_dim, state_emb_dim, hidden_dim=state_emb_dim//2, n_layers=1, act='mish') + else: + state_emb_dim = state_dim + self.state_encoder = nn.Identity() + input_dim = state_emb_dim + conditioning_embed_dim + elif conditioning_type == 'attention': + pass + elif conditioning_type == 'default': + pass + else: + raise NotImplementedError + self.conditioning_type = conditioning_type + + dims = [input_dim, *map(lambda m: unet_input_dim * m, dim_mults)] + in_out = list(zip(dims[:-1], dims[1:])) + print(f'[ models/temporal ] Channel dimensions: {in_out}') + + # Networks + self.time_mlp = TimeEncoder(32, time_emb_dim) + + # conditioning dimension (time + context) + cond_dim = time_emb_dim + (conditioning_embed_dim if conditioning_type == 'default' else 0) + + # Unet + self.downs = nn.ModuleList([]) + self.ups = nn.ModuleList([]) + num_resolutions = len(in_out) + + for ind, (dim_in, dim_out) in enumerate(in_out): + is_last = ind >= (num_resolutions - 1) + + self.downs.append(nn.ModuleList([ + ResidualTemporalBlock(dim_in, dim_out, cond_dim, n_support_points=n_support_points), + ResidualTemporalBlock(dim_out, dim_out, cond_dim, n_support_points=n_support_points), + Residual(PreNorm(dim_out, LinearAttention(dim_out))) if self_attention else nn.Identity(), + SpatialTransformer(dim_out, attention_num_heads, attention_dim_head, depth=1, + context_dim=conditioning_embed_dim) if conditioning_type == 'attention' else None, + Downsample1d(dim_out) if not is_last else nn.Identity() + ])) + + if not is_last: + n_support_points = n_support_points // 2 + + mid_dim = dims[-1] + self.mid_block1 = ResidualTemporalBlock(mid_dim, mid_dim, cond_dim, n_support_points=n_support_points) + self.mid_attn = Residual(PreNorm(mid_dim, LinearAttention(mid_dim))) if self_attention else nn.Identity() + self.mid_attention = SpatialTransformer(mid_dim, attention_num_heads, attention_dim_head, depth=1, + context_dim=conditioning_embed_dim) if conditioning_type == 'attention' else nn.Identity() + self.mid_block2 = ResidualTemporalBlock(mid_dim, mid_dim, cond_dim, n_support_points=n_support_points) + + for ind, (dim_in, dim_out) in enumerate(reversed(in_out[1:])): + is_last = ind >= (num_resolutions - 1) + + self.ups.append(nn.ModuleList([ + ResidualTemporalBlock(dim_out * 2, dim_in, cond_dim, n_support_points=n_support_points), + ResidualTemporalBlock(dim_in, dim_in, cond_dim, n_support_points=n_support_points), + Residual(PreNorm(dim_in, LinearAttention(dim_in))) if self_attention else nn.Identity(), + SpatialTransformer(dim_in, attention_num_heads, attention_dim_head, depth=1, + context_dim=conditioning_embed_dim) if conditioning_type == 'attention' else None, + Upsample1d(dim_in) if not is_last else nn.Identity() + ])) + + if not is_last: + n_support_points = n_support_points * 2 + + self.final_conv = nn.Sequential( + Conv1dBlock(unet_input_dim, unet_input_dim, kernel_size=5, n_groups=group_norm_n_groups(unet_input_dim)), + nn.Conv1d(unet_input_dim, state_dim, 1), + ) + + def forward(self, x, time, context): + """ + x : [ batch x horizon x state_dim ] + context: [batch x context_dim] + """ + b, h, d = x.shape + + t_emb = self.time_mlp(time) + c_emb = t_emb + if self.conditioning_type == 'concatenate': + x_emb = self.state_encoder(x) + context = einops.repeat(context, 'm n -> m h n', h=h) + x = torch.cat((x_emb, context), dim=-1) + elif self.conditioning_type == 'attention': + # reshape to keep the interface + context = einops.rearrange(context, 'b d -> b 1 d') + elif self.conditioning_type == 'default': + c_emb = torch.cat((t_emb, context), dim=-1) + + # swap horizon and channels (state_dim) + x = einops.rearrange(x, 'b h c -> b c h') # batch, horizon, channels (state_dim) + + h = [] + for resnet, resnet2, attn_self, attn_conditioning, downsample in self.downs: + x = resnet(x, c_emb) + # if self.conditioning_type == 'attention': + # x = attention1(x, context=conditioning_emb) + x = resnet2(x, c_emb) + x = attn_self(x) + if self.conditioning_type == 'attention': + x = attn_conditioning(x, context=context) + h.append(x) + x = downsample(x) + + x = self.mid_block1(x, c_emb) + x = self.mid_attn(x) + if self.conditioning_type == 'attention': + x = self.mid_attention(x, context=context) + x = self.mid_block2(x, c_emb) + + for resnet, resnet2, attn_self, attn_conditioning, upsample in self.ups: + x = torch.cat((x, h.pop()), dim=1) + x = resnet(x, c_emb) + x = resnet2(x, c_emb) + x = attn_self(x) + if self.conditioning_type == 'attention': + x = attn_conditioning(x, context=context) + x = upsample(x) + + x = self.final_conv(x) + + x = einops.rearrange(x, 'b c h -> b h c') + + return x + + +class EnvModel(nn.Module): + + def __init__( + self, + in_dim=16, + out_dim=16, + **kwargs + ): + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + self.net = nn.Identity() + + def forward(self, input_d): + env = input_d['env'] + env_emb = self.net(env) + return env_emb + + +class TaskModel(nn.Module): + + def __init__( + self, + in_dim=16, + out_dim=32, + **kwargs + ): + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + self.net = nn.Identity() + + def forward(self, input_d): + task = input_d['tasks'] + task_emb = self.net(task) + return task_emb + + +class TaskModelNew(nn.Module): + + def __init__( + self, + in_dim=16, + out_dim=32, + **kwargs + ): + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + self.net = nn.Identity() + + def forward(self, task): + task_emb = self.net(task) + return task_emb + + +class ContextModel(nn.Module): + + def __init__( + self, + env_model=None, + task_model=None, + out_dim=32, + **kwargs + ): + super().__init__() + + self.env_model = env_model + self.task_model = task_model + + self.in_dim = self.env_model.out_dim + self.task_model.out_dim + + # self.out_dim = out_dim + # self.net = MLP(self.in_dim, self.out_dim, hidden_dim=out_dim, n_layers=1, act='mish') + + self.out_dim = self.in_dim + self.net = nn.Identity() + + def forward(self, input_d=None): + if input_d is None: + return None + env_emb = self.env_model(input_d) + task_emb = self.task_model(input_d) + context = torch.cat((env_emb, task_emb), dim=-1) + context_emb = self.net(context) + return context_emb + + +class PointUnet(nn.Module): + + def __init__( + self, + n_support_points=None, + state_dim=None, + dim=32, + dim_mults=(1, 2, 4), + time_emb_dim=32, + conditioning_embed_dim=4, + conditioning_type=None, + **kwargs + ): + super().__init__() + + self.dim_mults = dim_mults + + self.state_dim = state_dim + input_dim = state_dim + + # Conditioning + if conditioning_type is None or conditioning_type == 'None': + conditioning_type = None + elif conditioning_type == 'concatenate': + if self.state_dim < conditioning_embed_dim // 4: + # Embed the state in a latent space HxF if the conditioning embedding is much larger than the state + state_emb_dim = conditioning_embed_dim // 4 + self.state_encoder = MLP(state_dim, state_emb_dim, hidden_dim=state_emb_dim//2, n_layers=1, act='mish') + else: + state_emb_dim = state_dim + self.state_encoder = nn.Identity() + input_dim = state_emb_dim + conditioning_embed_dim + elif conditioning_type == 'default': + pass + else: + raise NotImplementedError + self.conditioning_type = conditioning_type + + dims = [input_dim, *map(lambda m: dim * m, self.dim_mults)] + in_out = list(zip(dims[:-1], dims[1:])) + print(f'[ models/temporal ] Channel dimensions: {in_out}') + + # Networks + self.time_mlp = TimeEncoder(32, time_emb_dim) + + # conditioning dimension (time + context) + cond_dim = time_emb_dim + (conditioning_embed_dim if conditioning_type == 'default' else 0) + + # Unet + self.downs = nn.ModuleList([]) + self.ups = nn.ModuleList([]) + + for ind, (dim_in, dim_out) in enumerate(in_out): + self.downs.append(nn.ModuleList([ + TemporalBlockMLP(dim_in, dim_out, cond_dim) + ])) + + mid_dim = dims[-1] + self.mid_block1 = TemporalBlockMLP(mid_dim, mid_dim, cond_dim) + + for ind, (dim_in, dim_out) in enumerate(reversed(in_out[1:])): + self.ups.append(nn.ModuleList([ + TemporalBlockMLP(dim_out * 2, dim_in, cond_dim) + ])) + + self.final_layer = nn.Sequential( + MLP(dim, state_dim, hidden_dim=dim, n_layers=0, act='identity') + ) + + def forward(self, x, time, context): + """ + x : [ batch x horizon x state_dim ] + context: [batch x context_dim] + """ + x = einops.rearrange(x, 'b 1 d -> b d') + + t_emb = self.time_mlp(time) + c_emb = t_emb + if self.conditioning_type == 'concatenate': + x_emb = self.state_encoder(x) + x = torch.cat((x_emb, context), dim=-1) + elif self.conditioning_type == 'default': + c_emb = torch.cat((t_emb, context), dim=-1) + + h = [] + for resnet, in self.downs: + x = resnet(x, c_emb) + h.append(x) + + x = self.mid_block1(x, c_emb) + + for resnet, in self.ups: + x = torch.cat((x, h.pop()), dim=1) + x = resnet(x, c_emb) + + x = self.final_layer(x) + + x = einops.rearrange(x, 'b d -> b 1 d') + + return x diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..967554d017a078d00435723b445b6053bae026ac --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/__init__.py @@ -0,0 +1,2 @@ +from .mlp_model import * +from .no_model import * \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/mlp_model.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/mlp_model.py new file mode 100644 index 0000000000000000000000000000000000000000..1cfd2fcc97aaac6f343cabf0fb00738734110884 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/mlp_model.py @@ -0,0 +1,27 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +from torch import nn + +from mmd.models.layers.layers import MLP + + +class MLPModel(nn.Module): + def __init__(self, in_dim=16, out_dim=16, input_field='x', + output_field='y', + pretrained_dir=None, + **kwargs): + super().__init__() + + self.input_field = input_field + self.output_field = output_field + + self.in_dim = in_dim + self.out_dim = out_dim + + self._net = MLP(in_dim, out_dim, **kwargs) + + def forward(self, input): + x = input[self.input_field] + out = self._net(x) + return {self.output_field: out} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/no_model.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/no_model.py new file mode 100644 index 0000000000000000000000000000000000000000..7a389bab1404711b1ff8f6d3cbd441b726dac2f7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/generic/no_model.py @@ -0,0 +1,20 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch.nn as nn +class NoModel(nn.Module): + """ + Does nothing. Simply acts as a placeholder to keep the interface. + """ + + def __init__(self, in_dim=16, out_dim=16, input_field='x', + output_field='y', **kwargs): + self.input_field = input_field + self.output_field = output_field + + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + def forward(self, input): + return input diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..16281fe0b66dbac563229823d656ef173736e306 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/__init__.py @@ -0,0 +1 @@ +from .utils import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..96177b9e2472ba86b34201fc43490de6034dba0d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/helpers/utils.py @@ -0,0 +1,12 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" + + +def calc_output(in_dim, filter_size, padding=0, stride=1): + x = in_dim - filter_size + (2 * padding) + (filter_size % 2) + return (x / stride) + (1 - (filter_size % 2)) + + +def calc_output_conv2d_transpose(in_dim, filter_size, padding=0, stride=1, dilation=1): + return (in_dim - 1) * stride - 2 * padding + dilation * (filter_size - 1) + 1 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c2b8b5916dcc0dcfa43bc2d7ab8ec9952bf80d71 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/__init__.py @@ -0,0 +1,4 @@ +from .equiv_layers import * +from .layers import * +from .layers_attention import * +from .identity import Identity \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/equiv_layers.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/equiv_layers.py new file mode 100644 index 0000000000000000000000000000000000000000..21f2a21db004a6a766cb888b3f9fc1c494459892 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/equiv_layers.py @@ -0,0 +1,369 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch +import torch.nn as nn +import einops + +EPS = 1e-6 + + +def knn(x, k): + inner = -2 * torch.matmul(x.transpose(2, 1), x) + xx = torch.sum(x ** 2, dim=1, keepdim=True) + pairwise_distance = -xx - inner - xx.transpose(2, 1) + + idx = pairwise_distance.topk(k=k, dim=-1)[1] # (batch_size, num_points, k) + return idx + + +def get_graph_feature(x, k=20, idx=None, x_coord=None, device='cpu'): + batch_size = x.size(0) + num_points = x.size(3) + x = x.view(batch_size, -1, num_points) + if idx is None: + if x_coord is not None: # dynamic knn graph + idx = knn(x_coord, k=k) # (batch_size, num_points, k) + else: # fixed knn graph with input point coordinates + idx = knn(x, k=k) + + idx_base = torch.arange(0, batch_size, device=device).view(-1, 1, 1) * num_points + + idx = idx + idx_base + + idx = idx.view(-1) + + _, num_dims, _ = x.size() + num_dims = num_dims // 3 + + x = x.transpose(2, + 1).contiguous() # (batch_size, num_points, num_dims) -> (batch_size*num_points, num_dims) # batch_size * num_points * k + range(0, batch_size*num_points) + feature = x.view(batch_size * num_points, -1)[idx, :] + feature = feature.view(batch_size, num_points, k, num_dims, 3) + x = x.view(batch_size, num_points, 1, num_dims, 3).repeat(1, 1, k, 1, 1) + + feature = torch.cat((feature - x, x), dim=3).permute(0, 3, 4, 1, 2).contiguous() + + return feature + + +def get_graph_feature_cross(x, k=20, idx=None, device='cpu'): + + x = einops.rearrange(x, 'b 1 d n -> b d n') + batch_size = x.size(0) + num_dims = x.size(1) + num_points = x.size(2) + + + if idx is None: + idx = knn(x, k=k) # (batch_size, num_points, k) + + idx_base = einops.rearrange(torch.arange(0, batch_size, device=device), 'b -> b 1 1') * num_points + + idx = idx + idx_base + + idx = einops.rearrange(idx, 'b n k -> (b n k)') + + c = num_dims // 3 + + x = einops.rearrange(x, 'b d n -> b n d').contiguous() + #x = x.transpose(2,1).contiguous() + + feature = einops.rearrange(x, 'b n d -> (b n) d')[idx, :] + #feature = x.view(batch_size * num_points, -1)[idx, :] + + feature = feature.view(batch_size, num_points, k, c, 3) + + #x = x.view(batch_size, num_points, 1, num_dims, 3).repeat(1, 1, k, 1, 1) + x = einops.repeat(x, 'b n d -> b n k c d', k=k, c=c) + cross = torch.cross(feature, x, dim=-1) + + feature = torch.cat((feature - x, x, cross), dim=3) + + #feature = feature.permute(0, 3, 4, 1, 2).contiguous() + feature = einops.rearrange(feature, 'b n k c d -> b c d n k').contiguous() + + return feature + + +def get_graph_mean(x, k=20, idx=None): + batch_size = x.size(0) + num_points = x.size(3) + x = x.reshape(batch_size, -1, num_points).contiguous() + if idx is None: + idx = knn(x, k=k) # (batch_size, num_points, k) + device = torch.device('cuda') + + idx_base = torch.arange(0, batch_size, device=device).view(-1, 1, 1) * num_points + + idx = idx + idx_base + + idx = idx.view(-1) + + _, num_dims, _ = x.size() + num_dims = num_dims // 3 + + x = x.transpose(2, + 1).contiguous() # (batch_size, num_points, num_dims) -> (batch_size*num_points, num_dims) # batch_size * num_points * k + range(0, batch_size*num_points) + feature = x.view(batch_size * num_points, -1)[idx, :] + feature = feature.view(batch_size, num_points, k, num_dims, 3).mean(2, keepdim=False) + x = x.view(batch_size, num_points, num_dims, 3) + + feature = (feature - x).permute(0, 2, 3, 1).contiguous() + + return feature + + +def get_shell_mean_cross(x, k=10, nk=4, idx_all=None): + batch_size = x.size(0) + num_points = x.size(3) + x = x.reshape(batch_size, -1, num_points).contiguous() + if idx_all is None: + idx_all = knn(x, k=nk * k) # (batch_size, num_points, k) + device = torch.device('cuda') + + idx_base = torch.arange(0, batch_size, device=device).view(-1, 1, 1) * num_points + + idx = [] + for i in range(nk): + idx.append(idx_all[:, :, i * k:(i + 1) * k]) + idx[i] = idx[i] + idx_base + idx[i] = idx[i].view(-1) + + _, num_dims, _ = x.size() + num_dims = num_dims // 3 + + x = x.transpose(2, + 1).contiguous() # (batch_size, num_points, num_dims) -> (batch_size*num_points, num_dims) # batch_size * num_points * k + range(0, batch_size*num_points) + x = x.view(batch_size, num_points, num_dims, 3) + feature = [] + for i in range(nk): + feature.append(x.view(batch_size * num_points, -1)[idx[i], :]) + feature[i] = feature[i].view(batch_size, num_points, k, num_dims, 3).mean(2, keepdim=False) + feature[i] = feature[i] - x + cross = torch.cross(feature[i], x, dim=3) + feature[i] = torch.cat((feature[i], cross), dim=2) + + feature = torch.cat(feature, dim=2).permute(0, 2, 3, 1).contiguous() + + return feature + + +class VNLinear(nn.Module): + def __init__(self, in_channels, out_channels): + super(VNLinear, self).__init__() + self.map_to_feat = nn.Linear(in_channels, out_channels, bias=False) + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + x_out = self.map_to_feat(x.transpose(1, -1)).transpose(1, -1) + return x_out + + +class VNLeakyReLU(nn.Module): + def __init__(self, in_channels, share_nonlinearity=False, negative_slope=0.2): + super(VNLeakyReLU, self).__init__() + if share_nonlinearity == True: + self.map_to_dir = nn.Linear(in_channels, 1, bias=False) + else: + self.map_to_dir = nn.Linear(in_channels, in_channels, bias=False) + self.negative_slope = negative_slope + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + d = self.map_to_dir(x.transpose(1, -1)).transpose(1, -1) + dotprod = (x * d).sum(2, keepdim=True) + mask = (dotprod >= 0).float() + d_norm_sq = (d * d).sum(2, keepdim=True) + x_out = self.negative_slope * x + (1 - self.negative_slope) * ( + mask * x + (1 - mask) * (x - (dotprod / (d_norm_sq + EPS)) * d)) + return x_out + + +class VNLinearLeakyReLU(nn.Module): + def __init__(self, in_channels, out_channels, dim=5, share_nonlinearity=False, use_batchnorm=True, + negative_slope=0.2): + super(VNLinearLeakyReLU, self).__init__() + self.dim = dim + self.share_nonlinearity = share_nonlinearity + self.use_batchnorm = use_batchnorm + self.negative_slope = negative_slope + + # Conv + self.map_to_feat = nn.Linear(in_channels, out_channels, bias=False) + + # BatchNorm + self.use_batchnorm = use_batchnorm + if use_batchnorm == True: + self.batchnorm = VNBatchNorm(out_channels, dim=dim) + + # LeakyReLU + if share_nonlinearity == True: + self.map_to_dir = nn.Linear(in_channels, 1, bias=False) + else: + self.map_to_dir = nn.Linear(in_channels, out_channels, bias=False) + self.negative_slope = negative_slope + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + # Conv + p = self.map_to_feat(x.transpose(1, -1)).transpose(1, -1) + # InstanceNorm + if self.use_batchnorm == True: + p = self.batchnorm(p) + # LeakyReLU + d = self.map_to_dir(x.transpose(1, -1)).transpose(1, -1) + dotprod = (p * d).sum(2, keepdim=True) + mask = (dotprod >= 0).float() + d_norm_sq = (d * d).sum(2, keepdim=True) + x_out = self.negative_slope * p + (1 - self.negative_slope) * ( + mask * p + (1 - mask) * (p - (dotprod / (d_norm_sq + EPS)) * d)) + return x_out + + +class VNBatchNorm(nn.Module): + def __init__(self, num_features, dim): + super(VNBatchNorm, self).__init__() + self.dim = dim + if dim == 3 or dim == 4: + self.bn = nn.BatchNorm1d(num_features) + elif dim == 5: + self.bn = nn.BatchNorm2d(num_features) + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + norm = torch.sqrt((x * x).sum(2)) + norm_bn = self.bn(norm) + norm = norm.unsqueeze(2) + norm_bn = norm_bn.unsqueeze(2) + x = x / norm * norm_bn + + return x + + +class VNMaxPool(nn.Module): + def __init__(self, in_channels, share_nonlinearity=False): + super(VNMaxPool, self).__init__() + if share_nonlinearity: + self.map_to_dir = nn.Linear(in_channels, 1, bias=False) + else: + self.map_to_dir = nn.Linear(in_channels, in_channels, bias=False) + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + d = self.map_to_dir(x.transpose(1, -1)).transpose(1, -1) + dotprod = (x * d).sum(2, keepdim=True) + idx = dotprod.max(dim=-1, keepdim=False)[1] + index_tuple = torch.meshgrid([torch.arange(j) for j in x.size()[:-1]]) + (idx,) + x_max = x[index_tuple] + return x_max + + +class VNStdFeature(nn.Module): + def __init__(self, in_channels, dim=4, normalize_frame=False, share_nonlinearity=False, use_batchnorm=True): + super(VNStdFeature, self).__init__() + self.dim = dim + self.normalize_frame = normalize_frame + self.share_nonlinearity = share_nonlinearity + self.use_batchnorm = use_batchnorm + + self.vn1 = VNLinearLeakyReLU(in_channels, in_channels // 2, dim=dim, share_nonlinearity=share_nonlinearity, + use_batchnorm=use_batchnorm) + self.vn2 = VNLinearLeakyReLU(in_channels // 2, in_channels // 4, dim=dim, share_nonlinearity=share_nonlinearity, + use_batchnorm=use_batchnorm) + if normalize_frame: + self.vn_lin = nn.Linear(in_channels // 4, 2, bias=False) + else: + self.vn_lin = nn.Linear(in_channels // 4, 3, bias=False) + + def forward(self, x): + ''' + x: point features of shape [B, N_feat, 3, N_samples, ...] + ''' + z0 = self.vn1(x) + z0 = self.vn2(z0) + z0 = self.vn_lin(z0.transpose(1, -1)).transpose(1, -1) + + if self.normalize_frame: + # make z0 orthogonal. u2 = v2 - proj_u1(v2) + v1 = z0[:, 0, :] + # u1 = F.normalize(v1, dim=1) + v1_norm = torch.sqrt((v1 * v1).sum(1, keepdim=True)) + u1 = v1 / (v1_norm + EPS) + v2 = z0[:, 1, :] + v2 = v2 - (v2 * u1).sum(1, keepdim=True) * u1 + # u2 = F.normalize(u2, dim=1) + v2_norm = torch.sqrt((v2 * v2).sum(1, keepdim=True)) + u2 = v2 / (v2_norm + EPS) + + # compute the cross product of the two output vectors + u3 = torch.cross(u1, u2) + z0 = torch.stack([u1, u2, u3], dim=1).transpose(1, 2) + else: + z0 = z0.transpose(1, 2) + + if self.dim == 4: + x_std = torch.einsum('bijm,bjkm->bikm', x, z0) + elif self.dim == 3: + x_std = torch.einsum('bij,bjk->bik', x, z0) + elif self.dim == 5: + x_std = torch.einsum('bijmn,bjkmn->bikmn', x, z0) + + return x_std, z0 + + +# Resnet Blocks +class VNResnetBlockFC(nn.Module): + ''' Fully connected ResNet Block class. + + Args: + size_in (int): input dimension + size_out (int): output dimension + size_h (int): hidden dimension + ''' + + def __init__(self, size_in, size_out=None, size_h=None): + super().__init__() + # Attributes + if size_out is None: + size_out = size_in + + if size_h is None: + size_h = min(size_in, size_out) + + self.size_in = size_in + self.size_h = size_h + self.size_out = size_out + # Submodules + self.fc_0 = VNLinear(size_in, size_h) + self.fc_1 = VNLinear(size_h, size_out) + self.actvn_0 = VNLeakyReLU(size_in, negative_slope=0.2, share_nonlinearity=False) + self.actvn_1 = VNLeakyReLU(size_h, negative_slope=0.2, share_nonlinearity=False) + + if size_in == size_out: + self.shortcut = None + else: + self.shortcut = VNLinear(size_in, size_out) + # Initialization + nn.init.zeros_(self.fc_1.map_to_feat.weight) + + def forward(self, x): + net = self.fc_0(self.actvn_0(x)) + dx = self.fc_1(self.actvn_1(net)) + + if self.shortcut is not None: + x_s = self.shortcut(x) + else: + x_s = x + + return x_s + dx diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/identity.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/identity.py new file mode 100644 index 0000000000000000000000000000000000000000..64b4244d0e725a833423367705ad0d6f0f465352 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/identity.py @@ -0,0 +1,12 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch.nn as nn + +class Identity(nn.Module): + def __init__(self, ): + super(Identity, self).__init__() + + def forward(self, x): + return x + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers.py new file mode 100644 index 0000000000000000000000000000000000000000..86c126adfe853d1db4c92dbdb0f13a3c514ac74c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers.py @@ -0,0 +1,464 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import math + +import einops +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops.layers.torch import Rearrange + + +## Fully connected Neural Network block - Multi Layer Perceptron +class MLP(nn.Module): + def __init__(self, in_dim, out_dim, hidden_dim=16, n_layers=1, act='relu', batch_norm=True): + super(MLP, self).__init__() + activations = {'relu': nn.ReLU, 'sigmoid': nn.Sigmoid, 'tanh': nn.Tanh, 'leaky_relu': nn.LeakyReLU, + 'elu': nn.ELU, 'prelu': nn.PReLU, 'softplus': nn.Softplus, 'mish': nn.Mish, + 'identity': nn.Identity + } + + act_func = activations[act] + layers = [nn.Linear(in_dim, hidden_dim), act_func()] + for i in range(n_layers): + layers += [ + nn.Linear(hidden_dim, hidden_dim), + nn.BatchNorm1d(hidden_dim) if batch_norm else nn.Identity(), + act_func(), + ] + layers.append(nn.Linear(hidden_dim, out_dim)) + + self._network = nn.Sequential( + *layers + ) + + def forward(self, x): + return self._network(x) + + +# Resnet Blocks +class ResnetBlockFC(nn.Module): + ''' + Fully connected ResNet Block class. + + Args: + size_in (int): input dimension + size_out (int): output dimension + size_h (int): hidden dimension + ''' + + def __init__(self, size_in, size_out=None, size_h=None): + super().__init__() + # Attributes + if size_out is None: + size_out = size_in + + if size_h is None: + size_h = min(size_in, size_out) + + self.size_in = size_in + self.size_h = size_h + self.size_out = size_out + # Submodules + self.fc_0 = nn.Linear(size_in, size_h) + self.fc_1 = nn.Linear(size_h, size_out) + self.actvn = nn.ReLU() + + if size_in == size_out: + self.shortcut = None + else: + self.shortcut = nn.Linear(size_in, size_out, bias=False) + # Initialization + nn.init.zeros_(self.fc_1.weight) + + def forward(self, x): + net = self.fc_0(self.actvn(x)) + dx = self.fc_1(self.actvn(net)) + + if self.shortcut is not None: + x_s = self.shortcut(x) + else: + x_s = x + + return x_s + dx + + +class GaussianFourierProjection(nn.Module): + """Gaussian random features for encoding time steps.""" + + def __init__(self, embed_dim, scale=30.): + super().__init__() + # Randomly sample weights during initialization. These weights are fixed + # during optimization and are not trainable. + self.W = nn.Parameter(torch.randn(embed_dim // 2) * scale, requires_grad=False) + + def forward(self, x): + x_proj = x[:, None] * self.W[None, :] * 2 * np.pi + return torch.cat([torch.sin(x_proj), torch.cos(x_proj)], dim=-1) + + +# https://gist.github.com/kevinzakka/dd9fa5177cda13593524f4d71eb38ad5 +class SpatialSoftArgmax(nn.Module): + """Spatial softmax as defined in [1]. + Concretely, the spatial softmax of each feature + map is used to compute a weighted mean of the pixel + locations, effectively performing a soft arg-max + over the feature dimension. + References: + [1]: End-to-End Training of Deep Visuomotor Policies, + https://arxiv.org/abs/1504.00702 + """ + + def __init__(self, normalize=False): + """Constructor. + Args: + normalize (bool): Whether to use normalized + image coordinates, i.e. coordinates in + the range `[-1, 1]`. + """ + super().__init__() + + self.normalize = normalize + + self.temperatur = nn.Parameter(torch.ones(1), requires_grad=True) + + def _coord_grid(self, h, w, device): + if self.normalize: + return torch.stack( + torch.meshgrid( + torch.linspace(-1, 1, w, device=device), + torch.linspace(-1, 1, h, device=device), + ) + ) + return torch.stack( + torch.meshgrid( + torch.arange(0, w, device=device), + torch.arange(0, h, device=device), + ) + ) + + def forward(self, x): + assert x.ndim == 4, "Expecting a tensor of shape (B, C, H, W)." + + # compute a spatial softmax over the input: + # given an input of shape (B, C, H, W), + # reshape it to (B*C, H*W) then apply + # the softmax operator over the last dimension + b, c, h, w = x.shape + + # x = x * h * w + x = x * (h * w / self.temperatur) + # print(self.temperatur) + softmax = F.softmax(x.view(-1, h * w), dim=-1) + + # create a meshgrid of pixel coordinates + # both in the x and y axes + xc, yc = self._coord_grid(h, w, x.device) + + # element-wise multiply the x and y coordinates + # with the softmax, then sum over the h*w dimension + # this effectively computes the weighted mean of x + # and y locations + y_mean = (softmax * xc.flatten()).sum(dim=1, keepdims=True) + x_mean = (softmax * yc.flatten()).sum(dim=1, keepdims=True) + + # concatenate and reshape the result + # to (B, C*2) where for every feature + # we have the expected x and y pixel + # locations + return torch.cat([x_mean, y_mean], dim=1).view(-1, c * 2) + + +######################################################################################################################## +# Modules Temporal Unet +######################################################################################################################## +class Residual(nn.Module): + def __init__(self, fn): + super().__init__() + self.fn = fn + + def forward(self, x, *args, **kwargs): + return self.fn(x, *args, **kwargs) + x + + +class PreNorm(nn.Module): + def __init__(self, dim, fn): + super().__init__() + self.fn = fn + self.norm = LayerNorm(dim) + + def forward(self, x): + x = self.norm(x) + return self.fn(x) + + +class LayerNorm(nn.Module): + def __init__(self, dim, eps=1e-5): + super().__init__() + self.eps = eps + self.g = nn.Parameter(torch.ones(1, dim, 1)) + self.b = nn.Parameter(torch.zeros(1, dim, 1)) + + def forward(self, x): + var = torch.var(x, dim=1, unbiased=False, keepdim=True) + mean = torch.mean(x, dim=1, keepdim=True) + return (x - mean) / (var + self.eps).sqrt() * self.g + self.b + + +class LinearAttention(nn.Module): + def __init__(self, dim, heads=4, dim_head=32): + super().__init__() + self.scale = dim_head ** -0.5 + self.heads = heads + hidden_dim = dim_head * heads + self.to_qkv = nn.Conv1d(dim, hidden_dim * 3, 1, bias=False) + self.to_out = nn.Conv1d(hidden_dim, dim, 1) + + def forward(self, x): + qkv = self.to_qkv(x).chunk(3, dim = 1) + q, k, v = map(lambda t: einops.rearrange(t, 'b (h c) d -> b h c d', h=self.heads), qkv) + q = q * self.scale + + k = k.softmax(dim = -1) + context = torch.einsum('b h d n, b h e n -> b h d e', k, v) + + out = torch.einsum('b h d e, b h d n -> b h e n', context, q) + out = einops.rearrange(out, 'b h c d -> b (h c) d') + return self.to_out(out) + + +class TimeEncoder(nn.Module): + def __init__(self, dim, dim_out): + super().__init__() + self.encoder = nn.Sequential( + SinusoidalPosEmb(dim), + nn.Linear(dim, dim * 4), + nn.Mish(), + nn.Linear(dim * 4, dim_out) + ) + + def forward(self, x): + return self.encoder(x) + + +class SinusoidalPosEmb(nn.Module): + def __init__(self, dim): + super().__init__() + self.dim = dim + + def forward(self, x): + device = x.device + half_dim = self.dim // 2 + emb = math.log(10000) / (half_dim - 1) + emb = torch.exp(torch.arange(half_dim, device=device) * -emb) + emb = x[:, None] * emb[None, :] + emb = torch.cat((emb.sin(), emb.cos()), dim=-1) + return emb + + +class Downsample1d(nn.Module): + def __init__(self, dim): + super().__init__() + self.conv = nn.Conv1d(dim, dim, kernel_size=3, stride=2, padding=1) + + def forward(self, x): + return self.conv(x) + + +class Upsample1d(nn.Module): + def __init__(self, dim): + super().__init__() + self.conv = nn.ConvTranspose1d(dim, dim, kernel_size=4, stride=2, padding=1) + + def forward(self, x): + return self.conv(x) + + +class Conv1dBlock(nn.Module): + ''' + Conv1d --> GroupNorm --> Mish + ''' + + def __init__(self, inp_channels, out_channels, kernel_size, padding=None, n_groups=8): + super().__init__() + self.block = nn.Sequential( + nn.Conv1d(inp_channels, out_channels, kernel_size, stride=1, + padding=padding if padding is not None else kernel_size // 2), + Rearrange('batch channels n_support_points -> batch channels 1 n_support_points'), + nn.GroupNorm(n_groups, out_channels), + Rearrange('batch channels 1 n_support_points -> batch channels n_support_points'), + nn.Mish(), + ) + + def forward(self, x): + return self.block(x) + + +class ResidualBlock(nn.Module): + ################ + # Janner code + def __init__(self, in_channels, out_channels, stride=1, downsample=None): + super(ResidualBlock, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1), + nn.BatchNorm2d(out_channels), + nn.ReLU()) + self.conv2 = nn.Sequential( + nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1), + nn.BatchNorm2d(out_channels)) + self.downsample = downsample + self.relu = nn.ReLU() + self.out_channels = out_channels + + def forward(self, x): + residual = x + out = self.conv1(x) + out = self.conv2(out) + if self.downsample: + residual = self.downsample(x) + out += residual + out = self.relu(out) + return out + + +class ResidualTemporalBlock(nn.Module): + + def __init__(self, inp_channels, out_channels, cond_embed_dim, n_support_points, kernel_size=5): + super().__init__() + + self.blocks = nn.ModuleList([ + Conv1dBlock(inp_channels, out_channels, kernel_size, n_groups=group_norm_n_groups(out_channels)), + Conv1dBlock(out_channels, out_channels, kernel_size, n_groups=group_norm_n_groups(out_channels)), + ]) + + # Without context conditioning, cond_mlp handles only time embeddings + self.cond_mlp = nn.Sequential( + nn.Mish(), + nn.Linear(cond_embed_dim, out_channels), + Rearrange('batch t -> batch t 1'), + ) + + self.residual_conv = nn.Conv1d(inp_channels, out_channels, kernel_size=1, stride=1, padding=0) \ + if inp_channels != out_channels else nn.Identity() + + def forward(self, x, c): + ''' + x : [ batch_size x inp_channels x n_support_points ] + c : [ batch_size x embed_dim ] + returns: + out : [ batch_size x out_channels x n_support_points ] + ''' + h = self.blocks[0](x) + self.cond_mlp(c) + h = self.blocks[1](h) + res = self.residual_conv(x) + out = h + res + + return out + + +class TemporalBlockMLP(nn.Module): + + def __init__(self, inp_channels, out_channels, cond_embed_dim): + super().__init__() + + self.blocks = nn.ModuleList([ + MLP(inp_channels, out_channels, hidden_dim=out_channels, n_layers=0, act='mish') + ]) + + # Without context conditioning, cond_mlp handles only time embeddings + self.cond_mlp = nn.Sequential( + nn.Mish(), + nn.Linear(cond_embed_dim, out_channels), + # Rearrange('batch t -> batch t 1'), + ) + + self.last_act = nn.Mish() + + def forward(self, x, c): + ''' + x : [ batch_size x inp_channels x n_support_points ] + c : [ batch_size x embed_dim ] + returns: + out : [ batch_size x out_channels x n_support_points ] + ''' + h = self.blocks[0](x) + self.cond_mlp(c) + out = self.last_act(h) + return out + + + +def group_norm_n_groups(n_channels, target_n_groups=8): + if n_channels < target_n_groups: + return 1 + for n_groups in range(target_n_groups, target_n_groups + 10): + if n_channels % n_groups == 0: + return n_groups + return 1 + + +def compute_padding_conv1d(L, KSZ, S, D, deconv=False): + ''' + https://gist.github.com/AhmadMoussa/d32c41c11366440bc5eaf4efb48a2e73 + :param L: Input length (or width) + :param KSZ: Kernel size (or width) + :param S: Stride + :param D: Dilation Factor + :param deconv: True if ConvTranspose1d + :return: Returns padding such that output width is exactly half of input width + ''' + print(f"INPUT SIZE {L}") + if not deconv: + return math.ceil((S * (L / 2) - L + D * (KSZ - 1) - 1) / 2) + else: + print(L, S, D, KSZ) + pad = math.ceil(((L - 1) * S + D * (KSZ - 1) + 1 - L * 2) / 2) + print("PAD", pad) + output_size = (L - 1) * S - 2 * pad + D * (KSZ - 1) + 1 + print("OUTPUT SIZE", output_size) + return pad + + +def compute_output_length_maxpool1d(L, KSZ, S, D, P): + ''' + https://pytorch.org/docs/stable/generated/torch.nn.MaxPool1d.html + :param L: Input length (or width) + :param KSZ: Kernel size (or width) + :param S: Stride + :param D: Dilation Factor + :param P: Padding + ''' + return math.floor((L + 2 * P - D * (KSZ - 1) - 1) / S + 1) + + +if __name__ == "__main__": + b, c, h, w = 1, 64, 12, 12 + x = torch.full((b, c, h, w), 0.00) + + i_max = 4 + true_max = torch.randint(0, 10, size=(b, c, 2)) + for i in range(b): + for j in range(c): + x[i, j, true_max[i, j, 0], true_max[i, j, 1]] = 1000 + # x[i, j, i_max, true_max] = 1 + # x[0,0,0,0] = 1000 + soft_max = SpatialSoftArgmax(normalize=True)(x) + soft_max2 = SpatialSoftArgmax(normalize=False)(x) + diff = soft_max - (soft_max2 / 12) * 2 - 1 + resh = soft_max.reshape(b, c, 2) + assert torch.allclose(true_max.float(), resh) + + exit() + test_scales = [1, 5, 10, 30, 50, 70, 100] + for scale in test_scales[::-1]: + i_max = 4 + true_max = torch.randint(0, 10, size=(b, c, 2)) + for i in range(b): + for j in range(c): + x[i, j, true_max[i, j, 0], true_max[i, j, 1]] = scale + # x[i, j, i_max, true_max] = 1 + # x[0,0,0,0] = 1000 + soft_max = SpatialSoftArgmax(normalize=False)(x) + resh = soft_max.reshape(b, c, 2) + assert torch.allclose(true_max.float(), resh), scale diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers_attention.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers_attention.py new file mode 100644 index 0000000000000000000000000000000000000000..6532910447d83957297f794d0ecb2f00579076eb --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/layers/layers_attention.py @@ -0,0 +1,197 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import math +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange, repeat +from inspect import isfunction +from torch import einsum + +from mmd.models.layers.layers import group_norm_n_groups + + +def exists(val): + return val is not None + + +def uniq(arr): + return {el: True for el in arr}.keys() + + +def default(val, d): + if exists(val): + return val + return d() if isfunction(d) else d + + +def max_neg_value(t): + return -torch.finfo(t.dtype).max + + +def init_(tensor): + dim = tensor.shape[-1] + std = 1 / math.sqrt(dim) + tensor.uniform_(-std, std) + return tensor + + +# feedforward +class GEGLU(nn.Module): + def __init__(self, dim_in, dim_out): + super().__init__() + self.proj = nn.Linear(dim_in, dim_out * 2) + + def forward(self, x): + x, gate = self.proj(x).chunk(2, dim=-1) + return x * F.gelu(gate) + + +class FeedForward(nn.Module): + def __init__(self, dim, dim_out=None, mult=4, glu=False, dropout=0.): + super().__init__() + inner_dim = int(dim * mult) + dim_out = default(dim_out, dim) + project_in = nn.Sequential( + nn.Linear(dim, inner_dim), + nn.GELU() + ) if not glu else GEGLU(dim, inner_dim) + + self.net = nn.Sequential( + project_in, + nn.Dropout(dropout), + nn.Linear(inner_dim, dim_out) + ) + + def forward(self, x): + return self.net(x) + + +def zero_module(module): + """ + Zero out the parameters of a module and return it. + """ + for p in module.parameters(): + p.detach().zero_() + return module + + +def Normalize(in_channels): + # return torch.nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True) + return torch.nn.GroupNorm(num_groups=group_norm_n_groups(in_channels), num_channels=in_channels, eps=1e-6, + affine=True) + + +class CrossAttention(nn.Module): + def __init__(self, query_dim, context_dim=None, heads=8, dim_head=64, dropout=0.): + super().__init__() + inner_dim = dim_head * heads + context_dim = default(context_dim, query_dim) + + self.scale = dim_head ** -0.5 + self.heads = heads + + self.to_q = nn.Linear(query_dim, inner_dim, bias=False) + self.to_k = nn.Linear(context_dim, inner_dim, bias=False) + self.to_v = nn.Linear(context_dim, inner_dim, bias=False) + + self.to_out = nn.Sequential( + nn.Linear(inner_dim, query_dim), + nn.Dropout(dropout) + ) + + def forward(self, x, context=None, mask=None): + h = self.heads + + q = self.to_q(x) + context = default(context, x) + k = self.to_k(context) + v = self.to_v(context) + + q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> (b h) n d', h=h), (q, k, v)) + + sim = einsum('b i d, b j d -> b i j', q, k) * self.scale + + if exists(mask): + mask = rearrange(mask, 'b ... -> b (...)') + max_neg_value = -torch.finfo(sim.dtype).max + mask = repeat(mask, 'b j -> (b h) () j', h=h) + sim.masked_fill_(~mask, max_neg_value) + + # attention, what we cannot get enough of + attn = sim.softmax(dim=-1) + + out = einsum('b i j, b j d -> b i d', attn, v) + out = rearrange(out, '(b h) n d -> b n (h d)', h=h) + return self.to_out(out) + + +class BasicTransformerBlock(nn.Module): + def __init__(self, dim, n_heads, d_head, dropout=0., context_dim=None, gated_ff=True, checkpoint=True): + super().__init__() + self.attn1 = CrossAttention(query_dim=dim, heads=n_heads, dim_head=d_head, + dropout=dropout) # is a self-attention + self.ff = FeedForward(dim, dropout=dropout, glu=gated_ff) + self.attn2 = CrossAttention(query_dim=dim, context_dim=context_dim, + heads=n_heads, dim_head=d_head, dropout=dropout) # is self-attn if context is none + self.norm1 = nn.LayerNorm(dim) + self.norm2 = nn.LayerNorm(dim) + self.norm3 = nn.LayerNorm(dim) + self.checkpoint = checkpoint + + def forward(self, x, context=None): + # Residual connections and layer normalization + x = self.attn1(self.norm1(x)) + x # self attention + x = self.attn2(self.norm2(x), context=context) + x # attention to context + x = self.ff(self.norm3(x)) + x + return x + + +class SpatialTransformer(nn.Module): + """ + Transformer block for trajectory-like data. + First, project the input (aka embedding) + and reshape to b, t, d. + Then apply standard transformer action. + Finally, reshape to trajectory + """ + + def __init__(self, in_channels, n_heads, d_head, + depth=1, dropout=0., context_dim=None): + super().__init__() + self.in_channels = in_channels + inner_dim = n_heads * d_head + self.norm = Normalize(in_channels) + + # self.proj_in = nn.Conv2d(in_channels, + # inner_dim, + # kernel_size=1, + # stride=1, + # padding=0) + self.proj_in = nn.Conv1d(in_channels, inner_dim, kernel_size=1, stride=1, padding=0) + + self.transformer_blocks = nn.ModuleList( + [BasicTransformerBlock(inner_dim, n_heads, d_head, dropout=dropout, context_dim=context_dim) + for d in range(depth)] + ) + + # self.proj_out = zero_module(nn.Conv2d(inner_dim, + # in_channels, + # kernel_size=1, + # stride=1, + # padding=0)) + self.proj_out = zero_module(nn.Conv1d(inner_dim, in_channels, kernel_size=1, stride=1, padding=0)) + + def forward(self, x, context=None): + # note: if no context is given, cross-attention defaults to self-attention + b, c, h = x.shape + x_in = x + x = self.norm(x) + x = self.proj_in(x) + x = rearrange(x, 'b c h -> b h c') + for block in self.transformer_blocks: + x = block(x, context=context) + x = rearrange(x, 'b h c -> b c h', h=h) + x = self.proj_out(x) + return x + x_in diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/projection/projectors.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/projection/projectors.py new file mode 100644 index 0000000000000000000000000000000000000000..598966a80011aa74ffeeb0e8b0c036a1e04c830d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/models/projection/projectors.py @@ -0,0 +1,522 @@ +import numpy as np +import torch +import time +import logging + +from itertools import repeat +from typing import Optional, Union, Callable, Dict, List, Tuple +from abc import ABC, abstractmethod + +# Logger setup +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +class BaseProjector(ABC): + """ + Base class for projectors that project a state onto a valid state space. + """ + def __init__(self, **kwargs): + """ + Initialize the projector with any necessary parameters. + """ + super().__init__() + + @abstractmethod + def project(self, + x: torch.Tensor, + params: Optional[Dict[str, torch.Tensor]], + ) -> torch.Tensor: + """ + Project the given state onto a valid state space. + + Args: + state (torch.Tensor): The state to be projected. + + Returns: + torch.Tensor: The projected state. + """ + pass + + + +######################################################################################### +############################ MAX VELOCITY ADMM PROJECTOR ################################ + +class ADMMProjectionOperator(BaseProjector): + """ + ADMM Projection Operator for convex constraints. + + Args: + constraint_fn: A callable that defines the convex constraint. + rho: Penalty parameter for ADMM. + max_iter: Maximum number of iterations for ADMM. + tol: Tolerance for convergence. + """ + def __init__(self, + rho: float = 1.0, + max_iter: int = 100, + tol: float = 1e-4, + convergence_check: bool = True, + verbose: bool = False + ): + super().__init__() + self.rho = rho + self.max_iter = max_iter + assert max_iter > 1, "max_iter must be greater than 1." + self.tol = tol + self.tol_sq = tol**2 # Pre-compute the squared tolerance + logger.info( + f"ADMM projector initialized with " + f"rho={rho}, max_iter={max_iter}, tol={tol}" + ) + # Cached LU factorization info + self._cached_n = None + self._cached_dtype = None + self._cached_lu = None + self._cached_pivots = None + + self.cvg_chk = convergence_check + self.verbose = verbose + + def _ensure_factorization(self, n: int, dtype, device) -> bool: + """ + Ensure LU factorization of A = I + rho*D^T D is cached for given n, dtype, device. + Recompute if n or dtype changed. + Also cache the difference matrix D for dtu calculations. + """ + if self._cached_n == n and self._cached_dtype == dtype and self._cached_lu is not None: + return True + + # Build and cache (n-1 x n) difference operator D + D = torch.zeros(n - 1, n, device=device, dtype=dtype) + idx = torch.arange(n - 1, device=device) + D[idx, idx] = -1.0 + D[idx, idx + 1] = +1.0 + self._cached_D_T_expand = D.T.unsqueeze(0) # (1, n, n-1) + + # Assemble system matrix A = I + rho * D^T D + I_n = torch.eye(n, device=device, dtype=dtype) + A = I_n + self.rho * (D.T @ D) + + # Compute and cache LU factorization + lu, pivots = torch.linalg.lu_factor(A) + self._cached_lu = lu + self._cached_pivots = pivots + self._cached_n = n + self._cached_dtype = dtype + + return False + + def project(self, + X: torch.Tensor, + params: dict, + timeit: bool = False + ) -> torch.Tensor: + """ + Batched ADMM projection for X ∈ R^{B x H x D}, enforcing + ||x_{i+1} - x_i||_2 ≤ dx_max. Optionally includes an initial + position `pos_init` before X[:,0,:]. + """ + assert 'dx_max' in params, "Parameter 'dx_max' must be provided." + alpha = params['dx_max'] + x0 = params.get('pos_init', None) + + B, H, d = X.shape + device = X.device + dtype = X.dtype + + # Timing placeholders + if timeit: + t_init = t_linalg = t_loop = t_x_update = t_z_update = t_u_update = t_conv_chk = 0.0 + t_init_start = time.perf_counter() + + with torch.no_grad(): + # Prepare augmented trajectory + x = X.clone().detach() + n = H + if x0 is not None: + assert x0.shape == (B, d), "pos_init must be (B, D)" + x = torch.cat([x0.clone().detach().to(device).unsqueeze(1), x], dim=1) + n += 1 + X_aug = x.clone().detach() # (B, n, d) + + # Ensure factorization and D are cached + self._ensure_factorization(n, dtype, device) + lu, pivots = self._cached_lu, self._cached_pivots + D = self._cached_D_T_expand + + z = torch.zeros((B, n - 1, d), device=device, dtype=dtype) + u = torch.zeros((B, n - 1, d), device=device, dtype=dtype) + + if timeit: + t_init = time.perf_counter() - t_init_start + t_loop_start = time.perf_counter() + + # ADMM iterations + for _ in repeat(None, self.max_iter): + if timeit: + t_x_update_start = time.perf_counter() + + # x-update: compute b = X_aug + rho * D^T (z - u) + b = X_aug + self.rho * D @ (z - u) # (B, n, d) + + if timeit: + toc = time.perf_counter() + t_linalg_start = toc + t_x_update += toc - t_x_update_start + + # Solve A x = b via cached LU + x_new = torch.lu_solve(b, lu, pivots) + + if timeit: + toc = time.perf_counter() + t_linalg += toc - t_linalg_start + t_z_update_start = toc + + # z-update: project onto L2 balls + diff = x_new[:, 1:] - x_new[:, :-1] + u + norms = diff.norm(dim=2, keepdim=True) + scale = torch.clamp(alpha / (norms + 1e-10), max=1.0) + z_new = diff * scale + + if timeit: + toc = time.perf_counter() + t_z_update += toc - t_z_update_start + t_u_update_start = toc + + # u-update + u += (x_new[:, 1:] - x_new[:, :-1]) - z_new + + if timeit: + toc = time.perf_counter() + t_u_update += toc - t_u_update_start + t_conv_chk_start = toc + + # --- OPTIMIZED CONVERGENCE CHECK --- + # Check convergence using squared norms to avoid sqrt + if self.cvg_chk: + r_norm_sq = (x_new[:,1:] - x_new[:,:-1] - z_new).pow(2).sum(dim=(1, 2)) + # Note: z changes in this loop, so we compute the dual residual + # with the new and old z. + s_norm_sq = self.rho * (z_new - z).pow(2).sum(dim=(1, 2)) + converged = torch.all(r_norm_sq < self.tol_sq) and torch.all(s_norm_sq < self.tol_sq) + + if timeit: + t_conv_chk += time.perf_counter() - t_conv_chk_start + + if self.cvg_chk and converged: + break + + z = z_new + #end for + + if timeit: + if X.is_cuda: torch.cuda.synchronize() + t_loop = time.perf_counter() - t_loop_start + + result = x_new[:, 1:] if x0 is not None else x_new + assert result.shape == X.shape + + if timeit: + t_total = time.perf_counter() - t_init_start + return result, { + 'init': np.array([t_init]), + 'linalg': np.array([t_linalg]), + 'x_update': np.array([t_x_update]), + 'z_update': np.array([t_z_update]), + 'u_update': np.array([t_u_update]), + 'conv_chk': np.array([t_conv_chk]), + 'loop': np.array([t_loop]), + 'total': np.array([t_total]) + } + return result + + +class ADMMProjectionOperatorCUDAGraph(ADMMProjectionOperator): + """ + ADMM projection with CUDA-graph acceleration and anchor constraint. + """ + def __init__(self, rho: float = 1.0, max_iter: int = 100, tol: float = 1e-4, + decomp: str = 'cholesky', + convergence_check: bool = True, + check_period: int = 100, + verbose: bool = False): + super().__init__(rho=rho, max_iter=max_iter, tol=tol, convergence_check=convergence_check, verbose=verbose) + + assert decomp in ['cholesky', 'lu'], "`decomp` only supports 'cholesky' or 'lu' for now." + self.decomp:str = decomp.lower() + if decomp == 'lu': + self._solve_primal = self._solve_primal_lu + elif decomp == 'cholesky': + self._solve_primal = self._solve_primal_cholesky + else: + raise ValueError(f"Unknown decomp: {decomp}") + + self._graph_cached_D_T_expand = None + self._graph_cached_lu = None + self._graph_cached_pivots = None + self._graph_cached_cholesky = None + self._graph_cached_cholesky_T = None + self._cached_shape = None + self.graph:torch.cuda.CUDAGraph = None + self.check_graph: torch.cuda.CUDAGraph = None + self.stream: torch.cuda.Stream = None + self.static_buffers_ready:bool = False + self.static_x_penultimate = None + self.static_z_penultimate = None + self.static_b_anchor = None + + self.check_period = int(check_period) + assert self.check_period >= 1 + # device-side convergence flag (1=int converged) + self._dev_flag = None + self._host_flag = None + self._z_prev = None # for dual residual across chunks + + + def _build_difference_operator(self, n: int, dtype, device) -> Tuple[torch.Tensor, torch.Tensor]: + # Create main diagonal (all 1s) + D = torch.diag(torch.ones(n, dtype=dtype, device=device)) + # Create sub-diagonal (all -1s, one position below main diagonal) + D += torch.diag(-1.0 * torch.ones(n-1, dtype=dtype, device=device), diagonal=-1) + D_T_expand = D.t().unsqueeze(0) + return D, D_T_expand + + def _ensure_factorization(self, n, dtype, device): + matched = (self._cached_n == n and + self._cached_dtype == dtype and + self._graph_cached_D_T_expand is not None) + + if not matched: + D, D_T_expand = self._build_difference_operator(n, dtype, device) + self._graph_cached_D_T_expand = D_T_expand.contiguous() + + # Assemble system matrix A = I + rho * D^T D + I_n = torch.eye(n, device=device, dtype=dtype) + A = I_n + self.rho * (D.T @ D) + if self.decomp == 'lu': + # Compute and cache LU factorization + lu, pivots = torch.linalg.lu_factor(A) + self._graph_cached_lu = lu.contiguous() + self._graph_cached_pivots = pivots.contiguous() + elif self.decomp == 'cholesky': + # Use banded Cholesky: pack banded matrix or dense + self._graph_cached_cholesky = torch.linalg.cholesky(A).contiguous() + self._graph_cached_cholesky_T = self._graph_cached_cholesky.transpose(-2, -1).contiguous() + else: + raise ValueError(f"Unknown decomp: {self.decomp}") + + self._cached_n = n + self._cached_dtype = dtype + + + def _capture_graph(self, B: int, n: int, d: int, alpha: float, device: Union[torch.device, str]): + # Ensure max_iter is sufficient for penultimate capture + if self.max_iter < 2: + raise ValueError("max_iter must be at least 2 to capture penultimate iteration") + + dtype = self._cached_dtype + # Static buffers + self.static_X = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_z = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_u = torch.empty_like(self.static_z) + self.static_b_anchor = torch.empty_like(self.static_z) + self.static_rhs = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_x = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_x_penultimate = torch.empty_like(self.static_x) + self.static_z_penultimate = torch.empty_like(self.static_z) + + self.static_diff_z = torch.empty_like(self.static_z) + self.static_dtu = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_rho_dtu = torch.empty_like(self.static_dtu) + self.static_Dx = torch.empty_like(self.static_z) + self.static_norm = torch.empty((B, n, 1), device=device, dtype=dtype) + self.static_scale = torch.empty_like(self.static_norm) + self.static_temp_z = torch.empty_like(self.static_z) + self.static_temp_upd = torch.empty_like(self.static_z) + # Allocate static y buffer for Cholesky solve + self.static_y = torch.empty_like(self.static_x) + + self.static_alpha = torch.tensor([alpha], device=device, dtype=dtype) + self.static_rho = torch.tensor([self.rho], device=device, dtype=dtype) + self.static_eps = torch.tensor([1e-10], device=device, dtype=dtype) + self.static_tol = torch.tensor(self.tol, device=device, dtype=dtype) + + self._z_prev = torch.empty_like(self.static_z) + self._dev_flag = torch.zeros(1, device=device, dtype=torch.int32) + self._host_flag = torch.zeros(1, dtype=torch.int32) + + self.graph = torch.cuda.CUDAGraph() + self.check_graph = torch.cuda.CUDAGraph() + self.stream = torch.cuda.Stream(device=device) + torch.cuda.synchronize() + + # Warmup + with torch.cuda.stream(self.stream), torch.no_grad(): + for _ in range(3): + self._admm_iteration_static() + + self.graph.capture_begin() + for _ in range(self.check_period): + self._admm_iteration_static() + self.graph.capture_end() + + # capture CHECK graph + # computes device flag: 1 if converged, else 0 + self.check_graph.capture_begin() + # Primal residual: anchored difference constraint violation. + torch.sub(self.static_Dx, self.static_z, out=self.static_temp_upd) + torch.sub(self.static_temp_upd, self.static_b_anchor, out=self.static_temp_upd) + r_norm2 = self.static_temp_upd.pow(2).sum((-1, -2)) + r_max = torch.sqrt(r_norm2.max()) + + # Dual residual: change in z across the last chunk boundary. + torch.sub(self.static_z, self._z_prev, out=self.static_temp_z) + s_tmp = torch.matmul(self._graph_cached_D_T_expand, self.static_temp_z) + s_norm2 = (self.static_rho * s_tmp).pow(2).sum((-1, -2)) + s_max = torch.sqrt(s_norm2.max()) + + # Device-side stop flag lets the CUDA graph path avoid Python-side tensor scans. + self._dev_flag.zero_() + ok = (r_max <= self.static_tol) & (s_max <= self.static_tol) + flag_i32 = ok.to(torch.int32).view(1) + + self._dev_flag.copy_(flag_i32) + self.check_graph.capture_end() + + torch.cuda.synchronize() + self.static_buffers_ready = True + self._cached_shape = (B, n, d) + + def _solve_primal_lu(self): + torch.linalg.lu_solve( + self._graph_cached_lu, + self._graph_cached_pivots, + self.static_rhs, + out=self.static_x + ) + + def _solve_primal_cholesky(self): + # Solve L L^T x = rhs, L is lower triangular + torch.linalg.solve_triangular( + self._graph_cached_cholesky, + self.static_rhs, + left=True, + upper=False, + out=self.static_y + ) + ## Solve L^T x = y + torch.linalg.solve_triangular( + self._graph_cached_cholesky_T, + self.static_y, + left=True, + upper=True, + out=self.static_x + ) + + def _admm_iteration_static(self): + # Anchor-shifted x-update solves against z + b_anchor - u. + torch.add(self.static_z, self.static_b_anchor, out=self.static_diff_z) + torch.sub(self.static_diff_z, self.static_u, out=self.static_diff_z) + torch.matmul(self._graph_cached_D_T_expand, self.static_diff_z, out=self.static_dtu) + torch.mul(self.static_dtu, self.static_rho, out=self.static_rho_dtu) + torch.add(self.static_X, self.static_rho_dtu, out=self.static_rhs) + self._solve_primal() + + # Anchor-shifted z/u updates keep the constraint centered on the initial state. + self.static_Dx[:, 0, :] = self.static_x[:, 0, :] + torch.sub(self.static_x[:, 1:], self.static_x[:, :-1], out=self.static_Dx[:, 1:]) + torch.sub(self.static_Dx, self.static_b_anchor, out=self.static_temp_z) + torch.add(self.static_temp_z, self.static_u, out=self.static_temp_z) + torch.norm(self.static_temp_z, dim=2, keepdim=True, out=self.static_norm) + torch.add(self.static_norm, self.static_eps, out=self.static_scale) + self.static_scale.reciprocal_() + torch.mul(self.static_scale, self.static_alpha, out=self.static_scale) + torch.clamp_max_(self.static_scale, 1.0) + torch.mul(self.static_temp_z, self.static_scale, out=self.static_z) + + # u-update + torch.sub(self.static_Dx, self.static_z, out=self.static_temp_upd) + torch.sub(self.static_temp_upd, self.static_b_anchor, out=self.static_temp_upd) + torch.add(self.static_u, self.static_temp_upd, out=self.static_u) + + def project(self, X: torch.Tensor, params: dict, timeit: bool = False) -> torch.Tensor: + alpha:float = params['dx_max'] + x0:torch.Tensor = params['pos_init'] + B, n, d = X.shape + device, dtype = X.device, X.dtype + + self._ensure_factorization(n, dtype, device) + need_new = ( + not self.static_buffers_ready or + self._cached_shape != (B, n, d) or + self._cached_dtype != dtype + ) + if need_new: + self._capture_graph(B, n, d, alpha, device) + self._cached_dtype = dtype + + cur_stream = torch.cuda.current_stream(device) + self.stream.wait_stream(cur_stream) + with torch.cuda.stream(self.stream), torch.no_grad(): + self.static_X.copy_(X) + self.static_alpha.fill_(alpha) + self.static_z.zero_() + self.static_u.zero_() + self.static_b_anchor.zero_() + self.static_b_anchor[:, 0, :] = x0.clone().detach().to(dtype=dtype, device=device) + self._z_prev.zero_() + iters_done = 0 + while iters_done < self.max_iter: + self.graph.replay() + iters_done += self.check_period + if self.cvg_chk: + self._z_prev.copy_(self.static_z) + self.check_graph.replay() + self._host_flag.copy_(self._dev_flag.cpu()) + if int(self._host_flag.item()) == 1: + break + + out = self.static_x.clone().detach() + + cur_stream.wait_stream(self.stream) + + # Optional convergence log. + if self.cvg_chk: + if int(self._host_flag.item()) != 1: + logger.warning(f"ADMM projection did NOT converge within {self.max_iter} iterations.") + + feasible = self.feasibility_check(out, params, eps=1e-5) + if not feasible.all(): + logger.warning( + f"ADMM projection operator returned infeasible trajectories: " + f"{torch.logical_not(feasible).sum().item()} / {feasible.numel()} NOT feasible." + ) + + return out + + + def feasibility_check(self, + x: torch.Tensor, + params: Dict[str, Union[torch.Tensor, float]], + eps: float = 1e-6 + ) -> torch.Tensor: + """ + Check feasibility of the trajectory X under the projection operator. + Returns a tensor indicating whether each trajectory is feasible. + """ + alpha:float = params['dx_max'] + x0:torch.Tensor = params['pos_init'] + + with torch.no_grad(): + ## Check x0 feasibility + diff_x0 = x[..., 0, :] - x0 + norm = torch.linalg.norm(diff_x0, dim=-1) + feasible_x0 = norm <= alpha + eps + + return feasible_x0 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..33a37f0e4c6990c208cf3cdbf6b695d7437db309 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/__init__.py @@ -0,0 +1,3 @@ +from .cbs import * +from .prioritized_planning import * +from .dummy import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/cbs.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/cbs.py new file mode 100644 index 0000000000000000000000000000000000000000..d173d5532c3bab1c6bfee6470cb3e1f1bf56ac95 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/cbs.py @@ -0,0 +1,508 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import os +import time +from math import floor, ceil +from pathlib import Path +import matplotlib.pyplot as plt +import torch +from typing import Tuple, List, Dict, Type +from enum import Enum +import concurrent.futures + +# Project imports. +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer, create_fig_and_axes +from mmd.common.conflicts import VertexConflict, Conflict, PointConflict, EdgeConflict +from mmd.common.constraints import MultiPointConstraint, Constraint +from mmd.common.conflict_conversion import convert_conflicts_to_constraints +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.pretty_print import * +from mmd.common import densify_trajs, smooth_trajs, is_multi_agent_start_goal_states_valid, global_pad_paths +from mmd.config import MMDParams as params +from mmd.common.experiments import TrialSuccessStatus +from mmd.planners.single_agent import MPD + +""" +Some comments: +1. This assumes, as of now, a homogeneous team of robots (so that asking for one to compute + the conflicts with others makes sense with a robot method). +""" + + +class CBSExperienceReuseStrategy(Enum): + """ + Enum for replanning strategies in CBS. + """ + NONE = 0 + XCBS = 1 + NOISE_AS_EXPERIENCE = 2 + + +class SearchState: + """ + Constraint Tree node for CBS. + """ + + def __init__(self, ix_best_path_in_batch_l, path_bl, constraints={}): + self.path_bl = path_bl # List of batch of paths. (list of n_agents) x B x H x q_dim. + self.ix_best_path_in_batch_l = ix_best_path_in_batch_l # List of indices of the best path in the batch. (n_agents,). + self.conflict_l = [] + self.constraints = constraints # Map of agent_id: List[Constraint]. + self.g = float('inf') # Cost to reach this node. + + def update_g_l2(self): + """ + Update the cost to reach this node. + """ + self.g = 0 + for i, ix_best_path_in_batch in enumerate(self.ix_best_path_in_batch_l): + path = self.path_bl[i][ix_best_path_in_batch] + path_cost = torch.norm(path[1:] - path[:-1], dim=-1).sum() + self.g += path_cost + + def add_constraint(self, agent_id, constraint): + """ + Add a constraint to the state. + """ + if agent_id not in self.constraints: + self.constraints[agent_id] = [] + print(RED + f'Adding constraint for agent {agent_id}. Before:', len(self.constraints[agent_id]), RESET) + self.constraints[agent_id].append(constraint) + print(GREEN + f'After:', len(self.constraints[agent_id]), RESET) + + def get_copy(self): + """ + Create a copy of the state. + """ + new_ix_best_path_in_batch_l = self.ix_best_path_in_batch_l.copy() + new_path_bl = [path_b.clone() for path_b in self.path_bl] + new_constraints = {k: [c.get_copy() for c in v] for k, v in self.constraints.items()} + new_state = SearchState(new_ix_best_path_in_batch_l, new_path_bl, new_constraints) + new_state.conflict_l = self.conflict_l + new_state.g = self.g + return new_state + + +class CBS: + """ + Conflict-Based Search (CBS) algorithm. + """ + def __init__(self, low_level_planner_l, + start_l: List[torch.Tensor], + goal_l: List[torch.Tensor], + start_time_l: List[int] = None, + is_xcbs=False, + is_ecbs=True, + conflict_type_to_constraint_types: Dict[Type[Conflict], Type[Constraint]] = None, + reference_robot=None, + reference_task=None, + **kwargs): + # Some parameters: + self.low_level_choose_path_from_batch_strategy = params.low_level_choose_path_from_batch_strategy + # Set the low level planners. + self.low_level_planner_l = low_level_planner_l + # Whether to use experience in the low level planner. + self.is_xcbs = is_xcbs + self.experience_reuse_strategy = CBSExperienceReuseStrategy.XCBS + # Which conflicts to find and what constraints to create from them. + self.conflict_type_to_constraint_types = conflict_type_to_constraint_types + # Whether to impose soft constraints from other agents' paths. + self.is_ecbs = is_ecbs + self.num_agents = len(start_l) + self.agent_color_l = plt.cm.get_cmap('tab20')(torch.linspace(0, 1, self.num_agents)) + self.start_state_pos_l = start_l + self.goal_state_pos_l = goal_l + if start_time_l is None: + self.start_time_l = [0] * self.num_agents + else: + self.start_time_l = start_time_l + # Keep a reference robot for collision checking in a group of robots. + if reference_robot is None: + print(CYAN + 'Using the first robot in the low level planner list as the reference robot.' + RESET) + self.reference_robot = self.low_level_planner_l[0].robot + else: + self.reference_robot = reference_robot + if reference_task is None: + print(CYAN + 'Using the first task in the low level planner list as the reference task.' + RESET) + self.reference_task = self.low_level_planner_l[0].task + else: + self.reference_task = reference_task + self.tensor_args = self.low_level_planner_l[0].tensor_args + self.results_dir = self.low_level_planner_l[0].results_dir + # Check for collisions between robots, and between robots and obstacles, in their start and goal states. + if not is_multi_agent_start_goal_states_valid(self.reference_robot, + self.reference_task, + self.start_state_pos_l, + self.goal_state_pos_l): + print(RED + 'Start or goal states are invalid.') + print(self.start_state_pos_l) + print(self.goal_state_pos_l, RESET) + raise ValueError('Start or goal states are invalid.') + # Open list. + self.open_l = [] + + def get_conflicts(self, state: SearchState) -> List[Conflict]: + """ + Find conflicts between paths. + """ + # Get a list of best paths from the search state. Each path is shape (H, q_dim). + best_path_l = [state.path_bl[i][ix_best_path_in_batch].squeeze(0) for i, ix_best_path_in_batch in + enumerate(state.ix_best_path_in_batch_l)] + + # Pad the paths to make them all the same length. Different agents may have different start times, accommodate that. + best_path_l = global_pad_paths(best_path_l, self.start_time_l) + + # Get the positions of all robots at all times. + paths_pos_l = [self.reference_robot.get_position(path) for path in best_path_l] + # Return empty list if there are no paths. + if len(paths_pos_l) == 0: + return [] + max_t = max([len(path) for path in paths_pos_l]) # This should be the same for all agents. + # Find conflicts. + conflicts = [] + densification_factor = 2 if EdgeConflict in self.conflict_type_to_constraint_types.keys() else 1 + paths_pos_l_dense = densify_trajs(paths_pos_l, densification_factor) # n elements of shape (H * densification_factor, q_dim) + + # Check collisions for all time steps at once. + paths_pos_b_dense = torch.stack(paths_pos_l_dense) # n_robots, H, q_dim + # Change to (H, n_robots, q_dim) for the robot method. + paths_pos_b_dense = paths_pos_b_dense.permute(1, 0, 2) + collisions_pairwise_b, collision_points_b = self.reference_robot.check_rr_collisions(paths_pos_b_dense) # Shapes (H, n_robots, n_robots) bool, (H, n_robots, n_robots, ws_dim) float + # Check all time steps that have collisions in then. + collision_indices = torch.nonzero( + collisions_pairwise_b.int()) # Shape: (num_collisions, 3), each row [t_dense, agent_id_a, agent_id_b] + for collision_index in collision_indices: + t_dense, agent_id_a, agent_id_b = collision_index + t_global_from = floor(t_dense / densification_factor) + t_global_to = ceil(t_dense / densification_factor) + t_dense, t_global_from, t_global_to = int(t_dense), int(t_global_from), int(t_global_to) + # We set the conflict to happen at the beginning of the interpolated time interval, + # but use the midpoint between interpolated states. + midpoint_state_pos = collision_points_b[t_dense, agent_id_a, agent_id_b, :] + agent_id_a, agent_id_b = int(agent_id_a.item()), int(agent_id_b.item()) + # Check which conflict types are requested. + # Create a vertex conflict if the collision time is integral. + if VertexConflict in self.conflict_type_to_constraint_types and t_global_from == t_global_to: + conflicts.append( + VertexConflict([agent_id_a, agent_id_b], + [ + paths_pos_l[agent_id_a][t_global_from], # Corresponds to a graph vertex. + paths_pos_l[agent_id_b][t_global_from] # Corresponds to a graph vertex. + ], + int(t_global_from))) + + # Create an edge conflict if the collision time is not integral. + if EdgeConflict in self.conflict_type_to_constraint_types and t_global_from != t_global_to: + conflicts.append( + EdgeConflict([agent_id_a, agent_id_b], + q_from_l=[ + paths_pos_l[agent_id_a][t_global_from], + paths_pos_l[agent_id_b][t_global_from] + ], + q_to_l=[ + paths_pos_l[agent_id_a][t_global_to], + paths_pos_l[agent_id_b][t_global_to] + ], + t_from=t_global_from, + t_to=t_global_to)) + + # Create a point conflict. + # NOTE(yorai): this is normally called without a densification factor (=1), so the time from/to is the same. + if PointConflict in self.conflict_type_to_constraint_types: + conflicts.append( + PointConflict([agent_id_a, agent_id_b], + p_l=[ + paths_pos_l_dense[agent_id_a][t_dense], + paths_pos_l_dense[agent_id_b][t_dense] + ], + q_l=[ + midpoint_state_pos, + midpoint_state_pos + ], + t_from=int(t_global_from), + t_to=int(t_global_to))) + return conflicts + + def render_paths(self, paths_l: List[torch.Tensor], constraints_l: List[MultiPointConstraint] = None, + animation_duration: float = 10.0, output_fpath=None, n_frames=None, plot_trajs=True, + show_robot_in_image=True): + # Render + planner_visualizer = PlanningVisualizer( + task=self.reference_task, + ) + + # Add batch dimension to all paths. + paths_l = [path.unsqueeze(0) for path in paths_l] + + # If animation_duration is None or 0, don't animate and save an image instead. + if animation_duration is None or animation_duration == 0: + fig, ax = create_fig_and_axes() + for agent_id in range(self.num_agents): + planner_visualizer.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=paths_l[agent_id], + start_state=self.start_state_pos_l[agent_id], # None, # + goal_state= self.goal_state_pos_l[agent_id], # None, # + colors=[self.agent_color_l[agent_id]], + constraints_l=constraints_l, + show_robot_in_image=show_robot_in_image + ) + if output_fpath is None: + output_fpath = os.path.join(self.results_dir, 'robot-traj.png') + output_fpath = os.path.abspath(output_fpath) + if output_fpath[-4:] != '.png': + output_fpath += '.png' + print(f'Saving image to: file://{output_fpath}') + plt.axis('off') + plt.savefig(output_fpath, dpi=100, bbox_inches='tight', pad_inches=0) + return + + base_file_name = Path(os.path.basename(__file__)).stem + if output_fpath is None: + output_fpath = os.path.join(self.results_dir, f'{base_file_name}-robot-traj.gif') + output_fpath = os.path.abspath(output_fpath) + # Render the paths. + print(f'Rendering paths and saving to: file://{os.path.abspath(output_fpath)}') + planner_visualizer.animate_multi_robot_trajectories( + trajs_l=paths_l, + start_state_l=self.start_state_pos_l, + goal_state_l=self.goal_state_pos_l, + plot_trajs=plot_trajs, + video_filepath=output_fpath, + n_frames=max((2, paths_l[0].shape[1])) if n_frames is None else n_frames, + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=constraints_l, + colors=self.agent_color_l + ) + + def plan(self, runtime_limit=1000): + """ + Plan a path from start to goal with constraints. + """ + startt = time.time() + # This success status will be updated by the search. + success_status = TrialSuccessStatus.UNKNOWN + # ====================== + # Create the root node. + # ====================== + # Plan individual paths without constraints. + root_creation_start_time = time.time() + # Empty root node. + root = SearchState([], []) + for i in range(len(self.low_level_planner_l)): + # If ECBS, then pass the paths of other agents to this one in the form of constraints. + soft_constraint_l = [] + if self.is_ecbs: + soft_constraint_l = self.create_soft_constraints_from_other_agents_paths(root, agent_id=i) + + planner_output = self.low_level_planner_l[i](self.start_state_pos_l[i], + self.goal_state_pos_l[i], + constraints_l=soft_constraint_l) + # Check for planning failure in root creation. + if planner_output.trajs_final_free_idxs.shape[0] == 0: + print("Failed to find valid paths in root CT node.") + success_status = TrialSuccessStatus.FAIL_NO_SOLUTION + state = root + break + + # Update the root node. + ix_best_traj = planner_output.idx_best_traj + root.path_bl.append(planner_output.trajs_final) + root.ix_best_path_in_batch_l.append(ix_best_traj) + + # Check for runtime limit reached in root creation. + if time.time() - startt > runtime_limit: + print('Runtime limit reached in root creation.') + success_status = TrialSuccessStatus.FAIL_RUNTIME_LIMIT + state = root + break + + if success_status == TrialSuccessStatus.UNKNOWN: + root.update_g_l2() + conflict_l = self.get_conflicts(root) + root.conflict_l = conflict_l + # Create the open list. + self.open_l.append(root) + print(f'Root creation time: {time.time() - root_creation_start_time:.2f}s') + + # ====================== + # Start the search. + # ====================== + num_ct_expansions = 0 + while success_status == TrialSuccessStatus.UNKNOWN: + # If the open list is empty, return None. + if not self.open_l: + print('Open list is empty. NO SOLUTION.') + success_status = TrialSuccessStatus.FAIL_NO_SOLUTION + break + + # Sort the CT. + # Optionally replace the line below with `self.open_l.sort(key=lambda x: x.g)` to optimize for cost. + self.open_l.sort(key=lambda x: len(x.conflict_l)) + # Get the first node from the open list. + state = self.open_l.pop(0) + # Check if the conflict set is empty. + if not state.conflict_l: + print('No conflicts found in CT node. GOAL.') + success_status = TrialSuccessStatus.SUCCESS + break + + # Expand the search tree. + self.expand(state) + num_ct_expansions += 1 + if time.time() - startt > runtime_limit: + print('Runtime limit reached.') + success_status = TrialSuccessStatus.FAIL_RUNTIME_LIMIT + break + + # Return the best paths. Smoothed and padded to the same length accounting for start times. + best_path_l = [state.path_bl[i][ix_best_path_in_batch].squeeze(0) for i, ix_best_path_in_batch in + enumerate(state.ix_best_path_in_batch_l)] + + best_path_l = global_pad_paths(best_path_l, self.start_time_l) + # Return the best paths, the number of CT expansions, success status, and number of collisions in the solution. + return best_path_l, num_ct_expansions, success_status, len(state.conflict_l) + + def expand(self, state: SearchState): + """ + Expand the search tree. + """ + # Choose a conflict to turn into constraints. + conflict = state.conflict_l[0] + constraints = convert_conflicts_to_constraints(conflict, self.conflict_type_to_constraint_types) + # Create a CT node for each constraint tuple (agent_id, constraint). + for agent_id, constraint in constraints: + # Update the constraint time to account for agents starting after t=0. + constraint.t_range_l = [(t_range[0] - self.start_time_l[agent_id], + t_range[1] - self.start_time_l[agent_id]) + for t_range in constraint.t_range_l] + # Clamp down to the maximum time of the paths. + constraint.t_range_l = [(max(0, min(t_range[0], len(state.path_bl[agent_id][0]) - 1)), + min(len(state.path_bl[agent_id][0]) - 1, t_range[1])) + for t_range in constraint.t_range_l] + # Create new state. + new_state = state.get_copy() + # Add the constraint. + new_state.add_constraint(agent_id, constraint) # In local time to agent. + # Create a PlanningContext object for the agent which includes the current trajectories of all other agents. + # Plan the path with the constraint. Add the previous path as experience as a seed if allowed. + agent_constraint_l = new_state.constraints[agent_id].copy() + + # Set soft constraints from paths of other agents, if allowed. + if self.is_ecbs: + soft_constraint_l = self.create_soft_constraints_from_other_agents_paths(new_state, agent_id) + agent_constraint_l.extend(soft_constraint_l) + + # Set experience, if allowed. + agent_experience = None + if self.is_xcbs: + if self.experience_reuse_strategy == CBSExperienceReuseStrategy.XCBS: + agent_experience = PathBatchExperience(new_state.path_bl[agent_id]) + else: + raise ValueError(f'Invalid experience reuse strategy {self.experience_reuse_strategy}.') + planner_output = self.low_level_planner_l[agent_id](self.start_state_pos_l[agent_id], + self.goal_state_pos_l[agent_id], + constraints_l=agent_constraint_l, + experience=agent_experience) + + # Check if the planner found a valid path. + if len(planner_output.trajs_final_free_idxs) == 0: + print(RED + 'Failed to find valid path in CT node.' + RESET) + return # Skip this node. + + new_state.path_bl[agent_id] = planner_output.trajs_final + + if self.low_level_choose_path_from_batch_strategy == 'least_cost': + ix_best_traj = planner_output.idx_best_traj + new_state.ix_best_path_in_batch_l[agent_id] = ix_best_traj + # Find and set conflicts. + conflict_l = self.get_conflicts(new_state) + new_state.conflict_l = conflict_l + + elif self.low_level_choose_path_from_batch_strategy == 'least_collisions': + new_state.conflict_l = None + for ix_traj in planner_output.trajs_final_free_idxs: + temp_state = new_state.get_copy() + temp_state.ix_best_path_in_batch_l[agent_id] = ix_traj + conflict_l = self.get_conflicts(temp_state) + if new_state.conflict_l is None: + new_state.ix_best_path_in_batch_l[agent_id] = ix_traj + new_state.conflict_l = conflict_l + if len(conflict_l) < len(new_state.conflict_l): + new_state.ix_best_path_in_batch_l[agent_id] = ix_traj + new_state.conflict_l = conflict_l + print(f'Found a path with fewer conflicts: {len(conflict_l)}') + else: + raise ValueError('Invalid low level choose-path-from-batch strategy.') + + # Update the cost to reach this node. + new_state.update_g_l2() + print("New state cost num conflicts:", new_state.g, len(new_state.conflict_l), "\n\n\n\n") + # Add the new state to the open list. + self.open_l.append(new_state) + + def create_soft_constraints_from_other_agents_paths(self, + state: SearchState, + agent_id: int) -> List[MultiPointConstraint]: + """ + Create soft constraints from the paths of other agents. + """ + if len(state.path_bl) == 0: + return [] + + agent_constraint_l = [] # The output list of soft constraints. + q_l = [] + t_range_l = [] + radius_l = [] + num_agents_in_state = len(state.path_bl) + for agent_id_other in range(num_agents_in_state): + if agent_id_other != agent_id: + best_path_other_agent = \ + state.path_bl[agent_id_other][state.ix_best_path_in_batch_l[agent_id_other]].squeeze(0) + best_path_pos_other_agent = self.reference_robot.get_position(best_path_other_agent) + for t_other_agent in range(0, len(best_path_other_agent), 1): + t_agent = t_other_agent + self.start_time_l[agent_id_other] - self.start_time_l[agent_id] + # The last timestep index for this agent is the length of its path - 1. + # If it does not have a path stored, then create constraints for all timesteps + # in the path of the other agent (starting from zero). + T_agent = len(state.path_bl[agent_id_other][0]) - 1 + if agent_id >= len(state.path_bl): + T_agent = len(best_path_other_agent) - 1 + else: + T_agent = len(state.path_bl[agent_id][0]) - 1 + + if 1 <= t_agent <= T_agent: + q_l.append(best_path_pos_other_agent[t_other_agent]) + t_range_l.append((t_agent, t_agent + 1)) + radius_l.append(params.vertex_constraint_radius) + + if len(q_l) > 0: + soft_constraint = MultiPointConstraint(q_l=q_l, t_range_l=t_range_l) + soft_constraint.radius_l = radius_l + soft_constraint.is_soft = True + agent_constraint_l.append(soft_constraint) + return agent_constraint_l diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/dummy.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/dummy.py new file mode 100644 index 0000000000000000000000000000000000000000..c886608453245ca2300010375245c2e642433972 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/dummy.py @@ -0,0 +1,125 @@ +""" +Modified from +- https://github.com/yoraish/mmd + +MIT License + +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import os +import time +from pathlib import Path +import matplotlib.pyplot as plt +import torch +import logging +from typing import Tuple, List +from enum import Enum + +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer, create_fig_and_axes + +from mmd.common.experiments import TrialSuccessStatus +from mmd.common.conflicts import VertexConflict, Conflict +from mmd.common.constraints import MultiPointConstraint +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.pretty_print import * +from mmd.common import densify_trajs, smooth_trajs, is_multi_agent_start_goal_states_valid, global_pad_paths +from mmd.config import MMDParams as params +from mmd.planners.multi_agent.cbs import SearchState # Holding multi-agent paths and constraints information. +from mmd.planners.multi_agent.prioritized_planning import PrioritizedPlanning +from mmd.planners.single_agent.common import PlannerOutput + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +class DummyPlanning(PrioritizedPlanning): + """ + Wrapper around a low-level planner with no high-level coordination. + """ + def __init__(self, + low_level_planner_l, + start_l: List[torch.Tensor], + goal_l: List[torch.Tensor], + start_time_l: List[int] = None, + reference_robot=None, + reference_task=None, + **kwargs + ) -> None: + ## Only keep the 1st low-level planner. + if len(low_level_planner_l) > 1: + logger.warning( + "DummyPlanning is designed to work with a single low-level planner. " + "Multiple planners are provided, but only the first one will be used." + ) + low_level_planner_l = low_level_planner_l[:1] + super().__init__( + low_level_planner_l=low_level_planner_l, + start_l=start_l, + goal_l=goal_l, + start_time_l=None, + reference_robot=None, + reference_task=None, + ) + # Default to zero start times when none are provided. + if start_time_l is None: + start_time_l = [0] * self.num_agents + self.start_time_l = start_time_l + + self.start_poss_tensor = torch.stack(start_l) + self.goal_poss_tensor = torch.stack(goal_l) + + def update_start_goal_states(self, + start_l: List[torch.Tensor], + goal_l: List[torch.Tensor] + ): + """ + Update the start and goal states of the planner. + This is useful when the start and goal states change during planning. + """ + self.start_poss_tensor = torch.stack(start_l) + self.goal_poss_tensor = torch.stack(goal_l) + ## For compatibility of all legacy methods (e.g. rendering) + self.start_state_pos_l = start_l + self.goal_state_pos_l = goal_l + # Update the low-level planner with the new start and goal states. + for planner in self.low_level_planner_l: + planner.update_start_goal_states(self.start_poss_tensor, self.goal_poss_tensor) + + def plan(self, runtime_limit=100)->Tuple[List[torch.Tensor], int, TrialSuccessStatus, int]: + """ + Plan a path from start to goal. Do it for one agent at a time. + """ + success_status = TrialSuccessStatus.UNKNOWN + + root = SearchState([], []) + planner_output:PlannerOutput = self.low_level_planner_l[0]( + self.start_poss_tensor, + self.goal_poss_tensor, + ) + + best_path_l = [*planner_output.trajs_final] + success_status = TrialSuccessStatus.SUCCESS + + best_path_l = global_pad_paths(best_path_l, self.start_time_l) + return best_path_l, planner_output.t_profile, 0, success_status, len(root.conflict_l) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/prioritized_planning.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/prioritized_planning.py new file mode 100644 index 0000000000000000000000000000000000000000..321c19c95e64e9a73eff696e2ee52210a70a706b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/multi_agent/prioritized_planning.py @@ -0,0 +1,298 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import os +import time +from pathlib import Path +import matplotlib.pyplot as plt +import torch +from typing import Tuple, List +from enum import Enum +import concurrent.futures + +from mmd.common.experiments import TrialSuccessStatus +# Project imports. +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer, create_fig_and_axes +from mmd.common.conflicts import VertexConflict, Conflict +from mmd.common.constraints import MultiPointConstraint +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.pretty_print import * +from mmd.common import densify_trajs, smooth_trajs, is_multi_agent_start_goal_states_valid, global_pad_paths +from mmd.config import MMDParams as params +from mmd.planners.multi_agent.cbs import SearchState # Holding multi-agent paths and constraints information. + + +class PrioritizedPlanning: + """ + Prioritized Planning (PP) algorithm. + """ + def __init__(self, low_level_planner_l, + start_l: List[torch.Tensor], + goal_l: List[torch.Tensor], + start_time_l: List[int] = None, + reference_robot=None, + reference_task=None, + **kwargs): + + # Some parameters: + self.low_level_choose_path_from_batch_strategy = params.low_level_choose_path_from_batch_strategy + # Set the low level planners. + self.low_level_planner_l = low_level_planner_l + self.num_agents = len(start_l) + self.agent_color_l = plt.cm.get_cmap('tab20')(torch.linspace(0, 1, self.num_agents)) + self.start_state_pos_l = start_l + self.goal_state_pos_l = goal_l + if start_time_l is None: + start_time_l = [0] * self.num_agents + else: + self.start_time_l = start_time_l + # Keep a reference robot for collision checking in a group of robots. + if reference_robot is None: + print(CYAN + 'Using the first robot in the low level planner list as the reference robot.' + RESET) + self.reference_robot = self.low_level_planner_l[0].robot + else: + self.reference_robot = reference_robot + if reference_task is None: + print(CYAN + 'Using the first task in the low level planner list as the reference task.' + RESET) + self.reference_task = self.low_level_planner_l[0].task + else: + self.reference_task = reference_task + self.tensor_args = self.low_level_planner_l[0].tensor_args + self.results_dir = self.low_level_planner_l[0].results_dir + # Check for collisions between robots, and between robots and obstacles, in their start and goal states. + if not is_multi_agent_start_goal_states_valid(self.reference_robot, + self.reference_task, + self.start_state_pos_l, + self.goal_state_pos_l): + raise ValueError('Start or goal states are invalid.') + + def render_paths(self, paths_l: List[torch.Tensor], constraints_l: List[MultiPointConstraint] = None, + animation_duration: float = 10.0, output_fpath=None, n_frames=None, plot_trajs=True, + show_robot_in_image=True): + # Render + planner_visualizer = PlanningVisualizer( + task=self.reference_task, + ) + + # Add batch dimension to all paths. + paths_l = [path.unsqueeze(0) for path in paths_l] + + # If animation_duration is None or 0, don't animate and save an image instead. + if animation_duration is None or animation_duration == 0: + fig, ax = create_fig_and_axes() + for agent_id in range(self.num_agents): + planner_visualizer.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=paths_l[agent_id], + start_state=self.start_state_pos_l[agent_id], + goal_state=self.goal_state_pos_l[agent_id], + colors=[self.agent_color_l[agent_id]], + show_robot_in_image=show_robot_in_image + ) + if output_fpath is None: + output_fpath = os.path.join(self.results_dir, 'robot-traj.png') + if not output_fpath.endswith('.png'): + output_fpath = output_fpath + '.png' + print(f'Saving image to: file://{os.path.abspath(output_fpath)}') + plt.axis('off') + plt.savefig(output_fpath, dpi=100, bbox_inches='tight', pad_inches=0) + return + + base_file_name = Path(os.path.basename(__file__)).stem + if output_fpath is None: + output_fpath = os.path.join(self.results_dir, f'{base_file_name}-robot-traj.gif') + # Render the paths. + print(f'Rendering paths and saving to: file://{os.path.abspath(output_fpath)}') + planner_visualizer.animate_multi_robot_trajectories( + trajs_l=paths_l, + start_state_l=self.start_state_pos_l, + goal_state_l=self.goal_state_pos_l, + plot_trajs=plot_trajs, + video_filepath=output_fpath, + n_frames=max((2, paths_l[0].shape[1])) if n_frames is None else n_frames, + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=constraints_l, + colors=self.agent_color_l + ) + + def plan(self, runtime_limit=1000): + """ + Plan a path from start to goal. Do it for one agent at a time. + """ + startt = time.time() + success_status = TrialSuccessStatus.UNKNOWN + + # Empty root node. + root = SearchState([], []) + for i in range(len(self.low_level_planner_l)): + constraint_l = self.create_soft_constraints_from_other_agents_paths(root, agent_id=i) + for c in constraint_l: + # Make the constraints hard. This makes their collection act as a priority constraint. + c.is_soft = False + # Clip to range. + c.t_range_l = [(max(0, min(t_range[0], params.horizon - 1)), + min(params.horizon - 1, t_range[1])) + for t_range in c.t_range_l] + + planner_output = self.low_level_planner_l[i](self.start_state_pos_l[i], + self.goal_state_pos_l[i], + constraints_l=constraint_l) + if planner_output.trajs_final_free_idxs.shape[0] == 0: + success_status = TrialSuccessStatus.FAIL_NO_SOLUTION + break + + ix_best_traj = planner_output.idx_best_traj + # Update the root node. + root.path_bl.append(planner_output.trajs_final) + root.ix_best_path_in_batch_l.append(ix_best_traj) + root.conflict_l = self.get_conflicts(root) + # Check if this agent i should use another path that has fewer conflicts. + for ix_traj in planner_output.trajs_final_free_idxs: + temp_state = root.get_copy() + temp_state.ix_best_path_in_batch_l[i] = ix_traj + conflict_l = self.get_conflicts(temp_state) + if root.conflict_l is None: + root.ix_best_path_in_batch_l[i] = ix_traj + root.conflict_l = conflict_l + if len(conflict_l) < len(root.conflict_l): + print(f'Found a path with fewer conflicts: {len(conflict_l)} < {len(root.conflict_l)}') + root.ix_best_path_in_batch_l[i] = ix_traj + root.conflict_l = conflict_l + + # Check if the runtime limit has been reached. + if time.time() - startt > runtime_limit: + print('Runtime limit reached.') + success_status = TrialSuccessStatus.FAIL_RUNTIME_LIMIT + break + + # Extract the best path from the batch. + best_path_l = [root.path_bl[i][ix_best_path_in_batch].squeeze(0) for i, ix_best_path_in_batch in + enumerate(root.ix_best_path_in_batch_l)] + # Check for conflicts. + conflict_l = self.get_conflicts(root) + print(RED + 'Conflicts root node:', len(conflict_l), RESET) + if success_status == TrialSuccessStatus.UNKNOWN: + if len(conflict_l) > 0: + success_status = TrialSuccessStatus.FAIL_COLLISION_AGENTS + else: + success_status = TrialSuccessStatus.SUCCESS + + # Global pad before returning. + best_path_l = global_pad_paths(best_path_l, self.start_time_l) + # Return the best path, the CT nodes expanded (0), success status, and num collisions in output. + return best_path_l, 0, success_status, len(conflict_l) + + def create_soft_constraints_from_other_agents_paths(self, state: SearchState, agent_id: int) -> List[MultiPointConstraint]: + """ + Create soft constraints from the paths of other agents. + """ + if len(state.path_bl) == 0: + return [] + + agent_constraint_l = [] # The output list of soft constraints. + q_l = [] + t_range_l = [] + radius_l = [] + num_agents_in_state = len(state.path_bl) + for agent_id_other in range(num_agents_in_state): + if agent_id_other != agent_id: + best_path_other_agent = \ + state.path_bl[agent_id_other][state.ix_best_path_in_batch_l[agent_id_other]].squeeze(0) + best_path_pos_other_agent = self.reference_robot.get_position(best_path_other_agent) + for t_other_agent in range(0, len(best_path_other_agent), 1): + t_agent = t_other_agent + self.start_time_l[agent_id_other] - self.start_time_l[agent_id] + # The last timestep index for this agent is the lenfth of its path - 1. + # If it does not have a path stored, then create constraints for all timesteps + # in the path of the other agent (starting from zero). + T_agent = len(state.path_bl[agent_id_other][0]) - 1 + if agent_id >= len(state.path_bl): + T_agent = len(best_path_other_agent) - 1 + else: + T_agent = len(state.path_bl[agent_id][0]) - 1 + + if 1 <= t_agent <= T_agent: + q_l.append(best_path_pos_other_agent[t_other_agent]) + t_range_l.append((t_agent, t_agent + 1)) + radius_l.append(params.vertex_constraint_radius) + + if len(q_l) > 0: + soft_constraint = MultiPointConstraint(q_l=q_l, t_range_l=t_range_l) + soft_constraint.radius_l = radius_l + soft_constraint.is_soft = True + agent_constraint_l.append(soft_constraint) + return agent_constraint_l + + + def get_conflicts(self, state: SearchState) -> List[Conflict]: + """ + Find conflicts between paths. + """ + # Get a list of best paths from the search state. Each path is shape (H, q_dim). + best_path_l = [state.path_bl[i][ix_best_path_in_batch].squeeze(0) for i, ix_best_path_in_batch in + enumerate(state.ix_best_path_in_batch_l)] + # Pad the paths to make them all the same length. Different agents may have different start times, accommodate that. + best_path_l = global_pad_paths(best_path_l, self.start_time_l) + + # Get the positions of all robots at all times. + paths_pos_l = [self.reference_robot.get_position(path) for path in best_path_l] + # Return empty if no paths. + if len(paths_pos_l) == 0: + return [] + max_t = max([len(path) for path in paths_pos_l]) # This should be the same for all agents. + # Find conflicts. + conflicts = [] + densification_factor = 1 + paths_pos_l_dense = densify_trajs(paths_pos_l, densification_factor) + for t in range(max_t): + # Check all robot positions at [t * densification_factor, (t + 1) * densification_factor]. + for t_dense in range(t * densification_factor, (t + 1) * densification_factor): + # Note(yorai): the last iteration will likely only have the last state in the path repeated. This is ok. + positions_dense = [path[t_dense] if len(path) > t_dense else path[-1] for path in paths_pos_l_dense] + positions_dense = torch.stack(positions_dense) # ..., n_robots, q_dim + # Find conflicts. + collisions_pairwise, _ = self.reference_robot.check_rr_collisions(positions_dense) + if torch.any(collisions_pairwise): + # This node may only have paths from a (contiguous) subset of agents. + num_agents = positions_dense.shape[0] + for agent_id_a in range(num_agents): + for agent_id_b in range(agent_id_a + 1, num_agents): + if collisions_pairwise[..., agent_id_a, agent_id_b]: + t_a = t if len(paths_pos_l[agent_id_a]) > t else len(paths_pos_l[agent_id_a]) - 1 + t_a_dense = t_dense if len(paths_pos_l_dense[agent_id_a]) > t_dense else len(paths_pos_l_dense[agent_id_a]) - 1 + t_b = t if len(paths_pos_l[agent_id_b]) > t else len(paths_pos_l[agent_id_b]) - 1 + t_b_dense = t_dense if len(paths_pos_l_dense[agent_id_b]) > t_dense else len(paths_pos_l_dense[agent_id_b]) - 1 + # We set the conflict to happen at the beginning of the interpolated time interval, + # but use the interpolated states. + conflicts.append( + VertexConflict([agent_id_a, agent_id_b], + # [paths_pos_l[agent_id_a][t_a], paths_pos_l[agent_id_b][t_b]], + [ + paths_pos_l_dense[agent_id_a][t_a_dense], + paths_pos_l_dense[agent_id_b][t_b_dense] + ], + t)) + # print(f'Conflict between agents {agent_id_a} and {agent_id_b} at time {t}.') + return conflicts diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..644e2ff0109462fc6b8cfda203d3d7177253e9e7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/__init__.py @@ -0,0 +1,13 @@ +from .mpd import * +from .common import * +from .mpd_ensemble import * +from .pcd import * +from .direct import * +try: + from .ipopt import * # optional: needs casadi; unused by MMD-CBS / MPD / DiRecT paths +except Exception: + pass +try: + from .jax_scp_admm import * # optional: needs jax; unused by MMD-CBS / MPD / DiRecT paths +except Exception: + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/common.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/common.py new file mode 100644 index 0000000000000000000000000000000000000000..3afe8a265b48cd6417c45bb004efdafb9afd5166 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/common.py @@ -0,0 +1,48 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +class PlannerOutput: + """Container for single-agent planner outputs; `constraints_l` stores the active constraint set and `t_profile` stores the timing breakdown.""" + + def __init__(self): + self.trajs_iters = None + self.trajs_final = None + self.trajs_final_coll = None + self.trajs_final_coll_idxs = None + self.trajs_final_free = None + self.trajs_final_free_idxs = None + self.success_free_trajs = None + self.fraction_free_trajs = None + self.collision_intensity_trajs = None + self.idx_best_traj = None # In trajs_final. + self.traj_final_free_best = None + self.cost_best_free_traj = None + self.cost_smoothness = None + self.cost_path_length = None + self.cost_all = None + self.variance_waypoint_trajs_final_free = None + self.t_total = None + # The constraints set used for this call. + self.constraints_l = None + ## Timing breakdown. + self.t_profile = None diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/direct.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/direct.py new file mode 100644 index 0000000000000000000000000000000000000000..75fcb6663f4d178608235902e83057af2b48934c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/direct.py @@ -0,0 +1,1220 @@ +""" +Modified from +- https://github.com/EdmunLuad/pcd +- https://github.com/jacarvalho/mpd-public +- https://github.com/yoraish/mmd +MIT License + +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import pickle +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List, Dict, Optional, Union +import logging + +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostConstraint, CostMaxVelocity +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.diffusion_model_base import GaussianDiffusionModel +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.datasets.trajectories import TrajectoryDataset +from mmd.models.diffusion_models.sample_functions import extract, guide_gradient_steps, projected_coupling_ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from torch_robotics.torch_planning_objectives.fields.distance_fields import ( + DistanceField, CollisionObjectDistanceField, CollisionWorkspaceBoundariesDistanceField +) +from mmd.planners.single_agent.common import PlannerOutput +from mmd.planners.single_agent.single_agent_planner_base import SingleAgentPlanner +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.constraints import MultiPointConstraint +from mmd.common.pretty_print import * +from mmd.models.projection.projectors import BaseProjector, ADMMProjectionOperatorCUDAGraph +from mmd.coupling_costs.coupling_cost_functions import DummyCost, RobotCollisionCost +try: + from mmd.planners.single_agent.ipopt import IpoptProjector +except Exception: # casadi optional; MMD-CBS uses the diffusion (MPD) projector, not IPOPT + IpoptProjector = None +try: + from mmd.planners.single_agent.jax_scp_admm import JaxScpAdmmProjector +except Exception: # jax optional; unused by the CBS coupling path + JaxScpAdmmProjector = None +from mmd.planners.single_agent.projection_metrics import ProjectionMetricsRecorder + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +### DiRecT SAMPLING FUNCTION ### + +@torch.no_grad() +def projected_coupling_ddpm_sample_fn( + model, x, hard_conds, context, t, + guide=None, + projector: BaseProjector = None, + guide_kwargs={}, + project_params=None, + n_guide_steps=1, + scale_grad_by_std=False, + t_start_guide=torch.inf, + noise_std_extra_schedule_fn=None, # 'linear' + **kwargs +): + t_single = t[0] + # By convention, a time less than zero gets zero noise. That is, sampling to update mean is computed to until t=0 and then only guidance and sampling, without updating the mean, is done. + if t_single < 0: + raise ValueError( + f"t must be non-negative for sampling. Got t = {t_single}.") + t = torch.zeros_like(t) + + if t_single == 0: + # Not doing inpainting at t==0 to enforce projection constraints. + t_stps = [] + for k in hard_conds.keys(): + # x[..., H, D] + if isinstance(k, int) and k > 0 and k == x.shape[-2]-1: + t_stps.append(k) + max_t = max(t_stps) if t_stps else None + if max_t is not None: + max_t_hard_cond = { + max_t: hard_conds.pop(max_t) + } + + with TimerCUDA() as timer_diffuse: + model_mean, _, model_log_variance = model.p_mean_variance( + x=x, hard_conds=hard_conds, context=context, t=t) + t_diffuse = timer_diffuse.elapsed + + x = model_mean + model_log_variance = extract( + model.posterior_log_variance_clipped, t, x.shape) + model_std = torch.exp(0.5 * model_log_variance) + model_var = torch.exp(model_log_variance) + + with TimerCUDA() as timer_coupling: + if guide is not None and t_single < t_start_guide: + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + n_guide_steps=n_guide_steps, + scale_grad_by_std=scale_grad_by_std, + model_var=model_var, + **guide_kwargs + ) + t_coupling = timer_coupling.elapsed + + noise = torch.randn_like(x) + # No noise when t = 0. + noise[t == 0] = 0 + + # For smoother results, we can decay the noise standard deviation throughout the diffusion + # this is roughly equivalent to using a temperature in the prior distribution + if noise_std_extra_schedule_fn is None: + noise_std = 1.0 + else: + noise_std = noise_std_extra_schedule_fn(t_single) + + x = x + model_std * noise * noise_std + + ## ---------------------- Projector step ----------------------## + t_project = 0.0 + if projector is not None: + assert project_params is not None, "Projector parameters must be provided." + assert 'dx_max' in project_params, "Projector parameters must include 'dx_max'." + assert 'pos_init' in project_params, "Projector parameters must include 'pos_init'." + with TimerCUDA() as timer_project: + x_1_on_pos = projector.project( + x[:, 1:, :2], + params=project_params, + ) + t_project = timer_project.elapsed + x[:, 1:, :2] = x_1_on_pos + + values = None + time_elapsed = { + 'diffuse': t_diffuse, + 'coupling': t_coupling, + 'project': t_project, + } + + if 'timeit' in kwargs and kwargs['timeit']: + return x, values, time_elapsed + else: + return x, values + +@torch.no_grad() +def direct_ddpm_sample_fn( + model, x, hard_conds, context, t, + guide=None, + projector: BaseProjector = None, + guide_kwargs={}, + project_params=None, + n_guide_steps=1, + scale_grad_by_std=False, + t_start_guide=torch.inf, + t_start_projection=0, + noise_std_extra_schedule_fn=None, # 'linear' + **kwargs +): + + t_single = t[0] + apply_guidance = (projector is not None) and (t_single < t_start_projection) + # By convention, a time less than zero gets zero noise. That is, sampling to update mean is computed to until t=0 and then only guidance and sampling, without updating the mean, is done. + if t_single < 0: + raise ValueError( + f"t must be non-negative for sampling. Got t = {t_single}.") + t = torch.zeros_like(t) + + if t_single == 0: + # Not doing inpainting at t==0 to enforce projection constraints. + t_stps = [] + for k in hard_conds.keys(): + # x[..., H, D] + if isinstance(k, int) and k > 0 and k == x.shape[-2]-1: + t_stps.append(k) + max_t = max(t_stps) if t_stps else None + if max_t is not None: + max_t_hard_cond = { + max_t: hard_conds.pop(max_t) + } + + with TimerCUDA() as timer_diffuse: + model_mean, _, model_log_variance = model.p_mean_variance( + x=x, hard_conds=hard_conds, context=context, t=t) + t_diffuse = timer_diffuse.elapsed + + x = model_mean + model_log_variance = extract( + model.posterior_log_variance_clipped, t, x.shape) + model_std = torch.exp(0.5 * model_log_variance) + model_var = torch.exp(model_log_variance) + + with TimerCUDA() as timer_coupling: + if guide is not None and t_single < t_start_guide: + logger.info('Running guidance step') + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + n_guide_steps=n_guide_steps, + scale_grad_by_std=scale_grad_by_std, + model_var=model_var, + **guide_kwargs + ) + t_coupling = timer_coupling.elapsed + + noise = torch.randn_like(x) + # No noise when t = 0. + noise[t == 0] = 0 + + # For smoother results, we can decay the noise standard deviation throughout the diffusion + # this is roughly equivalent to using a temperature in the prior distribution + if noise_std_extra_schedule_fn is None: + noise_std = 1.0 + else: + noise_std = noise_std_extra_schedule_fn(t_single) + + x = x + model_std * noise * noise_std + + t_project = 0 + if apply_guidance: + logger.info(f'Running iter {t_single}') + if t_single == 0: + ## At the last step, model_mean IS the x_0 estimate + ## (posterior_mean_coef1=1, coef2=0 since alphas_cumprod_prev[0]=1). + ## Project directly and use as final output — no Tweedie re-estimation + ## (s=t-1=-1 would be invalid index), no alpha_s blending (cosine schedule + ## doesn't have alpha=1 exactly). + x_before_proj = x.detach().cpu().clone() if kwargs.get('intermediates_log') is not None else None + if projector is not None: + assert project_params is not None, "Projector parameters must be provided." + assert 'dx_max' in project_params, "Projector parameters must include 'dx_max'." + assert 'pos_init' in project_params, "Projector parameters must include 'pos_init'." + if 'smoothness_weight' in project_params: + projector.smoothness_weight = float(project_params['smoothness_weight']) + with TimerCUDA() as timer_project: + x_hat_proj = x.clone() + x_hat_proj[:, 1:, :2] = projector.project( + x[:, 1:, :2], + params=project_params, + ) + t_project = timer_project.elapsed + pm = kwargs.get('proj_metrics') + if pm is not None: + pm.record(int(t_single), x_hat_proj[:, 1:, :2], + project_params, t_project) + x = x_hat_proj + else: + ## ---------------------- Tweedie's estimation ----------------## + with TimerCUDA() as timer_project: + s = t - 1 + model_context = None + if context is not None: + model_context = model.context_model(context) + x_hat = model.predict_start_from_noise(x, t=s, noise=model.model(x, s, model_context)) + t_project = timer_project.elapsed + + ## ---------------------- Projector Tweedie ----------------------## + alpha_s = model.sqrt_alphas_cumprod[s[0]] + t_project = 0.0 + if projector is not None: + assert project_params is not None, "Projector parameters must be provided." + assert 'dx_max' in project_params, "Projector parameters must include 'dx_max'." + assert 'pos_init' in project_params, "Projector parameters must include 'pos_init'." + if 'smoothness_weight' in project_params: + # Note: for simplicity we are not considering the relation by g**2. + # Nevertheless smoothness weight is not being used in the experiments: pure Euclidean projection. + smoothness_weight = project_params['smoothness_weight'] / alpha_s.item()**2 ###### + projector.smoothness_weight = float(smoothness_weight) + with TimerCUDA() as timer_project: + x_hat_proj = x_hat.clone() + x_hat_proj_dofs = projector.project( + x_hat[:, 1:, :2], + params=project_params, + ) + t_project = timer_project.elapsed + pm = kwargs.get('proj_metrics') + if pm is not None: + pm.record(int(t_single), x_hat_proj_dofs, + project_params, t_project) + x_hat_proj[:, 1:, :2] = x_hat_proj_dofs + + ## ----------------- Correct the next sample ----------------------- + x = x + alpha_s * (x_hat_proj - x_hat) + + # Optionally log intermediates for visualization/debugging + intermediates_log = kwargs.get('intermediates_log', None) + if intermediates_log is not None: + step_data = {'t': int(t_single), 'x': x.detach().cpu().clone()} + if apply_guidance and t_single > 0: + step_data['x_hat'] = x_hat.detach().cpu().clone() + step_data['x_hat_proj'] = x_hat_proj.detach().cpu().clone() + if apply_guidance and t_single == 0 and x_before_proj is not None: + step_data['x_before_proj'] = x_before_proj + intermediates_log.append(step_data) + + values = None + time_elapsed = { + 'diffuse': t_diffuse, + 'coupling': t_coupling, + 'project': t_project, + } + + if 'timeit' in kwargs and kwargs['timeit']: + return x, values, time_elapsed + else: + return x, values +##### END DiRecT SAMPLING FN ##### + + +### PDM (PER-STEP LATENT PROJECTION) SAMPLING FUNCTION ### + +@torch.no_grad() +def pdm_ddpm_sample_fn( + model, x, hard_conds, context, t, + guide=None, + projector: BaseProjector = None, + guide_kwargs={}, + project_params=None, + n_guide_steps=1, + scale_grad_by_std=False, + t_start_guide=torch.inf, + t_start_projection=0, + noise_std_extra_schedule_fn=None, # 'linear' + **kwargs +): + """Projected Diffusion Model (PDM) step: identical to `direct_ddpm_sample_fn` + except the projection is applied directly to the noisy latent x_t after the + DDPM update, instead of DiRecT's Tweedie clean-space projection and + correction. Same gating (`t_start_projection`), same projector, same + guidance structure.""" + + t_single = t[0] + apply_guidance = (projector is not None) and (t_single < t_start_projection) + # By convention, a time less than zero gets zero noise. That is, sampling to update mean is computed to until t=0 and then only guidance and sampling, without updating the mean, is done. + if t_single < 0: + raise ValueError( + f"t must be non-negative for sampling. Got t = {t_single}.") + t = torch.zeros_like(t) + + if t_single == 0: + # Not doing inpainting at t==0 to enforce projection constraints. + t_stps = [] + for k in hard_conds.keys(): + # x[..., H, D] + if isinstance(k, int) and k > 0 and k == x.shape[-2]-1: + t_stps.append(k) + max_t = max(t_stps) if t_stps else None + if max_t is not None: + max_t_hard_cond = { + max_t: hard_conds.pop(max_t) + } + + with TimerCUDA() as timer_diffuse: + model_mean, _, model_log_variance = model.p_mean_variance( + x=x, hard_conds=hard_conds, context=context, t=t) + t_diffuse = timer_diffuse.elapsed + + x = model_mean + model_log_variance = extract( + model.posterior_log_variance_clipped, t, x.shape) + model_std = torch.exp(0.5 * model_log_variance) + model_var = torch.exp(model_log_variance) + + with TimerCUDA() as timer_coupling: + if guide is not None and t_single < t_start_guide: + logger.info('Running guidance step') + x = guide_gradient_steps( + x, + hard_conds=hard_conds, + guide=guide, + n_guide_steps=n_guide_steps, + scale_grad_by_std=scale_grad_by_std, + model_var=model_var, + **guide_kwargs + ) + t_coupling = timer_coupling.elapsed + + noise = torch.randn_like(x) + # No noise when t = 0. + noise[t == 0] = 0 + + # For smoother results, we can decay the noise standard deviation throughout the diffusion + # this is roughly equivalent to using a temperature in the prior distribution + if noise_std_extra_schedule_fn is None: + noise_std = 1.0 + else: + noise_std = noise_std_extra_schedule_fn(t_single) + + x = x + model_std * noise * noise_std + + t_project = 0 + x_before_proj = None + if apply_guidance: + logger.info(f'Running PDM projection iter {t_single}') + ## ---------------------- Per-step latent projection ----------------## + ## Project the noisy latent x_t directly onto the feasible set — no + x_before_proj = x.detach().cpu().clone() if kwargs.get('intermediates_log') is not None else None + assert project_params is not None, "Projector parameters must be provided." + assert 'dx_max' in project_params, "Projector parameters must include 'dx_max'." + assert 'pos_init' in project_params, "Projector parameters must include 'pos_init'." + if 'smoothness_weight' in project_params: + projector.smoothness_weight = float(project_params['smoothness_weight']) + with TimerCUDA() as timer_project: + x_proj_dofs = projector.project( + x[:, 1:, :2], + params=project_params, + ) + t_project = timer_project.elapsed + pm = kwargs.get('proj_metrics') + if pm is not None: + pm.record(int(t_single), x_proj_dofs, + project_params, t_project) + x[:, 1:, :2] = x_proj_dofs + + # Optionally log intermediates for visualization/debugging + intermediates_log = kwargs.get('intermediates_log', None) + if intermediates_log is not None: + step_data = {'t': int(t_single), 'x': x.detach().cpu().clone()} + if apply_guidance and x_before_proj is not None: + step_data['x_before_proj'] = x_before_proj + step_data['x_after_proj'] = x.detach().cpu().clone() + intermediates_log.append(step_data) + + values = None + time_elapsed = { + 'diffuse': t_diffuse, + 'coupling': t_coupling, + 'project': t_project, + } + + if 'timeit' in kwargs and kwargs['timeit']: + return x, values, time_elapsed + else: + return x, values +##### END PDM SAMPLING FN ##### + + +PLANNER_ALGORITHMS = ['base', 'final'] +PROJECTORS = ['Ipopt', 'JaxScpADMM', 'ADMMVelOnly'] + + +class DiRecT(SingleAgentPlanner): + def __init__(self, + model_id: str, + planner_alg: str, + n_agents: int, + start_state_pos: torch.Tensor, + goal_state_pos: torch.Tensor, + use_guide_on_extra_objects_only: bool, + start_guide_steps_fraction: float, + n_guide_steps: int, + n_diffusion_steps_without_noise: int, + cost_func_key: str, + weight_grad_cost_collision: float, + weight_grad_cost_smoothness: float, + weight_grad_cost_constraints: float, + weight_grad_cost_soft_constraints: float, + factor_num_interpolated_points_for_collision: float, + trajectory_duration: float, + device: str, + debug: bool, + seed: int, + results_dir: str, + trained_models_dir: str, + n_samples: int, + n_local_inference_noising_steps: int, + n_local_inference_denoising_steps: int, + projection_args: Dict = None, + cost_func_param: Dict = {}, + timeit: bool = False, + projector_type: str = 'JaxScpADMM', + start_projection_guidance: float = 0.0, + **kwargs + ) -> None: + assert n_samples % n_agents == 0, ( + f"Number of samples ({n_samples}) must be divisible by number of " + f"agents ({n_agents})." + ) + + super().__init__() + + self.constraints = [] + fix_random_seed(seed) + + device = get_torch_device(device) + tensor_args = {'device': device, 'dtype': torch.float32} + logger.info(f'Initializing Planner with Model -- {model_id}') + logger.info(f'Algorithm --- {planner_alg}') + + assert planner_alg in PLANNER_ALGORITHMS, \ + (f"Unknown planner algorithm: '{planner_alg}'. " + f"Supported algorithms are {PLANNER_ALGORITHMS}.") + + ## All options for DiRecT ## + project_latent = False + use_coupling_obstacle_cost = False + use_coupling_inter_robot_cost = False + if planner_alg == 'base': + pass + elif planner_alg == 'final': + pass # unguided diffusion, project only at the last step + else: + raise NotImplementedError( + f"Unknown planner algorithm: '{planner_alg}'. " + f"Supported algorithms are {PLANNER_ALGORITHMS}.") + use_coupling = use_coupling_obstacle_cost or use_coupling_inter_robot_cost + + model_dir = os.path.join(trained_models_dir, model_id) + results_dir = os.path.join(model_dir, 'results_inference', str(seed)) + os.makedirs(results_dir, exist_ok=True) + + args = load_params_from_yaml(os.path.join(model_dir, "args.yaml")) + + # Load dataset with env, robot, task. The TrajectoryDataset type is used here. + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=tensor_args + ) + # Extract objects from the dataset. + dataset: TrajectoryDataset = train_subset.dataset + # Number of support points in the trajectory. + n_support_points = dataset.n_support_points + # The environment. + env = dataset.env + # The robot. Contains the dt, the joint limits, etc. + robot = dataset.robot + # The task, commonly PlanningTask, is in charge of objects, extra objects, collisions, etc. + task: PlanningTask = dataset.task + + # time interval for finite differences + dt = trajectory_duration / n_support_points + + # set robot's dt + robot.dt = dt + logger.info(f'Robot dt set to {robot.dt}') + + # Load prior model + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model: GaussianDiffusionModel = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs + ) + diffusion_model.load_state_dict( + torch.load( + os.path.join(model_dir, 'checkpoints', 'ema_model_current_state_dict.pth' + if args['use_ema'] + else 'model_current_state_dict.pth'), + map_location=tensor_args['device']) + ) + diffusion_model.eval() + model = diffusion_model + + freeze_torch_model_params(model) + model = torch.compile(model) + model.warmup(horizon=n_support_points, device=device) + + # If the args specify a test start and goal, use those. + if start_state_pos is not None and goal_state_pos is not None: + assert start_state_pos.shape[0] == n_agents, \ + (f"Start state position shape {start_state_pos.shape} " + f"does not match number of agents {n_agents}.") + assert goal_state_pos.shape[0] == n_agents, \ + (f"Goal state position shape {goal_state_pos.shape} " + f"does not match number of agents {n_agents}.") + else: + # Random initial and final positions pairs + n_tries = 1000 + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2 * n_agents) + starts = q_free[:n_agents] + goals = q_free[n_agents:] + + cmp_a = starts + cmp_b = goals + + # [n_agents, state_dim] -> [n_agents] + norms = torch.linalg.norm(cmp_a - cmp_b, dim=-1) + if (norms > dataset.threshold_start_goal_pos).all(): + start_state_pos = starts + goal_state_pos = goals + break + if start_state_pos is None or goal_state_pos is None: + logger.error( + f"Could not find a collision-free configuration for start and goal positions after {n_tries} tries.") + logger.error( + f"Distance threshold: {dataset.threshold_start_goal_pos}; " + f"Norms of all checked pairs: {norms}. ") + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + logger.info(f'start_state_pos: {start_state_pos}') + logger.info(f'goal_state_pos: {goal_state_pos}') + + # Run motion planning inference + # normalize start and goal positions + hard_conds = dataset.get_hard_conditions(torch.stack( + (start_state_pos, goal_state_pos)), normalize=True) + # For groupwise hard conditioning + hard_conds["group_size"] = n_samples // n_agents + context = None + + # Set up the planning costs + cost_collision_l = [] + # for guidance, the weights_cost_l are the gradient multipliers (after gradient clipping) + weights_grad_cost_l = [] + + # Inter-robot collisions + if use_coupling_inter_robot_cost: + cost_collision_l.append( + RobotCollisionCost( + robot_radius=robot.radius, + cost_func_key=cost_func_key, + **cost_func_param, + ) + ) + else: + cost_collision_l.append(DummyCost()) + weights_grad_cost_l.append( + weight_grad_cost_constraints if use_coupling_inter_robot_cost else 0.0) + + # Static obstacles + if use_guide_on_extra_objects_only: + logger.error("Extra objects guiding is not implemented yet.") + raise NotImplementedError( + "Extra objects guiding is not implemented yet.") + collision_fields = task.get_collision_fields_extra_objects() + else: + collision_fields = task.get_collision_fields() + + # For projectors: create collision fields with analytic primitives + # (precompute_sdf_obj_fixed=False) so the Jax extractor can parse them. + # The default (True) converts primitives to GridMapSDF which Jax skips. + # Only needed for Ipopt/JaxScpADMM projectors, not for ADMMVelOnly + # which handles velocity constraints only. + use_vel_only_projector = (projector_type == 'ADMMVelOnly') + if not use_vel_only_projector: + env_class = type(env) + env_for_proj = env_class(precompute_sdf_obj_fixed=False, tensor_args=tensor_args) + robot_for_proj = type(robot)(radius=robot.radius, tensor_args=tensor_args) + task_for_proj = PlanningTask( + env=env_for_proj, robot=robot_for_proj, + tensor_args=tensor_args, obstacle_cutoff_margin=0.05) + proj_collision_fields = task_for_proj.get_collision_fields() + + for collision_field in collision_fields: + if use_coupling_obstacle_cost: + cost_collision_l.append( + CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=1.0, + tensor_args=tensor_args + ) + ) + else: + cost_collision_l.append(DummyCost()) + weights_grad_cost_l.append( + weight_grad_cost_collision if use_coupling_obstacle_cost else 0.0) + + cost_func_list = [ + *cost_collision_l, + ] + # A `*cost_constraints_l` will be added as "extra cost" and removed after each planning call. + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + weights_cost_l=weights_grad_cost_l, + tensor_args=tensor_args + ) + + # Guiding manager + guide = GuideManagerTrajectoriesWithVelocity( + dataset, + cost_composite, + clip_grad=True, + interpolate_trajectories_for_collision=False, + num_interpolated_points=ceil( + n_support_points * factor_num_interpolated_points_for_collision), + tensor_args=tensor_args, + ) + + t_start_guide = ceil(start_guide_steps_fraction * + model.n_diffusion_steps) + # Same semantics as start_guide_steps_fraction: + # 1.0 → projection at every step, 0.0 → no projection steps + t_start_projection = ceil(start_projection_guidance * model.n_diffusion_steps) + if planner_alg == 'final': + t_start_projection = 1 # project only at the last denoising step (t=0) + + ## Projector add the obstacles and similar ## + use_projection_inter_robot_cost = True + use_projection_obstacle_cost = True + proj_params = projection_args.get('project_params', None) + assert proj_params is not None, \ + "Projector parameters must be provided in the model args." + sample_fn_proj_params = { + 'pos_init': hard_conds[0][..., :start_state_pos.shape[-1]].clone().detach().repeat_interleave(n_samples//n_agents, dim=0), + 'dx_max': (proj_params['vel_max'] - 1e-3) * dt, + 'smoothness_weight': proj_params.get('smoothness_weight', 0.0), + } + + ##### ALGO PARAMS ##### + if use_vel_only_projector: + # Velocity-only ADMM projector + projector_args = projection_args.get('projector', {}).copy() + projector_args.pop('_target_', None) + admm_projector = ADMMProjectionOperatorCUDAGraph(**projector_args) + else: + # Projectors operate on normalized [-1,1] trajectories. + # All constraints must be transformed to normalized space. + # LimitsNormalizer: x_norm = 2*(x_world - mins)/(maxs - mins) - 1 + # x_world = (x_norm + 1)/2 * range + mins + traj_normalizer = dataset.normalizer.normalizers[dataset.field_key_traj] + pos_mins = traj_normalizer.mins[:2].cpu() + pos_maxs = traj_normalizer.maxs[:2].cpu() + pos_range = pos_maxs - pos_mins + norm_scale = (2.0 / pos_range).min().item() + + # 1) Normalize robot radius + normalized_robot_radius = robot.radius * norm_scale + + # 2) Normalize workspace bounds + normalized_ws_bounds = torch.stack([ + 2.0 * (env.limits[0, :2].cpu() - pos_mins) / pos_range - 1.0, + 2.0 * (env.limits[1, :2].cpu() - pos_mins) / pos_range - 1.0, + ]) + + logger.info(f'Normalization: scale={norm_scale:.4f}, ' + f'robot_radius: world={robot.radius:.4f} -> norm={normalized_robot_radius:.4f}, ' + f'ws_bounds: world={env.limits[:, :2].tolist()} -> norm={normalized_ws_bounds.tolist()}') + + # Everything in normalized space — the projector doesn't know about + # the normalization. SDF functions are wrapped so both input positions + # AND output distances are in normalized space. + ipopt_projector = IpoptProjector( + collision_fields=proj_collision_fields, + robot_radius=normalized_robot_radius, + n_agents=n_agents, + ws_bounds=normalized_ws_bounds, + max_iter=100, + ) + # For JaxScpADMM: obstacle geometry is normalized in step 4 below, + # so SDF distances are in normalized space. Use normalized radius for both. + jax_projector = JaxScpAdmmProjector( + collision_fields=proj_collision_fields, + robot_radius=normalized_robot_radius, + n_agents=n_agents, + ws_bounds=normalized_ws_bounds, + ) + + # 3) Wrap SDF functions so both input and output are in normalized space. + # Input: x_norm -> unnormalize -> x_world + # SDF: sdf(x_world) -> world-space distance + # Output: scale distance to normalized space (* norm_scale) + import casadi as ca + pos_mins_np = pos_mins.numpy() + pos_range_np = pos_range.numpy() + + def _wrap_sdf_normalized(sdf_fn, dim=2): + x_norm = ca.MX.sym('x_norm', dim) + x_world = (x_norm + 1.0) / 2.0 * pos_range_np + pos_mins_np + sdf_val = sdf_fn(x_world) * norm_scale # scale distance to normalized space + return ca.Function(sdf_fn.name() + '_norm', [x_norm], [sdf_val]) + + ipopt_projector.sdf_fns = [ + _wrap_sdf_normalized(fn) for fn in ipopt_projector.sdf_fns + ] + + # 4) Normalize JaxScpADMM obstacle centers and half_sizes + obs = jax_projector._obs_np + if obs['n_total'] > 0: + obs['centers'] = (2.0 * (obs['centers'] - pos_mins_np[None]) / pos_range_np[None] - 1.0).astype(np.float32) + obs['half_sizes'] = (obs['half_sizes'] * 2.0 / pos_range_np[None]).astype(np.float32) + obs['corner_radii'] = (obs['corner_radii'] * norm_scale).astype(np.float32) + if jax_projector.ws_min is not None: + jax_projector.ws_min = normalized_ws_bounds[0].numpy() + jax_projector.ws_max = normalized_ws_bounds[1].numpy() + assert projector_type in PROJECTORS, \ + f"Unknown projector: {projector_type}. Choose from {PROJECTORS}" + ############################ + + # Keep some variables in the class as members. + self.start_state_pos = torch.clone(start_state_pos) + self.goal_state_pos = torch.clone(goal_state_pos) + self.robot = robot + self.context = context + self.use_projection = False + self.use_coupling = use_coupling + self.n_diffusion_steps_without_noise = n_diffusion_steps_without_noise + self.hard_conds = hard_conds + self.model: GaussianDiffusionModel = model + self.n_support_points = n_support_points + self.t_start_guide = t_start_guide + self.n_guide_steps = n_guide_steps + self.guide = guide + self.tensor_args = tensor_args + self.projector = None + self.num_samples = n_samples + self.num_agents = n_agents + # When doing local inference, how many steps to add noise for before denoising again. + self.n_local_inference_noising_steps = n_local_inference_noising_steps + self.n_local_inference_denoising_steps = n_local_inference_denoising_steps + self.dataset: TrajectoryDataset = dataset + self.task = task + self.results_dir = results_dir + ## DiRecT ## + if use_vel_only_projector: + self.general_projector = admm_projector + else: + self._ipopt_projector = ipopt_projector + self._jax_projector = jax_projector + if projector_type == 'Ipopt': + self.general_projector = ipopt_projector + elif projector_type == 'JaxScpADMM': + self.general_projector = jax_projector + # Projection-metrics recorder (per-type violations / solver + # failures for the ablation). Enabled via PROJ_METRICS_OUT env + # var. Uses the SAME predicates as the final reported metrics + # (scripts/inference/inference_pcdiff.py): world-frame + # collision_detect at 2r, task.get_trajs_collision_and_free with + # the run's own interpolation factor, and calc_velocity on + # normalized positions vs vel_max + VEL_TOL. One evaluator for + # all optimizers, and its violations are violations by the + # main tables' own definition. + self.proj_metrics = None + if not use_vel_only_projector: + self.proj_metrics = ProjectionMetricsRecorder.from_env( + task=task, + robot_radius=robot.radius, # WORLD units + pos_mins=pos_mins, + pos_range=pos_range, + n_agents=n_agents, + vel_max=proj_params['vel_max'], + dt=dt, + num_interpolation=ceil( + n_support_points + * factor_num_interpolated_points_for_collision), + ) + self.t_start_projection = t_start_projection + + self.recent_call_data = PlannerOutput() + + sample_fn_guide_kwargs = { + "group_size": n_agents, + "margin_coeff": 1.99, # No margin for the guide + } + + self.sample_fn_kwargs = dict( + guide=self.guide, + projector=self.general_projector if start_projection_guidance > 0 else None, + guide_kwargs=sample_fn_guide_kwargs if use_coupling else {}, + project_params=sample_fn_proj_params, + n_guide_steps=self.n_guide_steps, + t_start_guide=self.t_start_guide, + t_start_projection=self.t_start_projection, + noise_std_extra_schedule_fn=lambda x: 0.5, + proj_metrics=self.proj_metrics, + timeit=timeit, + ) + + self.agent_batch_inds = torch.tensor_split( + torch.arange(n_samples, device=device), n_agents) + + # Sampling function used by run_constrained_inference. Subclasses + # (e.g. PDM) override this to change the projection strategy while + # keeping every other flag and hyperparameter identical. + self.sample_fn = direct_ddpm_sample_fn + + def __call__(self, + start_state_pos, goal_state_pos, + constraints_l=None, + experience: Optional[PathBatchExperience] = None, + *args, + **kwargs + ) -> PlannerOutput: + """ + Call the model with the given parameters. + :param n_samples: Number of trajectories to generate. + :param start_state_pos: The start state of the robot. + :param goal_state_pos: The goal state of the robot. + :param constraints_l: A list of constraints. + :param previous_path: The previous path of the robot. This would be used to guide the next path. + """ + if torch.allclose(start_state_pos, self.start_state_pos) and torch.allclose(goal_state_pos, self.goal_state_pos): + pass + else: + logger.info("Start or/and goal states changed. ") + self.update_start_goal_states(start_state_pos, goal_state_pos) + + # Carry out inference with the constraints. If there is no experience, inference from scratch. + with TimerCUDA() as timer_inference: + if experience is None: + trajs_normalized_iters, t_profile, _, _ = self.run_constrained_inference( + # Shape [B (n_samples), H, D] + cost_constraints_l=constraints_l) + # Otherwise, use the experience path as a seed for a local inference call. + else: + trajs_normalized_iters, _, _ = self.run_constrained_local_inference( + constraints_l, experience) + t_total = timer_inference.elapsed + + # Un-normalize trajectory samples from the models. + trajs_iters = self.dataset.unnormalize_trajectories( + trajs_normalized_iters) + + trajs_final = trajs_iters[-1] + trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs, _ = ( + self.task.get_trajs_collision_and_free(trajs_final, return_indices=True)) + + self.recent_call_data = PlannerOutput() + self.recent_call_data.trajs_iters = trajs_iters + self.recent_call_data.trajs_final = trajs_final + self.recent_call_data.trajs_final_coll = trajs_final_coll + self.recent_call_data.trajs_final_coll_idxs = trajs_final_coll_idxs + # Shape [B, H, D] + self.recent_call_data.trajs_final_free = trajs_final_free + self.recent_call_data.t_total = t_total + # Shape [B] + self.recent_call_data.trajs_final_free_idxs = trajs_final_free_idxs + self.recent_call_data.constraints_l = constraints_l + if t_profile is not None: + t_inner_total = sum(t_profile.values()) + t_profile['others'] = t_total - t_inner_total + self.recent_call_data.t_profile = t_profile + + if getattr(self, 'proj_metrics', None) is not None: + self.proj_metrics.finalize_trial() + + return self.recent_call_data + + def update_start_goal_states(self, start_state_pos: torch.Tensor, goal_state_pos: torch.Tensor): + """ + Update the start and goal states of the planner. + :param start_state_pos: The new start state position. + :param goal_state_pos: The new goal state position. + """ + assert start_state_pos.shape[0] == self.num_agents, \ + (f"Start state position shape {start_state_pos.shape} " + f"does not match number of agents {self.num_agents}.") + assert goal_state_pos.shape[0] == self.num_agents, \ + (f"Goal state position shape {goal_state_pos.shape} " + f"does not match number of agents {self.num_agents}.") + + self.start_state_pos = start_state_pos.clone().detach() + self.goal_state_pos = goal_state_pos.clone().detach() + + self.hard_conds = self.dataset.get_hard_conditions( + torch.stack((start_state_pos, goal_state_pos)), normalize=True) + group_sz = self.num_samples // self.num_agents + self.hard_conds["group_size"] = group_sz + + if 'project_params' in self.sample_fn_kwargs: + self.sample_fn_kwargs['project_params'].update({ + 'pos_init': self.hard_conds[0][..., :start_state_pos.shape[-1]].clone().detach().repeat_interleave(group_sz, dim=0), + }) + + def run_constrained_inference(self, + cost_constraints_l: Union[List[CostConstraint], + None] = None + ) -> Tuple[torch.Tensor, float, float]: + if cost_constraints_l is not None: + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l] + ) + + timeit = self.sample_fn_kwargs.get('timeit', False) + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + if timeit: + trajs_normalized_iters, t_sampling_profile = self.model.run_inference( + self.context, self.hard_conds, + n_samples=self.num_samples, horizon=self.n_support_points, + return_chain=True, + sample_fn=self.sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + else: + trajs_normalized_iters = self.model.run_inference( + self.context, self.hard_conds, + n_samples=self.num_samples, horizon=self.n_support_points, + return_chain=True, + sample_fn=self.sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + # logger.info(f't_model_sampling: {t_model_sampling:.3f} sec') + + # run extra guiding steps without diffusion + t_post_diffusion_guide = 0.0 + # Remove the extra cost. + self.guide.reset_extra_costs() + + if not timeit: + t_sampling_profile = None + + return trajs_normalized_iters, t_sampling_profile, t_model_sampling, t_post_diffusion_guide + + def run_constrained_local_inference(self, cost_constraints_l: List[CostConstraint], + experience: PathBatchExperience): + + raise NotImplementedError( + "The method `run_constrained_local_inference` is not implemented. " + "This method should be implemented in the derived class." + ) + + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters = self.model.run_local_inference( + experience.path_b, + self.n_local_inference_noising_steps, + self.n_local_inference_denoising_steps, + self.context, + self.hard_conds, + n_samples=self.num_samples, + horizon=self.n_support_points, + return_chain=True, + sample_fn=self.sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + t_post_diffusion_guide = 0.0 + + # Remove the extra cost. + self.guide.reset_extra_costs() + + return trajs_normalized_iters, t_model_sampling, t_post_diffusion_guide + + def save_recent_result(self): + # Compute motion planning metrics + print(f'\n----------------METRICS----------------') + print(f't_total: {self.recent_call_data.t_total:.3f} sec') + + success_free_trajs = self.task.compute_success_free_trajs( + self.recent_call_data.trajs_final) + fraction_free_trajs = self.task.compute_fraction_free_trajs( + self.recent_call_data.trajs_final) + collision_intensity_trajs = self.task.compute_collision_intensity_trajs( + self.recent_call_data.trajs_final) + + print(f'success: {success_free_trajs}') + print(f'percentage free trajs: {fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {collision_intensity_trajs * 100:.2f}') + + # compute costs only on collision-free trajectories + traj_final_free_best = None + idx_best_traj = None + cost_best_free_traj = None + cost_smoothness = None + cost_path_length = None + cost_all = None + variance_waypoint_trajs_final_free = None + if self.recent_call_data.trajs_final_free is not None: + print(f'cost smoothness: {self.recent_call_data.cost_smoothness.mean():.4f}, ' + f'{self.recent_call_data.cost_smoothness.std():.4f}') + print(f'cost path length: {self.recent_call_data.cost_path_length.mean():.4f}, ' + f'{self.recent_call_data.cost_path_length.std():.4f}') + + # compute best trajectory + cost_best_free_traj = torch.min( + self.recent_call_data.cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + # variance of waypoints + print(f'variance waypoint: {self.recent_call_data.variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + results_data_dict = { + 'trajs_iters': self.recent_call_data.trajs_iters, + 'trajs_final_coll': self.recent_call_data.trajs_final_coll, + 'trajs_final_coll_idxs': self.recent_call_data.trajs_final_coll_idxs, + 'trajs_final_free': self.recent_call_data.trajs_final_free, + 'trajs_final_free_idxs': self.recent_call_data.trajs_final_free_idxs, + 'success_free_trajs': success_free_trajs, + 'fraction_free_trajs': fraction_free_trajs, + 'collision_intensity_trajs': collision_intensity_trajs, + 'idx_best_traj': idx_best_traj, + 'traj_final_free_best': traj_final_free_best, + 'cost_best_free_traj': cost_best_free_traj, + 'cost_path_length_trajs_final_free': cost_path_length, + 'cost_smoothness_trajs_final_free': cost_smoothness, + 'cost_all_trajs_final_free': cost_all, + 'variance_waypoint_trajs_final_free': variance_waypoint_trajs_final_free, + 't_total': self.recent_call_data.t_total + } + with open(os.path.join(self.results_dir, 'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, + protocol=pickle.HIGHEST_PROTOCOL) + + def render_recent_result(self, animation_duration: float = 5.0): + # Render + planner_visualizer = PlanningVisualizer( + task=self.task, + ) + + base_file_name = Path(os.path.basename(__file__)).stem + traj_final_free_best = self.recent_call_data.trajs_final[self.recent_call_data.idx_best_traj] + pos_trajs_iters = self.robot.get_position( + self.recent_call_data.trajs_iters) + + planner_visualizer.animate_opt_iters_joint_space_state( + trajs=self.recent_call_data.trajs_iters, + pos_start_state=self.start_state_pos, pos_goal_state=self.goal_state_pos, + vel_start_state=torch.zeros_like(self.start_state_pos), + vel_goal_state=torch.zeros_like(self.goal_state_pos), + traj_best=traj_final_free_best, + video_filepath=os.path.join( + self.results_dir, f'{base_file_name}-joint-space-opt-iters.gif'), + n_frames=max((2, len(self.recent_call_data.trajs_iters) // 10)), + anim_time=5 + ) + + # visualize in the planning environment + planner_visualizer.animate_opt_iters_robots( + trajs=pos_trajs_iters, start_state=self.start_state_pos, goal_state=self.goal_state_pos, + traj_best=traj_final_free_best, + video_filepath=os.path.join( + self.results_dir, f'{base_file_name}-traj-opt-iters.gif'), + # n_frames=max((2, len(trajs_iters))), + n_frames=2, + anim_time=5 + ) + + planner_visualizer.animate_robot_trajectories( + trajs=pos_trajs_iters[-1], start_state=self.start_state_pos, goal_state=self.goal_state_pos, + plot_trajs=True, + video_filepath=os.path.join( + self.results_dir, f'{base_file_name}-robot-traj.gif'), + n_frames=max((2, pos_trajs_iters[-1].shape[1])), + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=self.recent_call_data.constraints_l + ) + + +class PDM(DiRecT): + """Projected Diffusion Model baseline: per-step projection of the noisy + latent x_t (cf. Christopher et al., "Constrained Synthesis with Projected + Diffusion Models"). + + Identical to DiRecT in every flag and hyperparameter (projector type, + t_start_projection gating, guidance config, noise schedule, configs) — + the only difference is the sampling function: after each DDPM update the + latent itself is projected onto the feasible set, instead of DiRecT's + Tweedie mapping to clean space, projection there, and correction back. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.sample_fn = pdm_ddpm_sample_fn diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/ipopt.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/ipopt.py new file mode 100644 index 0000000000000000000000000000000000000000..8815896d096c0ad9f46a6c276da9162d05cf6b58 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/ipopt.py @@ -0,0 +1,671 @@ +import os +import torch +import numpy as np +from typing import Tuple, List, Dict, Optional, Union +import logging +from mmd.models.projection.projectors import BaseProjector, ADMMProjectionOperatorCUDAGraph +from torch_robotics.torch_planning_objectives.fields.distance_fields import ( + DistanceField, CollisionObjectDistanceField, CollisionWorkspaceBoundariesDistanceField +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +import casadi as ca + +### IPOPT PROJECTOR ### +class IpoptProjector(BaseProjector): + """Nonlinear-programming trajectory projector using CasADi/IPOPT. + + Solves the following NLP independently for each (agent, sample) pair: + + min_{X} (1/2)||X - X_ref||^2 + smoothness_weight * sum_t ||x_{t+1} - x_t||^2 + + s.t. ||x_{t+1} - x_t||_2 <= dx_max for all t (velocity) + sdf_k(x_t) >= robot_radius for all t,k (obstacle avoidance) + ||x_a(t) - x_b(t)||_2 >= 2*robot_radius for all pairs (a,b), t (inter-robot) + x_0 = pos_init (start pinning) + x_{H-1} = pos_final (optional) (goal pinning) + ws_min + r <= x_t <= ws_max - r (workspace bounds) + + All quantities are in the caller's coordinate frame (typically normalized + [-1, 1] space — the caller normalizes before calling ``project()``). + + Uses ``casadi.nlpsol`` with IPOPT and ``casadi.Function.map()`` for + thread-parallel solves across the batch dimension. + """ + + def __init__( + self, + collision_fields: List[DistanceField], + robot_radius: float = 0.0, + n_agents: int = 1, + ws_bounds: Optional[torch.Tensor] = None, + use_obstacle_constraints: bool = True, + use_inter_robot_constraints: bool = True, + fix_final_point: bool = False, + smoothness_weight: float = 0.0, + max_iter: int = 100, + tol: float = 1e-4, + verbose: bool = False, + linear_solver: str = 'mumps', + warm_start: bool = True, + ): + """ + Args: + collision_fields: List of DistanceField objects from task.get_collision_fields(). + Handles CollisionObjectDistanceField, CollisionWorkspaceBoundariesDistanceField, + and CollisionSelfField (ignored — not relevant for single-body robots). + robot_radius: Collision radius of the robot body. Raw SDF values do not + account for the robot volume, so obstacle constraints are enforced as + sdf(x) >= robot_radius. Inter-robot constraints use 2 * robot_radius + as the minimum separation distance. + n_agents: Number of robots. The batch dimension B is split into n_agents + groups of equal size; inter-robot constraints are imposed between + corresponding samples across groups at each timestep. + ws_bounds: Workspace bounds tensor of shape (2, dim), i.e. [[x_min, y_min], [x_max, y_max]]. + If None, extracted from CollisionWorkspaceBoundariesDistanceField if present. + use_obstacle_constraints: If True, enforce sdf(x) >= robot_radius for + every waypoint against each obstacle primitive. + use_inter_robot_constraints: If True, enforce pairwise minimum separation + ||x_a(t) - x_b(t)||_2 >= 2 * robot_radius between all robot pairs + at every timestep. + fix_final_point: If True, pin the last waypoint of each agent to a + goal position supplied via params['pos_final'] at project() time. + Enforces x_{H-1} == pos_final as an equality constraint. + smoothness_weight: Weight for the smoothness regularisation term + sum_t ||x_{t+1} - x_t||^2 added to the objective. Encourages + shorter, smoother trajectories. 0.0 disables it. + max_iter: Maximum number of IPOPT iterations per solve. + tol: Convergence tolerance for IPOPT. + verbose: If True, set IPOPT print_level to 5; otherwise 0. + linear_solver: IPOPT linear solver backend (e.g. 'mumps', 'ma27'). + warm_start: Enable warm-starting between consecutive solves. + """ + self.ca = ca + self.robot_radius = robot_radius + self.n_agents = n_agents + self.use_obstacle_constraints = use_obstacle_constraints + self.use_inter_robot_constraints = use_inter_robot_constraints + self.fix_final_point = fix_final_point + self.smoothness_weight = smoothness_weight + self.max_iter = max_iter + self.tol = tol + self.verbose = verbose + + self.ipopt_opts = { + 'ipopt.max_iter': max_iter, + 'ipopt.tol': tol, + 'ipopt.print_level': 5 if verbose else 0, + 'ipopt.linear_solver': linear_solver, + 'ipopt.warm_start_init_point': 'yes' if warm_start else 'no', + 'print_time': 1 if verbose else 0, + } + + self.sdf_fns = [] # List of CasADi Functions, one per primitive + self.ws_min = None + self.ws_max = None + + for field in collision_fields: + if isinstance(field, CollisionWorkspaceBoundariesDistanceField): + # The task may store relaxed bounds (e.g. * 1.08 hack). + # We only use these as a fallback; prefer explicit ws_bounds. + self.ws_min = field.ws_min.cpu().numpy() + self.ws_max = field.ws_max.cpu().numpy() + elif isinstance(field, CollisionObjectDistanceField): + sdf_fn_list = self._build_casadi_sdf(field) + self.sdf_fns.extend(sdf_fn_list) + + # Explicit ws_bounds override anything extracted from the boundary + # field (which may carry the 1.08x relaxation hack from PlanningTask). + if ws_bounds is not None: + self.ws_min = ws_bounds[0].cpu().numpy() + self.ws_max = ws_bounds[1].cpu().numpy() + + # Minimum separation distance for inter-robot constraints + self.min_separation = 2.0 * robot_radius + + # Pre-compute inter-robot pair indices: all (a, b) with a < b + self.agent_pairs = [] + if use_inter_robot_constraints and n_agents > 1: + for a in range(n_agents): + for b in range(a + 1, n_agents): + self.agent_pairs.append((a, b)) + + n_obstacle = len(self.sdf_fns) if use_obstacle_constraints else 0 + n_inter = len(self.agent_pairs) + logger.info( + f'IpoptProjector: {n_obstacle} obstacle constraint(s), ' + f'{n_inter} inter-robot pair(s), ' + f'robot_radius={robot_radius}, ' + f'workspace bounds: {self.ws_min is not None}') + + ### VELOCITY CONSTRAINTS ### + @staticmethod + def velocity_constraints( + ca_module, + wpts: List['ca.MX'], + pos_init: 'ca.MX', + dx_max: 'ca.MX', + H: int, + dim: int, + ) -> Tuple[List, List, List]: + """ + Build CasADi expressions for the velocity (max-displacement) constraints. + + Enforces ||x_{t+1} - x_t||_2 <= dx_max for consecutive waypoints, plus + ||x_0 - pos_init||_2 <= dx_max for the anchor. + + Args: + ca_module: The casadi module. + wpts: List of H column vectors, each (dim, 1). + pos_init: Initial position column vector, (dim, 1). + dx_max: Maximum displacement per step (symbolic scalar). + H: Number of waypoints. + dim: State dimension. + + Returns: + (g, lbg, ubg): constraint expressions, lower bounds, upper bounds. + """ + g = [] + lbg = [] + ubg = [] + dx_max_sq = dx_max ** 2 + + # Anchor: x_0 is now pinned via variable bounds (lbx=ubx=pos_init), + # so we only need the velocity constraint from x_0 to x_1. + + # Consecutive: ||x_{t+1} - x_t||^2 - dx_max^2 <= 0 + for t in range(H - 1): + diff = wpts[t + 1] - wpts[t] + g.append(ca_module.dot(diff, diff) - dx_max_sq) + lbg.append(-float('inf')) + ubg.append(0.0) + + return g, lbg, ubg + + ### INTER ROBOT COLLISIONS ### + def inter_robot_constraints( + self, + X_agents: List[List['ca.MX']], + H: int, + ) -> Tuple[List, List, List]: + """ + Build CasADi expressions for inter-robot collision avoidance. + + For every pair of agents (a, b) and every timestep t, enforces: + ||x_a(t) - x_b(t)||^2 >= (2 * robot_radius)^2 + + Args: + X_agents: List of waypoint lists, one per agent. + X_agents[a][t] is a (dim, 1) column vector. + H: Number of waypoints. + + Returns: + (g, lbg, ubg): constraint expressions, lower bounds, upper bounds. + """ + ca = self.ca + g = [] + lbg = [] + ubg = [] + + min_sep_sq = self.min_separation ** 2 + + for (a, b) in self.agent_pairs: + for t in range(H): + diff = X_agents[a][t] - X_agents[b][t] + g.append(ca.dot(diff, diff)) + lbg.append(min_sep_sq) + ubg.append(float('inf')) + + return g, lbg, ubg + + ### OBSTACLE COLLISIONS ### + def obstacle_constraints( + self, + wpts: List['ca.MX'], + H: int, + ) -> Tuple[List, List, List]: + """ + Build CasADi expressions for obstacle avoidance constraints. + + For every waypoint t and every obstacle primitive, enforces: + sdf(x_t) >= robot_radius + + Args: + wpts: List of H column vectors, each (dim, 1). + H: Number of waypoints. + + Returns: + (g, lbg, ubg): constraint expressions, lower bounds, upper bounds. + """ + g = [] + lbg = [] + ubg = [] + + for sdf_fn in self.sdf_fns: + for t in range(H): + g.append(sdf_fn(wpts[t])) + lbg.append(self.robot_radius) + ubg.append(float('inf')) + + return g, lbg, ubg + + ### CASADI CONSTRUCTION OF OBSTACLES ### + def _build_casadi_sdf( + self, field: CollisionObjectDistanceField + ) -> List['ca.Function']: + """ + Extract obstacle SDFs from a CollisionObjectDistanceField and build + individual CasADi functions — one per primitive instance. + + Returns: + List of CasADi Function f(x) -> sdf_value where x has shape (dim,). + Each function corresponds to a single primitive (one sphere, one box, etc.). + """ + if field.df_obj_list_fn is None: + return [] + + df_obj_list = field.df_obj_list_fn() + if len(df_obj_list) == 0: + return [] + + from torch_robotics.environments.grid_map_sdf import GridMapSDF + grid_sdfs = [o for o in df_obj_list if isinstance(o, GridMapSDF)] + analytic_objs = [o for o in df_obj_list if not isinstance(o, GridMapSDF)] + + fns = [] + # Grid SDF is already C2-smooth (bspline), keep as single function + for grid_sdf in grid_sdfs: + fns.append(self._build_interpolant_from_grid(grid_sdf)) + # Analytic primitives: one function per individual shape instance + fns.extend(self._build_analytical_sdfs(analytic_objs)) + + return fns + + def _build_interpolant_from_grid(self, grid_sdf) -> 'ca.Function': + """ + Build a CasADi bspline interpolant from a precomputed GridMapSDF. + + The bspline interpolant is C2-smooth, which is better for IPOPT + convergence than the piecewise-linear surrogate used by the torch grid. + """ + ca = self.ca + + limits = grid_sdf.limits.cpu() + cmap_dim = grid_sdf.cmap_dim.cpu() + sdf_np = grid_sdf.sdf_tensor.cpu().numpy() + dim = grid_sdf.dim + + grid_axes = [] + for d in range(dim): + axis = np.linspace( + float(limits[0, d]), float(limits[1, d]), int(cmap_dim[d]) + ) + grid_axes.append(axis) + + # CasADi interpolant expects data in column-major (Fortran) order + sdf_flat = sdf_np.ravel(order='F').tolist() + + sdf_interp = ca.interpolant( + 'sdf_obstacle', 'bspline', grid_axes, sdf_flat + ) + + x_sym = ca.MX.sym('x', dim) + sdf_val = sdf_interp(x_sym) + return ca.Function('sdf_obstacle', [x_sym], [sdf_val], ['x'], ['sdf']) + + def _build_analytical_sdfs(self, obj_list) -> List['ca.Function']: + """ + Build individual CasADi SDF functions from a list of ObjectField / + primitive objects — one function per primitive instance (per sphere, + per box, etc.). Each constraint is smooth, which is better for IPOPT + convergence than a single min(sdf_i) union. + """ + ca = self.ca + from torch_robotics.environments.primitives import ( + ObjectField, MultiSphereField, MultiBoxField, MultiRoundedBoxField + ) + + if len(obj_list) == 0: + return [] + + # Infer dim from the first primitive that actually has data, + # since some fields (e.g. empty MultiSphereField) report dim=0. + dim = 0 + for obj in obj_list: + if isinstance(obj, ObjectField): + for prim in obj.fields: + if prim.dim > 0: + dim = prim.dim + break + elif obj.dim > 0: + dim = obj.dim + if dim > 0: + break + if dim == 0: + return [] + + x_sym = ca.MX.sym('x', dim) + fns = [] + + for obj in obj_list: + if isinstance(obj, ObjectField): + pos = obj.pos.cpu().numpy()[:dim] + x_local = x_sym - pos + for prim in obj.fields: + if prim.dim == 0: + continue # skip empty primitives + exprs = self._primitive_sdf_exprs(prim, x_local) + for k, expr in enumerate(exprs): + name = f'sdf_{type(prim).__name__}_{len(fns)}' + fns.append(ca.Function(name, [x_sym], [expr], ['x'], ['sdf'])) + else: + if obj.dim == 0: + continue + exprs = self._primitive_sdf_exprs(obj, x_sym) + for k, expr in enumerate(exprs): + name = f'sdf_{type(obj).__name__}_{len(fns)}' + fns.append(ca.Function(name, [x_sym], [expr], ['x'], ['sdf'])) + + return fns + + def _primitive_sdf_exprs(self, prim, x_sym) -> List: + """ + Return a list of CasADi scalar expressions — one per individual shape + instance in the primitive (one per sphere, one per box, etc.). + + Supports MultiSphereField and MultiBoxField / MultiRoundedBoxField. + """ + ca = self.ca + from torch_robotics.environments.primitives import ( + MultiSphereField, MultiBoxField, MultiRoundedBoxField + ) + + # Small epsilon to avoid sqrt(0) gradient singularity in norm + _eps = 1e-12 + + if isinstance(prim, MultiSphereField): + centers = prim.centers.cpu().numpy() + radii = prim.radii.cpu().numpy() + exprs = [] + for i in range(len(centers)): + diff = x_sym - centers[i] + d = ca.sqrt(ca.dot(diff, diff) + _eps) - float(radii[i]) + exprs.append(d) + return exprs + + elif isinstance(prim, (MultiRoundedBoxField, MultiBoxField)): + centers = prim.centers.cpu().numpy() + half_sizes = prim.half_sizes.cpu().numpy() + has_radius = hasattr(prim, 'radius') and prim.radius is not None + radii = prim.radius.cpu().numpy() if has_radius else None + + dim = x_sym.shape[0] + exprs = [] + for i in range(len(centers)): + r = float(radii[i]) if radii is not None else 0.0 + q = ca.fabs(x_sym - centers[i]) - half_sizes[i] + r + q_pos = ca.fmax(q, 0) + outer = ca.sqrt(ca.dot(q_pos, q_pos) + _eps) + q_max = q[0] + for d_idx in range(1, dim): + q_max = ca.fmax(q_max, q[d_idx]) + inner = ca.fmin(q_max, 0) + d = outer + inner - r + exprs.append(d) + return exprs + + else: + logger.warning( + f"Unsupported primitive type {type(prim).__name__} for " + f"CasADi SDF conversion, skipping." + ) + return [] + + ### SOLVER CONSTRUCTION AND CACHING ### + def _build_solver(self, H: int, dim: int): + """ + Build the CasADi NLP for one sample group (n_agents coupled + trajectories) and cache the solver. + + Decision variables: X_a (H x dim) for each agent a, flattened + into a single vector of length n_agents * H * dim. + Parameters (passed at solve time, not optimised): + p = [pos_init_0, ..., pos_init_{n-1}, + (pos_final_0, ..., pos_final_{n-1}), # only if fix_final_point + dx_max, X_ref_flat] + length = n_agents * dim * (1 + fix_final_point) + 1 + n_agents * H * dim. + """ + ca = self.ca + n = self.n_agents + n_vars = n * H * dim + n_anchor_params = n * dim # pos_inits + n_goal_params = n * dim if self.fix_final_point else 0 + n_params = n_anchor_params + n_goal_params + 1 + n_vars + + # Decision variables — flat vector, row-major: [x0_0, x0_1, x1_0, x1_1, ...] + X_flat = ca.MX.sym('X', n_vars) + + # Extract per-agent waypoint lists: X_agents[a][t] is (dim, 1) column vector + X_agents = [] + for a in range(n): + base = a * H * dim + wpts = [] + for t in range(H): + wpts.append(X_flat[base + t * dim: base + (t + 1) * dim]) + X_agents.append(wpts) + + # Parameters + p = ca.MX.sym('p', n_params) + idx = 0 + pos_inits = [] + for a in range(n): + pos_inits.append(p[idx:idx + dim]) + idx += dim + pos_finals = [] + if self.fix_final_point: + for a in range(n): + pos_finals.append(p[idx:idx + dim]) + idx += dim + dx_max_sym = p[idx]; idx += 1 + X_ref = p[idx:] + + # Objective: projection – minimise distance to reference + f = ca.dot(X_flat - X_ref, X_flat - X_ref) + + # Smoothness regularisation: sum of squared consecutive displacements + if self.smoothness_weight > 0: + for a in range(n): + for t in range(H - 1): + diff = X_agents[a][t + 1] - X_agents[a][t] + f = f + self.smoothness_weight * ca.dot(diff, diff) + + # Constraints + g, lbg, ubg = [], [], [] + + for a in range(n): + gv, lv, uv = self.velocity_constraints( + ca, X_agents[a], pos_inits[a], dx_max_sym, H, dim) + g.extend(gv); lbg.extend(lv); ubg.extend(uv) + + if self.use_obstacle_constraints and len(self.sdf_fns) > 0: + go, lo, uo = self.obstacle_constraints(X_agents[a], H) + g.extend(go); lbg.extend(lo); ubg.extend(uo) + + if self.use_inter_robot_constraints and len(self.agent_pairs) > 0: + gr, lr, ur = self.inter_robot_constraints(X_agents, H) + g.extend(gr); lbg.extend(lr); ubg.extend(ur) + + # Goal-pinning is now done via variable bounds (lbx=ubx=pos_final), + # not equality constraints. This eliminates endpoints as DOFs. + + # Variable bounds (workspace, inset by robot_radius so the disk + # doesn't overlap the boundary) + lbx = np.full(n_vars, -np.inf) + ubx = np.full(n_vars, np.inf) + if self.ws_min is not None and self.ws_max is not None: + for a in range(n): + for t in range(H): + for d in range(dim): + idx = a * H * dim + t * dim + d + lbx[idx] = float(self.ws_min[d]) + self.robot_radius + ubx[idx] = float(self.ws_max[d]) - self.robot_radius + + g_cat = ca.vertcat(*g) if g else ca.MX(0, 1) + nlp = {'x': X_flat, 'f': f, 'g': g_cat, 'p': p} + solver = ca.nlpsol('ipopt_proj', 'ipopt', nlp, self.ipopt_opts) + + # Cache everything + self._solver = solver + self._H = H + self._dim = dim + self._n_vars = n_vars + self._n_params = n_params + self._lbg = np.array(lbg, dtype=np.float64) if lbg else np.array([]) + self._ubg = np.array(ubg, dtype=np.float64) if ubg else np.array([]) + self._lbx = lbx + self._ubx = ubx + self._solver_map = None + self._cached_batch_groups = None + + logger.info( + f'IpoptProjector solver built: {n_vars} vars, ' + f'{len(lbg)} constraints, {n} agent(s), H={H}, dim={dim}') + + ### PROJECT ### + def project( + self, + x: torch.Tensor, + params: Optional[Dict[str, torch.Tensor]], + ) -> torch.Tensor: + """ + Project trajectories onto the feasible set via IPOPT. + + Args: + x: Trajectory tensor, shape (B, H, dim). Agent a owns indices + [a*group_size : (a+1)*group_size] where group_size = B // n_agents. + params: Dict with: + 'dx_max' (float): max displacement per timestep. + 'pos_init' (Tensor): shape (B, dim), initial positions. + 'pos_final' (Tensor, optional): shape (B, dim), goal positions. + Required when fix_final_point=True. + + Returns: + Projected trajectories, same shape as x. + """ + B, H, dim = x.shape + n = self.n_agents + group_size = B // n + + assert B % n == 0, ( + f"Batch size {B} must be divisible by n_agents {n}.") + + dx_max = float(params['dx_max']) + pos_init = params['pos_init'] + assert pos_init.shape == (B, dim) + + pos_final = None + if self.fix_final_point: + pos_final = params['pos_final'] + assert pos_final.shape == (B, dim) + + # Build / rebuild solver if dimensions changed + if not hasattr(self, '_solver') or self._H != H or self._dim != dim: + self._build_solver(H, dim) + + # Build / rebuild mapped solver if batch size changed + if (self._solver_map is None + or self._cached_batch_groups != group_size): + n_threads = min(group_size, os.cpu_count() or 8) + # Force MUMPS/OpenMP to single-threaded so CasADi's thread + # pool can run N solves truly in parallel. + os.environ.setdefault('OMP_NUM_THREADS', '1') + self._solver_map = self._solver.map( + group_size, 'thread', n_threads) + self._cached_batch_groups = group_size + logger.info( + f'IpoptProjector: mapped solver for {group_size} groups, ' + f'{n_threads} threads') + + # To numpy + x_np = x.detach().cpu().numpy() + pos_init_np = pos_init.detach().cpu().numpy() + pos_final_np = pos_final.detach().cpu().numpy() if pos_final is not None else None + n_vars = self._n_vars + + # Pack x0 (initial guess) and p (parameters) per group. + # For group g, agent a's trajectory is x_np[a * group_size + g]. + x0_all = np.zeros((n_vars, group_size)) + p_all = np.zeros((self._n_params, group_size)) + + for g in range(group_size): + x0_parts = [] + p_init_parts = [] + p_final_parts = [] + for a in range(n): + batch_idx = a * group_size + g + x0_parts.append(x_np[batch_idx].ravel()) + p_init_parts.append(pos_init_np[batch_idx]) + if pos_final_np is not None: + p_final_parts.append(pos_final_np[batch_idx]) + + x0_flat = np.concatenate(x0_parts) + p_pieces = p_init_parts + p_final_parts + [[dx_max]] + [x0_flat] + x0_all[:, g] = x0_flat + p_all[:, g] = np.concatenate(p_pieces) + + # Tile bounds across groups + lbx_tiled = np.tile(self._lbx[:, None], (1, group_size)) + ubx_tiled = np.tile(self._ubx[:, None], (1, group_size)) + + # Pin start (t=0) as non-DOF: lbx = ubx = pos_init + for g_idx in range(group_size): + for a in range(n): + batch_idx = a * group_size + g_idx + offset = a * H * dim + for d in range(dim): + idx = offset + d # t=0 + lbx_tiled[idx, g_idx] = pos_init_np[batch_idx, d] + ubx_tiled[idx, g_idx] = pos_init_np[batch_idx, d] + # Pin end (t=H-1) if fix_final_point + if self.fix_final_point and pos_final_np is not None: + for d in range(dim): + idx = offset + (H - 1) * dim + d + lbx_tiled[idx, g_idx] = pos_final_np[batch_idx, d] + ubx_tiled[idx, g_idx] = pos_final_np[batch_idx, d] + + if len(self._lbg) > 0: + lbg_tiled = np.tile(self._lbg[:, None], (1, group_size)) + ubg_tiled = np.tile(self._ubg[:, None], (1, group_size)) + else: + lbg_tiled = np.zeros((0, group_size)) + ubg_tiled = np.zeros((0, group_size)) + + # Solve all groups in parallel + sol = self._solver_map( + x0=x0_all, + lbx=lbx_tiled, + ubx=ubx_tiled, + lbg=lbg_tiled, + ubg=ubg_tiled, + p=p_all, + ) + + x_sol = np.array(sol['x']) # (n_vars, group_size) + + # Unpack into (B, H, dim) + result = np.empty_like(x_np) + for g in range(group_size): + for a in range(n): + batch_idx = a * group_size + g + offset = a * H * dim + result[batch_idx] = ( + x_sol[offset:offset + H * dim, g].reshape(H, dim)) + + return torch.tensor(result, dtype=x.dtype, device=x.device) + +### END IPOPT PROJECTOR ### diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/jax_scp_admm.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/jax_scp_admm.py new file mode 100644 index 0000000000000000000000000000000000000000..914de8842c06e87b689519c12088a146fea70a88 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/jax_scp_admm.py @@ -0,0 +1,1371 @@ +""" +Batched JAX SCP+ADMM Collision Avoidance Projector. + +A fully JAX-jittable, GPU-accelerated trajectory projector using +Successive Convexification (SCP) with ADMM inner solves for multi-robot +collision avoidance. Drop-in replacement for IpoptProjector with the same +BaseProjector.project(x, params) interface. + +Internal sections: + 1. Data Structures + 2. Torch-JAX Bridge + 3. Obstacle Oracle (SDF margin + normal) + 4. Active Constraint Selection + 5. Tridiagonal Solver (Thomas algorithm) + 6. Velocity Projection (Z update) + 7. Local Collision QP (C, s update) + 8. ADMM Inner Loop + 9. SCP Outer Loop + 10. JaxScpAdmmProjector class +""" + +import os +import logging +import functools +from typing import List, Dict, Optional, Tuple, NamedTuple + +import numpy as np +import torch + +import jax +import jax.numpy as jnp +from jax import lax + +from mmd.models.projection.projectors import BaseProjector +from torch_robotics.torch_planning_objectives.fields.distance_fields import ( + DistanceField, + CollisionObjectDistanceField, + CollisionWorkspaceBoundariesDistanceField, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 1: Data Structures +# ═══════════════════════════════════════════════════════════════════════════════ + +class SphereObstacles(NamedTuple): + centers: jnp.ndarray # (N_sph, 2) + radii: jnp.ndarray # (N_sph,) + +class BoxObstacles(NamedTuple): + centers: jnp.ndarray # (N_box, 2) + half_sizes: jnp.ndarray # (N_box, 2) + corner_radii: jnp.ndarray # (N_box,) + +class ObstacleSet(NamedTuple): + # Unified flat arrays for vectorized oracle. + # All obstacles are stored as "generalized rounded boxes": + # spheres -> half_sizes=0, corner_radius=radius + # boxes -> corner_radius=0 + # rounded -> as-is + centers: jnp.ndarray # (N_obs, 2) + half_sizes: jnp.ndarray # (N_obs, 2) + corner_radii: jnp.ndarray # (N_obs,) + n_total: int + +class ActiveConstraintDescriptors(NamedTuple): + # Robot-robot: (B, H, K_rr, ...) + rr_i_idx: jnp.ndarray # (B, H, K_rr) int + rr_j_idx: jnp.ndarray # (B, H, K_rr) int + rr_normal: jnp.ndarray # (B, H, K_rr, 2) + rr_rhs: jnp.ndarray # (B, H, K_rr) + rr_mask: jnp.ndarray # (B, H, K_rr) bool + # Obstacle: (B, H, K_obs, ...) + obs_robot_idx: jnp.ndarray # (B, H, K_obs) int + obs_obs_idx: jnp.ndarray # (B, H, K_obs) int + obs_normal: jnp.ndarray # (B, H, K_obs, 2) + obs_rhs: jnp.ndarray # (B, H, K_obs) + obs_mask: jnp.ndarray # (B, H, K_obs) bool + +class AdmmState(NamedTuple): + X: jnp.ndarray # (B, H, N_r, 2) + V: jnp.ndarray # (B, H, N_r, 2) + Z: jnp.ndarray # (B, H-1, N_r, 2) + C: jnp.ndarray # (B, H, N_r, 2) + s: jnp.ndarray # (B, H, K_rr + K_obs) + U_xv: jnp.ndarray # (B, H, N_r, 2) + U_xc: jnp.ndarray # (B, H, N_r, 2) + U_z: jnp.ndarray # (B, H-1, N_r, 2) + +class ScpState(NamedTuple): + X: jnp.ndarray + admm_state: AdmmState + mu: jnp.ndarray # scalar + tau: jnp.ndarray # scalar + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 2: Torch-JAX Bridge +# ═══════════════════════════════════════════════════════════════════════════════ + +def _torch_to_jax(t: torch.Tensor) -> jnp.ndarray: + return jnp.asarray(t.detach().cpu().numpy()) + + +def _jax_to_torch(a: jnp.ndarray, dtype: torch.dtype, device: torch.device) -> torch.Tensor: + return torch.tensor(np.asarray(a), dtype=dtype, device=device) + + +def _repack_batch_to_grouped( + x: jnp.ndarray, + pos_init: jnp.ndarray, + pos_final: Optional[jnp.ndarray], + n_agents: int, +) -> Tuple[jnp.ndarray, jnp.ndarray, Optional[jnp.ndarray]]: + """ + Convert from IpoptProjector batch layout to grouped time-major layout. + + IPOPT layout: batch[a * group_size + g] = agent a, sample g. + Output: (group_size, H, n_agents, 2) trajectories + (group_size, n_agents, 2) positions + """ + B, H, dim = x.shape + group_size = B // n_agents + + # x: (B, H, dim) -> (n_agents, group_size, H, dim) -> (group_size, H, n_agents, dim) + x_grouped = x.reshape(n_agents, group_size, H, dim) + x_grouped = jnp.transpose(x_grouped, (1, 2, 0, 3)) + + pos_init_grouped = pos_init.reshape(n_agents, group_size, dim) + pos_init_grouped = jnp.transpose(pos_init_grouped, (1, 0, 2)) + + pos_final_grouped = None + if pos_final is not None: + pos_final_grouped = pos_final.reshape(n_agents, group_size, dim) + pos_final_grouped = jnp.transpose(pos_final_grouped, (1, 0, 2)) + + return x_grouped, pos_init_grouped, pos_final_grouped + + +def _unpack_grouped_to_batch( + X: jnp.ndarray, n_agents: int, B: int +) -> jnp.ndarray: + """Inverse of _repack_batch_to_grouped.""" + group_size, H, _n, dim = X.shape + # (group_size, H, n_agents, dim) -> (n_agents, group_size, H, dim) -> (B, H, dim) + X_t = jnp.transpose(X, (2, 0, 1, 3)) + return X_t.reshape(B, H, dim) + + +def _extract_obstacle_params_from_fields( + collision_fields: List[DistanceField], + ws_bounds: Optional[torch.Tensor], + robot_radius: float, +) -> Tuple[ObstacleSet, Optional[np.ndarray], Optional[np.ndarray]]: + """ + Walk collision_fields exactly as IpoptProjector.__init__ does and extract + obstacle geometry as JAX arrays in a unified "generalized rounded box" format. + """ + from torch_robotics.environments.primitives import ( + ObjectField, MultiSphereField, MultiBoxField, MultiRoundedBoxField, + ) + from torch_robotics.environments.grid_map_sdf import GridMapSDF + + ws_min_np = None + ws_max_np = None + all_centers = [] + all_half_sizes = [] + all_corner_radii = [] + + for field in collision_fields: + if isinstance(field, CollisionWorkspaceBoundariesDistanceField): + ws_min_np = field.ws_min.cpu().numpy() + ws_max_np = field.ws_max.cpu().numpy() + elif isinstance(field, CollisionObjectDistanceField): + if field.df_obj_list_fn is None: + continue + df_obj_list = field.df_obj_list_fn() + # Filter out grid SDFs (not supported in this version) + analytic_objs = [o for o in df_obj_list if not isinstance(o, GridMapSDF)] + + for obj in analytic_objs: + if isinstance(obj, ObjectField): + pos = obj.pos.cpu().numpy()[:2] + for prim in obj.fields: + _extract_prim(prim, pos, all_centers, all_half_sizes, all_corner_radii) + else: + _extract_prim(obj, np.zeros(2), all_centers, all_half_sizes, all_corner_radii) + + if ws_bounds is not None: + ws_min_np = ws_bounds[0].cpu().numpy() + ws_max_np = ws_bounds[1].cpu().numpy() + + # Return numpy arrays -- defer JAX conversion to first solve call + if len(all_centers) == 0: + obs_np = { + 'centers': np.zeros((0, 2), dtype=np.float32), + 'half_sizes': np.zeros((0, 2), dtype=np.float32), + 'corner_radii': np.zeros((0,), dtype=np.float32), + 'n_total': 0, + } + else: + obs_np = { + 'centers': np.array(all_centers, dtype=np.float32), + 'half_sizes': np.array(all_half_sizes, dtype=np.float32), + 'corner_radii': np.array(all_corner_radii, dtype=np.float32), + 'n_total': len(all_centers), + } + + return obs_np, ws_min_np, ws_max_np + + +def _extract_prim(prim, pos_offset, all_centers, all_half_sizes, all_corner_radii): + """Extract individual shape instances from a primitive into flat lists.""" + from torch_robotics.environments.primitives import ( + MultiSphereField, MultiBoxField, MultiRoundedBoxField, + ) + + if isinstance(prim, MultiSphereField): + c_np = prim.centers.cpu().numpy() + if c_np.ndim < 2 or c_np.shape[0] == 0: + return + centers = c_np[:, :2] + radii = prim.radii.cpu().numpy() + for i in range(len(centers)): + r = float(radii[i]) + all_centers.append(centers[i] + pos_offset) + # Sphere = rounded box with half_sizes=r, corner_radius=r. + # This makes the rounded-box SDF correctly reduce to ||p-c|| - r. + all_half_sizes.append(np.array([r, r])) + all_corner_radii.append(r) + + elif isinstance(prim, (MultiRoundedBoxField, MultiBoxField)): + c_np = prim.centers.cpu().numpy() + if c_np.ndim < 2 or c_np.shape[0] == 0: + return + centers = c_np[:, :2] + half_sizes = prim.half_sizes.cpu().numpy()[:, :2] + has_radius = hasattr(prim, 'radius') and prim.radius is not None + radii = prim.radius.cpu().numpy() if has_radius else None + for i in range(len(centers)): + r = float(radii[i]) if radii is not None else 0.0 + all_centers.append(centers[i] + pos_offset) + all_half_sizes.append(half_sizes[i]) + all_corner_radii.append(r) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 3: Obstacle Oracle (SDF margin + normal) +# ═══════════════════════════════════════════════════════════════════════════════ + +_SDF_EPS = 1e-12 + + +def _rounded_box_sdf(p, center, half_size, corner_radius): + """ + Signed distance from point p to a rounded box. + Matches ipopt.py _primitive_sdf_exprs (lines 384-402). + + For spheres stored as half_size=0, corner_radius=r, this reduces to: + sqrt(||p-c||^2 + eps) - r, which matches the sphere SDF. + """ + q = jnp.abs(p - center) - half_size + corner_radius + q_pos = jnp.maximum(q, 0.0) + outer = jnp.sqrt(jnp.dot(q_pos, q_pos) + _SDF_EPS) + inner = jnp.minimum(jnp.max(q), 0.0) + return outer + inner - corner_radius + + +def _obstacle_margin_single(p, center, half_size, corner_radius, robot_radius): + """Signed margin for a single robot position vs a single obstacle.""" + sdf_val = _rounded_box_sdf(p, center, half_size, corner_radius) + return sdf_val - robot_radius + + +def _obstacle_margin_and_normal_single(p, center, half_size, corner_radius, robot_radius): + """Margin and its gradient (normal) w.r.t. p.""" + margin_fn = lambda pp: _obstacle_margin_single(pp, center, half_size, corner_radius, robot_radius) + margin = margin_fn(p) + normal = jax.grad(margin_fn)(p) + return margin, normal + + +def _all_obstacle_margins_single_robot(p, obs_set, robot_radius): + """Margins for one robot position against all obstacles. Returns (N_obs,).""" + return jax.vmap( + lambda c, hs, cr: _obstacle_margin_single(p, c, hs, cr, robot_radius) + )(obs_set.centers, obs_set.half_sizes, obs_set.corner_radii) + + +def _all_obstacle_margins(positions, obs_set, robot_radius): + """ + Margins for all robots at one timestep against all obstacles. + positions: (N_robots, 2) + Returns: (N_robots, N_obs) + """ + return jax.vmap( + lambda p: _all_obstacle_margins_single_robot(p, obs_set, robot_radius) + )(positions) + + +def _obstacle_margin_and_normal_for_pair(p, obs_idx, obs_set, robot_radius): + """Margin and normal for one (robot_position, obstacle_index) pair.""" + center = obs_set.centers[obs_idx] + half_size = obs_set.half_sizes[obs_idx] + corner_radius = obs_set.corner_radii[obs_idx] + return _obstacle_margin_and_normal_single(p, center, half_size, corner_radius, robot_radius) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 4: Active Constraint Selection +# ═══════════════════════════════════════════════════════════════════════════════ + +def _compute_robot_robot_margins_at_timestep(positions, pair_i, pair_j, min_sep): + """ + Compute robot-robot separation margins for all pre-computed pairs at one timestep. + positions: (N_robots, 2) + pair_i, pair_j: (N_pairs,) int arrays of robot index pairs + Returns: (N_pairs,) margins = ||p_i - p_j|| - min_sep + """ + pi = positions[pair_i] # (N_pairs, 2) + pj = positions[pair_j] # (N_pairs, 2) + diff = pi - pj + dist = jnp.sqrt(jnp.sum(diff ** 2, axis=-1) + _SDF_EPS) + return dist - min_sep + + +def _compute_robot_robot_margins(X, pair_i, pair_j, min_sep): + """ + X: (B, H, N_robots, 2) + Returns: (B, H, N_pairs) + """ + return jax.vmap(jax.vmap( + lambda pos: _compute_robot_robot_margins_at_timestep(pos, pair_i, pair_j, min_sep) + ))(X) + + +def _compute_obstacle_margins(X, obs_set, robot_radius): + """ + X: (B, H, N_robots, 2) + Returns: (B, H, N_robots * N_obs) + """ + def per_timestep(positions): + # (N_robots, N_obs) -> flatten to (N_robots * N_obs,) + m = _all_obstacle_margins(positions, obs_set, robot_radius) + return m.ravel() + + return jax.vmap(jax.vmap(per_timestep))(X) + + +def _select_active_constraints( + X: jnp.ndarray, + obs_set: ObstacleSet, + robot_radius: float, + min_sep: float, + K_rr: int, + K_obs: int, + pair_i: jnp.ndarray, + pair_j: jnp.ndarray, + N_robots: int, + margin_threshold: float = 0.0, + eps_normal: float = 1e-8, +) -> ActiveConstraintDescriptors: + """ + Evaluate all margins, select those below margin_threshold, build sparse + linearization descriptors with masks. Capped at K_rr / K_obs for fixed + shapes; if more than K violate, the worst K are kept. + + margin_threshold: only constraints with margin < threshold are active. + Set to 0.0 to activate only penetrating/touching constraints, or + to a positive value to include a safety buffer. + + X: (B, H, N_robots, 2) + """ + B, H, Nr, _ = X.shape + N_obs = obs_set.n_total + N_pairs = pair_i.shape[0] + + # --- Robot-robot --- + if N_pairs > 0 and K_rr > 0: + rr_margins = _compute_robot_robot_margins(X, pair_i, pair_j, min_sep) # (B, H, N_pairs) + + def select_rr_per_bh(margins_bh, positions_bh): + # Set margins above threshold to +inf so they sort to the end + thresholded = jnp.where(margins_bh < margin_threshold, margins_bh, jnp.inf) + # Pad if N_pairs < K_rr + padded = jnp.concatenate([thresholded, jnp.full(max(K_rr - N_pairs, 0), jnp.inf)])[:K_rr] + _, top_indices = lax.top_k(-padded, K_rr) + + top_indices = jnp.clip(top_indices, 0, N_pairs - 1) + # Mask: valid index AND margin was below threshold + selected_margins = margins_bh[top_indices] + mask = (jnp.arange(K_rr) < N_pairs) & (selected_margins < margin_threshold) + + sel_i = pair_i[top_indices] + sel_j = pair_j[top_indices] + + pi = positions_bh[sel_i] + pj = positions_bh[sel_j] + diff = pi - pj + dist = jnp.sqrt(jnp.sum(diff ** 2, axis=-1, keepdims=True) + _SDF_EPS) + normal = diff / jnp.maximum(dist, eps_normal) + rhs = jnp.full(K_rr, min_sep) + + return sel_i, sel_j, normal, rhs, mask + + sel_i, sel_j, normals, rhs, masks = jax.vmap(jax.vmap(select_rr_per_bh))( + rr_margins, X + ) + else: + sel_i = jnp.zeros((B, H, K_rr), dtype=jnp.int32) + sel_j = jnp.zeros((B, H, K_rr), dtype=jnp.int32) + normals = jnp.zeros((B, H, K_rr, 2)) + rhs = jnp.zeros((B, H, K_rr)) + masks = jnp.zeros((B, H, K_rr), dtype=jnp.bool_) + + # --- Obstacle --- + if N_obs > 0 and K_obs > 0: + obs_margins = _compute_obstacle_margins(X, obs_set, robot_radius) # (B, H, N_robots*N_obs) + N_cand = Nr * N_obs + + robot_indices_flat = jnp.repeat(jnp.arange(Nr), N_obs) + obs_indices_flat = jnp.tile(jnp.arange(N_obs), Nr) + + def select_obs_per_bh(margins_bh, positions_bh): + thresholded = jnp.where(margins_bh < margin_threshold, margins_bh, jnp.inf) + padded = jnp.concatenate([thresholded, jnp.full(max(K_obs - N_cand, 0), jnp.inf)])[:K_obs] + _, top_indices = lax.top_k(-padded, K_obs) + top_indices = jnp.clip(top_indices, 0, N_cand - 1) + + selected_margins = margins_bh[top_indices] + mask = (jnp.arange(K_obs) < N_cand) & (selected_margins < margin_threshold) + + sel_robot = robot_indices_flat[top_indices] + sel_obs = obs_indices_flat[top_indices] + + def get_normal_rhs(r_idx, o_idx): + p = positions_bh[r_idx] + margin, normal = _obstacle_margin_and_normal_for_pair( + p, o_idx, obs_set, robot_radius + ) + rhs_val = jnp.dot(normal, p) - margin + return normal, rhs_val + + obs_normals, obs_rhs = jax.vmap(get_normal_rhs)(sel_robot, sel_obs) + return sel_robot, sel_obs, obs_normals, obs_rhs, mask + + obs_sel_robot, obs_sel_obs, obs_normals_out, obs_rhs_out, obs_masks = jax.vmap(jax.vmap( + select_obs_per_bh + ))(obs_margins, X) + else: + obs_sel_robot = jnp.zeros((B, H, K_obs), dtype=jnp.int32) + obs_sel_obs = jnp.zeros((B, H, K_obs), dtype=jnp.int32) + obs_normals_out = jnp.zeros((B, H, K_obs, 2)) + obs_rhs_out = jnp.zeros((B, H, K_obs)) + obs_masks = jnp.zeros((B, H, K_obs), dtype=jnp.bool_) + + return ActiveConstraintDescriptors( + rr_i_idx=sel_i, + rr_j_idx=sel_j, + rr_normal=normals, + rr_rhs=rhs, + rr_mask=masks, + obs_robot_idx=obs_sel_robot, + obs_obs_idx=obs_sel_obs, + obs_normal=obs_normals_out, + obs_rhs=obs_rhs_out, + obs_mask=obs_masks, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 5: Tridiagonal Solver (Thomas Algorithm) +# ═══════════════════════════════════════════════════════════════════════════════ + +def _thomas_factorize(diag_main, diag_sub, diag_sup): + """ + Precompute Thomas algorithm factorization for a tridiagonal system. + + diag_main: (H,) main diagonal + diag_sub: (H-1,) sub-diagonal (below main) + diag_sup: (H-1,) super-diagonal (above main) + + Returns: (c_prime (H-1,), d_denom (H,)) for forward/backward sweeps. + """ + H = diag_main.shape[0] + + def forward_step(carry, i): + c_prev, d_prev = carry + # c_prime[i] = diag_sup[i] / (diag_main[i+1] - diag_sub[i] * c_prev) + # But we index from 0: for step i (0-indexed), we process row i+1 + denom = diag_main[i + 1] - diag_sub[i] * c_prev + c_new = jnp.where(i < H - 2, diag_sup[i + 1] / denom, 0.0) + d_new = denom + return (c_new, d_new), (c_new, denom) + + # First row + c0 = diag_sup[0] / diag_main[0] + d0 = diag_main[0] + + if H == 1: + return jnp.array([]), jnp.array([d0]) + + (_, _), (c_primes, d_denoms) = lax.scan( + forward_step, + (c0, d0), + jnp.arange(H - 1), + ) + + c_prime = jnp.concatenate([jnp.array([c0]), c_primes[:-1]]) + d_denom = jnp.concatenate([jnp.array([d0]), d_denoms]) + + return c_prime, d_denom + + +def _thomas_solve(c_prime, d_denom, diag_sub, rhs): + """ + Batched tridiagonal solve using pre-factored coefficients. + + c_prime: (H-1,) + d_denom: (H,) + diag_sub: (H-1,) + rhs: (..., H) -- arbitrary leading batch dims + + Returns: (..., H) solution. + """ + H = d_denom.shape[0] + batch_shape = rhs.shape[:-1] + + # Flatten batch dims + rhs_flat = rhs.reshape(-1, H) # (batch, H) + Nb = rhs_flat.shape[0] + + # Forward sweep: compute modified RHS + def forward_step(d_prev, i): + # d'[i] = (rhs[i] - diag_sub[i-1] * d'[i-1]) / d_denom[i] + d_new = (rhs_flat[:, i] - diag_sub[i - 1] * d_prev) / d_denom[i] + return d_new, d_new + + d0 = rhs_flat[:, 0] / d_denom[0] + + if H == 1: + return d0.reshape(*batch_shape, H) + + _, d_primes = lax.scan( + forward_step, + d0, + jnp.arange(1, H), + ) + # d_primes: (H-1, batch) + d_all = jnp.concatenate([d0[None], d_primes], axis=0) # (H, batch) + + # Backward sweep + def backward_step(x_next, i): + # x[i] = d'[i] - c'[i] * x[i+1] + x_i = d_all[i] - c_prime[i] * x_next + return x_i, x_i + + x_last = d_all[-1] + _, x_rev = lax.scan( + backward_step, + x_last, + jnp.arange(H - 2, -1, -1), + ) + # x_rev: (H-1, batch), in reverse order + x_all = jnp.concatenate([x_rev[::-1], x_last[None]], axis=0) # (H, batch) + + return x_all.T.reshape(*batch_shape, H) + + +def _build_time_laplacian_coeffs(H): + """ + Build the 1D time Laplacian L_H = D^T D tridiagonal coefficients. + L_H main: [1, 2, 2, ..., 2, 1], off: [-1, -1, ..., -1] + """ + main = jnp.concatenate([jnp.array([1.0]), + jnp.full(H - 2, 2.0), + jnp.array([1.0])]) + off = jnp.full(H - 1, -1.0) + return main, off + + +def _build_x_tridiag_coeffs(smoothness_weight, tau, rho_xv, rho_xc, H, + fix_start=True, fix_final=False): + """H_X = (1 + tau + rho_xv + rho_xc)I + lambda * L_H, with endpoint pinning.""" + L_main, L_off = _build_time_laplacian_coeffs(H) + diag_shift = 1.0 + tau + rho_xv + rho_xc + main = diag_shift + smoothness_weight * L_main + sub = smoothness_weight * L_off + sup = smoothness_weight * L_off + + # Pin endpoints: replace rows with identity so X[0]=b[0], X[-1]=b[-1] + if fix_start: + main = main.at[0].set(1.0) + sup = sup.at[0].set(0.0) + if fix_final: + main = main.at[-1].set(1.0) + sub = sub.at[-1].set(0.0) + + return main, sub, sup + + +def _build_v_tridiag_coeffs(rho_xv, rho_z, H, fix_start=True, fix_final=False): + """ + H_V = rho_xv * I + rho_z * L_H, with endpoint rows replaced by identity + to enforce V[0] = pos_init (and V[-1] = pos_final if fix_final). + """ + L_main, L_off = _build_time_laplacian_coeffs(H) + main = rho_xv + rho_z * L_main + sub = rho_z * L_off + sup = rho_z * L_off + + # Pin start: row 0 becomes identity (main[0]=1, sup[0]=0) + if fix_start: + main = main.at[0].set(1.0) + sup = sup.at[0].set(0.0) + + # Pin end: row H-1 becomes identity (main[-1]=1, sub[-1]=0) + if fix_final: + main = main.at[-1].set(1.0) + sub = sub.at[-1].set(0.0) + + return main, sub, sup + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 6: Velocity Projection (Z Update) +# ═══════════════════════════════════════════════════════════════════════════════ + +def _project_to_velocity_balls(y, dx_max, eps=1e-10): + """ + Project each velocity increment onto the L2 ball of radius dx_max. + y: (B, H-1, N_robots, 2) + Returns: same shape, with each (robot, timestep) vector clipped. + """ + norm = jnp.sqrt(jnp.sum(y ** 2, axis=-1, keepdims=True) + eps) + scale = jnp.minimum(1.0, dx_max / norm) + return y * scale + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 7: Local Collision QP (C, s Update) +# ═══════════════════════════════════════════════════════════════════════════════ + +def _sparse_At_lambda(lam_rr, lam_obs, descriptors, Nr, K_rr, B, H): + """ + Compute A^T @ lambda from sparse descriptors. + Returns (B, H, Nr, 2) -- gradient contribution to c from dual variables. + + For robot-robot constraint l: a_l = [0..0, +n_l at i, 0..0, -n_l at j, 0..0] + For obstacle constraint l: a_l = [0..0, +g_l at r, 0..0] + """ + b_idx = jnp.arange(B)[:, None, None] + h_idx = jnp.arange(H)[None, :, None] + + result = jnp.zeros((B, H, Nr, 2)) + + # Robot-robot: scatter +lambda*n to robot i, -lambda*n to robot j + rr_contrib = lam_rr[:, :, :, None] * descriptors.rr_normal # (B,H,K_rr,2) + result = result.at[b_idx, h_idx, descriptors.rr_i_idx].add(rr_contrib) + result = result.at[b_idx, h_idx, descriptors.rr_j_idx].add(-rr_contrib) + + # Obstacle: scatter +lambda*g to robot r + obs_contrib = lam_obs[:, :, :, None] * descriptors.obs_normal # (B,H,K_obs,2) + result = result.at[b_idx, h_idx, descriptors.obs_robot_idx].add(obs_contrib) + + return result + + +def _sparse_A_c(C, descriptors, K_rr, K_obs, B, H): + """ + Compute A @ c from sparse descriptors. + Returns (rr_vals (B,H,K_rr), obs_vals (B,H,K_obs)). + + Robot-robot: a_l^T c = n_l^T (c_i - c_j) + Obstacle: a_l^T c = g_l^T c_r + """ + b_idx = jnp.arange(B)[:, None, None] + h_idx = jnp.arange(H)[None, :, None] + + ci_rr = C[b_idx, h_idx, descriptors.rr_i_idx] # (B,H,K_rr,2) + cj_rr = C[b_idx, h_idx, descriptors.rr_j_idx] + rr_vals = jnp.sum(descriptors.rr_normal * (ci_rr - cj_rr), axis=-1) + + cr_obs = C[b_idx, h_idx, descriptors.obs_robot_idx] # (B,H,K_obs,2) + obs_vals = jnp.sum(descriptors.obs_normal * cr_obs, axis=-1) + + return rr_vals, obs_vals + + +def _solve_local_collision_qp_dual( + q_h: jnp.ndarray, + descriptors: ActiveConstraintDescriptors, + rho_xc: float, + mu: jnp.ndarray, + N_robots: int, + K_rr: int, + K_obs: int, + maxiter: int, +): + """ + Solve the local collision QP per (batch, time) via dual projected gradient ascent. + + Primal problem: + min_{c, s} (rho_xc/2)||c - q||^2 + mu * 1^T s + s.t. a_l^T c + s_l >= b_l for each constraint l + s >= 0 + + Dual (with 0 <= lambda <= mu): + max_lambda -(1/(2*rho_xc)) * ||A^T lambda||^2 + lambda^T (b - A q) + + Primal recovery: c* = q + (1/rho_xc) * A^T lambda* + s_l* = max(0, b_l - a_l^T c*) + + q_h: (B, H, N_robots, 2) + Returns: (C_new, s_new) + """ + B, H, Nr, _ = q_h.shape + K_total = K_rr + K_obs + + # Compute A @ q (constant) + Aq_rr, Aq_obs = _sparse_A_c(q_h, descriptors, K_rr, K_obs, B, H) + + # Residual: b - A @ q (positive = q violates constraint) + resid_rr = (descriptors.rr_rhs - Aq_rr) * descriptors.rr_mask # (B,H,K_rr) + resid_obs = (descriptors.obs_rhs - Aq_obs) * descriptors.obs_mask # (B,H,K_obs) + + # Initialize dual variables to 0 + lam_rr = jnp.zeros((B, H, K_rr)) + lam_obs = jnp.zeros((B, H, K_obs)) + + # Step size for dual projected gradient. + # Each constraint row has ||a||^2 ~ 1. With overlap on same robot, + # spectral_norm(A A^T) can reach ~max_per_robot. Use rho_xc / 4 as default; + # for many constraints on few robots, the caller should increase qp_maxiter. + alpha = rho_xc / 4.0 + + def dual_step(carry, _): + lam_rr, lam_obs = carry + + # Gradient of dual: dq/dlambda = -(1/rho_xc) * A @ (A^T @ lambda) + (b - A @ q) + At_lam = _sparse_At_lambda(lam_rr, lam_obs, descriptors, Nr, K_rr, B, H) + AAt_lam_rr, AAt_lam_obs = _sparse_A_c(At_lam, descriptors, K_rr, K_obs, B, H) + + grad_rr = -(1.0 / rho_xc) * AAt_lam_rr + resid_rr + grad_obs = -(1.0 / rho_xc) * AAt_lam_obs + resid_obs + + # Projected gradient ascent: lambda <- clip(lambda + alpha * grad, 0, mu) + lam_rr_new = jnp.clip(lam_rr + alpha * grad_rr, 0.0, mu) + lam_obs_new = jnp.clip(lam_obs + alpha * grad_obs, 0.0, mu) + + # Mask out inactive constraints + lam_rr_new = lam_rr_new * descriptors.rr_mask + lam_obs_new = lam_obs_new * descriptors.obs_mask + + return (lam_rr_new, lam_obs_new), None + + (lam_rr_out, lam_obs_out), _ = lax.scan(dual_step, (lam_rr, lam_obs), None, length=maxiter) + + # Primal recovery: c = q + (1/rho_xc) * A^T @ lambda + At_lam = _sparse_At_lambda(lam_rr_out, lam_obs_out, descriptors, Nr, K_rr, B, H) + C_out = q_h + (1.0 / rho_xc) * At_lam + + # Slack recovery: s_l = max(0, b_l - a_l^T c) + Ac_rr, Ac_obs = _sparse_A_c(C_out, descriptors, K_rr, K_obs, B, H) + s_rr = jnp.maximum(descriptors.rr_rhs - Ac_rr, 0.0) * descriptors.rr_mask + s_obs = jnp.maximum(descriptors.obs_rhs - Ac_obs, 0.0) * descriptors.obs_mask + s_out = jnp.concatenate([s_rr, s_obs], axis=-1) + + return C_out, s_out + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 8: ADMM Inner Loop +# ═══════════════════════════════════════════════════════════════════════════════ + +def _diff_operator(X): + """First difference along time: DX[t] = X[t+1] - X[t]. Returns (..., H-1, ...).""" + return X[:, 1:] - X[:, :-1] + + +def _x_solve( + V, C, U_xv, U_xc, X_pred, X_scp, + tau, rho_xv, rho_xc, x_c_prime, x_d_denom, x_diag_sub, + pos_init, pos_final, fix_final_point, +): + """ + X update: solve H_X @ X = b_X per channel. + H_X has identity rows at endpoints so X[0]=pos_init, X[-1]=pos_final. + + b_X = X_pred + tau*X_scp + rho_xv*(V - U_xv) + rho_xc*(C - U_xc) + with b[0] = pos_init, b[-1] = pos_final (matching identity rows). + """ + B, H, Nr, two = V.shape + b = X_pred + tau * X_scp + rho_xv * (V - U_xv) + rho_xc * (C - U_xc) + + # Pin endpoints in RHS (tridiag has identity rows there) + b = b.at[:, 0].set(pos_init) + if fix_final_point: + b = b.at[:, -1].set(pos_final) + + # Reshape to (B * Nr * 2, H) for batched tridiag solve + b_flat = b.transpose(0, 2, 3, 1).reshape(-1, H) + x_flat = _thomas_solve(x_c_prime, x_d_denom, x_diag_sub, b_flat) + return x_flat.reshape(B, Nr, two, H).transpose(0, 3, 1, 2) + + +def _v_solve( + X, Z, U_xv, U_z, + rho_xv, rho_z, v_c_prime, v_d_denom, v_diag_sub, + pos_init, pos_final, fix_final_point, +): + """ + V update: solve H_V @ V = b_V with fixed endpoints. + H_V = rho_xv * I + rho_z * L_H (tridiag in time) + + b_V = rho_xv * (X + U_xv) + rho_z * D^T (Z - U_z) + + For fixed endpoints, we directly set V[0] = pos_init, V[-1] = pos_final, + and solve only interior points, folding endpoint contributions into the RHS. + """ + B, H, Nr, two = X.shape + + # b_consensus = rho_xv * (X + U_xv) + b = rho_xv * (X + U_xv) + + # D^T applied to (Z - U_z): spread increments back to positions + # D^T y[t] = -y[t] + y[t-1], with boundary adjustments + z_term = Z - U_z # (B, H-1, Nr, 2) + dt_z = jnp.zeros_like(X) + dt_z = dt_z.at[:, 0].set(-z_term[:, 0]) + dt_z = dt_z.at[:, 1:-1].set(z_term[:, :-1] - z_term[:, 1:]) + dt_z = dt_z.at[:, -1].set(z_term[:, -1]) + b = b + rho_z * dt_z + + # Pin endpoints in RHS (tridiag system has identity rows at endpoints) + b = b.at[:, 0].set(pos_init) + if fix_final_point: + b = b.at[:, -1].set(pos_final) + + # Solve -- endpoints are built into the tridiag, no overwrite needed + b_flat = b.transpose(0, 2, 3, 1).reshape(-1, H) + v_flat = _thomas_solve(v_c_prime, v_d_denom, v_diag_sub, b_flat) + V_new = v_flat.reshape(B, Nr, two, H).transpose(0, 3, 1, 2) + + return V_new + + +def _admm_step( + state: AdmmState, + X_pred: jnp.ndarray, + X_scp: jnp.ndarray, + descriptors: ActiveConstraintDescriptors, + x_c_prime, x_d_denom, x_diag_sub, + v_c_prime, v_d_denom, v_diag_sub, + tau, rho_xv, rho_xc, rho_z, mu, dx_max, + pos_init, pos_final, fix_final_point, + N_robots, K_rr, K_obs, qp_maxiter, +): + """Single ADMM iteration.""" + + # 1. X solve (endpoints pinned as non-DOFs) + X_new = _x_solve( + state.V, state.C, state.U_xv, state.U_xc, X_pred, X_scp, + tau, rho_xv, rho_xc, x_c_prime, x_d_denom, x_diag_sub, + pos_init, pos_final, fix_final_point, + ) + + # 2. V solve + V_new = _v_solve( + X_new, state.Z, state.U_xv, state.U_z, + rho_xv, rho_z, v_c_prime, v_d_denom, v_diag_sub, + pos_init, pos_final, fix_final_point, + ) + + # 3. Z update (velocity projection) + DV = _diff_operator(V_new) # (B, H-1, Nr, 2) + y = DV + state.U_z + Z_new = _project_to_velocity_balls(y, dx_max) + + # 4. C, s update (local collision QP) + q_h = X_new + state.U_xc + C_new, s_new = _solve_local_collision_qp_dual( + q_h, descriptors, rho_xc, mu, N_robots, K_rr, K_obs, qp_maxiter, + ) + + # 5. Dual updates + U_xv_new = state.U_xv + X_new - V_new + U_xc_new = state.U_xc + X_new - C_new + U_z_new = state.U_z + DV - Z_new + + return AdmmState( + X=X_new, V=V_new, Z=Z_new, C=C_new, s=s_new, + U_xv=U_xv_new, U_xc=U_xc_new, U_z=U_z_new, + ) + + +def _admm_loop( + state: AdmmState, + X_pred, X_scp, descriptors, + x_c_prime, x_d_denom, x_diag_sub, + v_c_prime, v_d_denom, v_diag_sub, + tau, rho_xv, rho_xc, rho_z, mu, dx_max, + pos_init, pos_final, fix_final_point, + N_robots, K_rr, K_obs, qp_maxiter, + K_admm, +): + """Run K_admm ADMM iterations via lax.scan.""" + + def step_fn(state, _): + new_state = _admm_step( + state, X_pred, X_scp, descriptors, + x_c_prime, x_d_denom, x_diag_sub, + v_c_prime, v_d_denom, v_diag_sub, + tau, rho_xv, rho_xc, rho_z, mu, dx_max, + pos_init, pos_final, fix_final_point, + N_robots, K_rr, K_obs, qp_maxiter, + ) + return new_state, None + + final_state, _ = lax.scan(step_fn, state, None, length=K_admm) + return final_state + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 9: SCP Outer Loop +# ═══════════════════════════════════════════════════════════════════════════════ + +def _scp_solve( + X_pred: jnp.ndarray, + obs_set: ObstacleSet, + robot_radius: float, + min_sep: float, + pair_i: jnp.ndarray, + pair_j: jnp.ndarray, + pos_init: jnp.ndarray, + pos_final: Optional[jnp.ndarray], + fix_final_point: bool, + dx_max: float, + N_robots: int, + smoothness_weight: float, + M: int, + K_admm: int, + K_rr: int, + K_obs: int, + rho_xv: float, + rho_xc: float, + rho_z: float, + mu_init: float, + mu_max: float, + mu_mult: float, + tau_init: float, + tau_max: float, + tau_mult: float, + qp_maxiter: int, + margin_threshold: float, + safety_buffer: float, +): + """ + Full SCP solve: outer successive convexification with ADMM inner loop. + + X_pred: (B, H, N_robots, 2) -- reference trajectory + Returns: (X_solution, diagnostics_dict) + """ + B, H, Nr, _ = X_pred.shape + + # Precompute V tridiagonal factorization (constant across all iterations) + v_main, v_sub, v_sup = _build_v_tridiag_coeffs( + rho_xv, rho_z, H, fix_start=True, fix_final=fix_final_point + ) + v_c_prime, v_d_denom = _thomas_factorize(v_main, v_sub, v_sup) + + # pos_final fallback + if pos_final is None: + pos_final = X_pred[:, -1] # (B, Nr, 2) + + # Initialize SCP state + K_total = K_rr + K_obs + init_admm = AdmmState( + X=X_pred, + V=X_pred, + Z=_diff_operator(X_pred), + C=X_pred, + s=jnp.zeros((B, H, K_total)), + U_xv=jnp.zeros_like(X_pred), + U_xc=jnp.zeros_like(X_pred), + U_z=jnp.zeros((B, H - 1, Nr, 2)), + ) + + init_scp = ScpState( + X=X_pred, + admm_state=init_admm, + mu=jnp.array(mu_init), + tau=jnp.array(tau_init), + ) + + def scp_step(scp_state, _): + X_current = scp_state.X + mu = scp_state.mu + tau = scp_state.tau + + # 1. Select active constraints at current linearization point + # Use inflated radius/separation for safety buffer against SCP gap + eff_robot_radius = robot_radius + safety_buffer + eff_min_sep = min_sep + 2.0 * safety_buffer + descriptors = _select_active_constraints( + X_current, obs_set, eff_robot_radius, eff_min_sep, + K_rr, K_obs, pair_i, pair_j, N_robots, + margin_threshold=margin_threshold, + ) + + # 2. Build X tridiag factorization for current tau + x_main, x_sub, x_sup = _build_x_tridiag_coeffs( + smoothness_weight, tau, rho_xv, rho_xc, H, + fix_start=True, fix_final=fix_final_point, + ) + x_c_prime, x_d_denom = _thomas_factorize(x_main, x_sub, x_sup) + + # 3. Reset collision-related duals (constraint linearization changed) + # Keep X, V, Z, U_xv, U_z warm-started from previous SCP iteration. + prev = scp_state.admm_state + admm_init = AdmmState( + X=prev.X, V=prev.V, Z=prev.Z, + C=prev.X, # reset C to current X + s=jnp.zeros_like(prev.s), + U_xv=prev.U_xv, U_xc=jnp.zeros_like(prev.U_xc), U_z=prev.U_z, + ) + + # Run ADMM inner loop + admm_out = _admm_loop( + admm_init, + X_pred, X_current, descriptors, + x_c_prime, x_d_denom, x_sub, + v_c_prime, v_d_denom, v_sub, + tau, rho_xv, rho_xc, rho_z, mu, dx_max, + pos_init, pos_final, fix_final_point, + N_robots, K_rr, K_obs, qp_maxiter, + K_admm, + ) + + # 4. Update SCP state + X_new = admm_out.X + mu_new = jnp.minimum(mu * mu_mult, mu_max) + tau_new = jnp.minimum(tau * tau_mult, tau_max) + + return ScpState( + X=X_new, + admm_state=admm_out, + mu=mu_new, + tau=tau_new, + ), None + + final_scp, _ = lax.scan(scp_step, init_scp, None, length=M) + + X_out = final_scp.X + + # Final velocity projection: clamp consecutive steps to dx_max. + # ADMM consensus between X and V may not be exact, so X can have + # small velocity violations. Walk forward from the anchor, clamping + # each displacement to the L2 ball of radius dx_max. + def _clamp_velocities(X, pos_init, dx_max): + # X: (group_size, H, N_robots, 2), pos_init: (group_size, N_robots, 2) + def _step(prev, x_t): + delta = x_t - prev + norm = jnp.sqrt(jnp.sum(delta ** 2, axis=-1, keepdims=True) + 1e-10) + scale = jnp.minimum(1.0, dx_max / norm) + clamped = prev + delta * scale + return clamped, clamped + _, X_clamped = lax.scan(_step, pos_init, jnp.moveaxis(X, 1, 0)) + return jnp.moveaxis(X_clamped, 0, 1) + + X_out = _clamp_velocities(X_out, pos_init, dx_max) + + # Diagnostics + diagnostics = { + 'final_mu': final_scp.mu, + 'final_tau': final_scp.tau, + 'final_slack_sum': jnp.sum(final_scp.admm_state.s), + } + + return X_out, diagnostics + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Section 10: JaxScpAdmmProjector Class +# ═══════════════════════════════════════════════════════════════════════════════ + +class JaxScpAdmmProjector(BaseProjector): + """Batched JAX SCP+ADMM trajectory projector (GPU-accelerated). + + Drop-in replacement for IpoptProjector with the same + ``BaseProjector.project(x, params)`` interface. + + Solves the same constrained projection as IpoptProjector (minimum- + deviation from reference subject to velocity, inter-robot collision, + obstacle, and workspace constraints) but uses a two-level iterative + algorithm that is fully JAX-jittable and runs on GPU: + + **Outer loop — Successive Convexification (SCP), M iterations:** + 1. Linearize nonlinear collision constraints around current X. + 2. Select the K worst-violating constraints (K_rr inter-robot, + K_obs obstacle) for a sparse active set. + 3. Solve the resulting convex sub-problem via ADMM. + 4. Update SCP penalty (mu *= mu_mult) and trust region (tau *= tau_mult). + + **Inner loop — ADMM consensus, K_admm iterations per SCP step:** + Variables are split into consensus groups: + - X : trajectory (coupled to reference via quadratic cost) + - V : trajectory copy for velocity consensus + - Z : velocity increments, projected onto L2 balls (||z_t|| <= dx_max) + - C : trajectory copy for collision consensus + - s : slack variables for collision constraints + + Each ADMM iteration: + 1. X-update: tridiagonal solve (Thomas algorithm, O(H) per agent) + incorporating smoothness, reference tracking, and consensus terms. + 2. V-update: tridiagonal solve for velocity consensus. + 3. Z-update: per-timestep L2 ball projection for velocity limits. + 4. (C, s)-update: dual projected gradient ascent for the local + collision QP with sparse constraint Jacobians. + 5. Dual variable updates to enforce consensus. + + A final forward velocity-clamp pass walks from the anchor point and + projects each step to enforce hard velocity constraints post-SCP. + + Parameters + ---------- + collision_fields : list of DistanceField + Obstacle geometry extracted from the planning task. + robot_radius : float + Collision radius per robot (in the caller's coordinate frame). + n_agents : int + Number of robots whose trajectories are jointly projected. + ws_bounds : torch.Tensor, optional + Workspace limits, shape (2, dim). Row 0 = lower, row 1 = upper. + M : int + Number of SCP outer iterations (default 5). + K_admm : int + Number of ADMM inner iterations per SCP step (default 50). + K_rr, K_obs : int + Maximum active inter-robot / obstacle constraints per timestep. + Auto-sized to cover all candidates if smaller than needed. + rho_xv, rho_xc, rho_z : float + ADMM penalty weights for the (X,V), (X,C), and (V,Z) consensus + constraints respectively. + mu_init, mu_max, mu_mult : float + SCP collision penalty schedule: mu starts at mu_init, multiplied + by mu_mult each SCP iteration, capped at mu_max. + tau_init, tau_max, tau_mult : float + SCP trust-region (safety buffer) schedule. + margin_threshold : float + Constraints with margin above this value are ignored (not active). + safety_buffer : float + Extra inflation of robot radius during SCP to compensate for + linearization error. + """ + + def __init__( + self, + collision_fields: List[DistanceField], + robot_radius: float = 0.0, + n_agents: int = 1, + ws_bounds: Optional[torch.Tensor] = None, + use_obstacle_constraints: bool = True, + use_inter_robot_constraints: bool = True, + fix_final_point: bool = False, + smoothness_weight: float = 0.0, + # --- SCP parameters --- + M: int = 5, + K_admm: int = 50, + K_rr: int = 16, + K_obs: int = 16, + # --- ADMM penalty parameters --- + rho_xv: float = 1.0, + rho_xc: float = 1.0, + rho_z: float = 1.0, + # --- SCP schedule parameters --- + mu_init: float = 1.0, + mu_max: float = 1000.0, + mu_mult: float = 2.0, + tau_init: float = 0.1, + tau_max: float = 10.0, + tau_mult: float = 1.5, + # --- QP solver --- + qp_maxiter: int = 100, + # --- Constraint selection --- + margin_threshold: float = 0.1, + safety_buffer: float = 0.01, + # --- Misc --- + verbose: bool = False, + ): + super().__init__() + + self.robot_radius = robot_radius + self.n_agents = n_agents + self.fix_final_point = fix_final_point + self.smoothness_weight = smoothness_weight + self.verbose = verbose + + # Extract obstacle geometry as numpy (defer JAX conversion to first call) + self._obs_np, ws_min_np, ws_max_np = _extract_obstacle_params_from_fields( + collision_fields, ws_bounds, robot_radius + ) + self.ws_min = ws_min_np + self.ws_max = ws_max_np + + # SCP / ADMM parameters + self.M = M + self.K_admm = K_admm + + # Auto-size K_rr and K_obs to cover all candidate constraints + n_pairs = n_agents * (n_agents - 1) // 2 if (use_inter_robot_constraints and n_agents > 1) else 0 + n_obs_total = self._obs_np['n_total'] if use_obstacle_constraints else 0 + self.K_rr = max(K_rr, n_pairs) if n_pairs > 0 else 0 + self.K_obs = max(K_obs, n_agents * n_obs_total) if n_obs_total > 0 else 0 + self.rho_xv = rho_xv + self.rho_xc = rho_xc + self.rho_z = rho_z + self.mu_init = mu_init + self.mu_max = mu_max + self.mu_mult = mu_mult + self.tau_init = tau_init + self.tau_max = tau_max + self.tau_mult = tau_mult + self.qp_maxiter = qp_maxiter + self.margin_threshold = margin_threshold + self.safety_buffer = safety_buffer + + # Minimum separation distance for inter-robot constraints + self.min_separation = 2.0 * robot_radius + + # Pre-compute inter-robot pair indices (numpy -- defer JAX to first call) + pairs = [] + if use_inter_robot_constraints and n_agents > 1: + for a in range(n_agents): + for b in range(a + 1, n_agents): + pairs.append((a, b)) + if len(pairs) > 0: + self._pair_i_np = np.array([p[0] for p in pairs], dtype=np.int32) + self._pair_j_np = np.array([p[1] for p in pairs], dtype=np.int32) + else: + self._pair_i_np = np.zeros(0, dtype=np.int32) + self._pair_j_np = np.zeros(0, dtype=np.int32) + + # JIT-compiled solver (lazy, built on first call) + self._jit_solve = None + self._cached_H = None + + logger.info( + f'JaxScpAdmmProjector: {n_agents} agents, ' + f'{self._obs_np["n_total"]} obstacles, ' + f'M={M}, K_admm={K_admm}, K_rr={self.K_rr}, K_obs={self.K_obs}' + ) + + def _build_jit_solver(self, H: int): + """Build and cache the JIT-compiled solver for a given horizon.""" + + # Convert numpy arrays to JAX (first time touching JAX backend) + obs_np = self._obs_np + obs_set = ObstacleSet( + centers=jnp.array(obs_np['centers']), + half_sizes=jnp.array(obs_np['half_sizes']), + corner_radii=jnp.array(obs_np['corner_radii']), + n_total=obs_np['n_total'], + ) + pair_i = jnp.array(self._pair_i_np) + pair_j = jnp.array(self._pair_j_np) + + @jax.jit + def solve(X_pred, pos_init, pos_final, dx_max_arr, smoothness_weight): + return _scp_solve( + X_pred=X_pred, + obs_set=obs_set, + robot_radius=self.robot_radius, + min_sep=self.min_separation, + pair_i=pair_i, + pair_j=pair_j, + pos_init=pos_init, + pos_final=pos_final, + fix_final_point=self.fix_final_point, + dx_max=dx_max_arr, + N_robots=self.n_agents, + smoothness_weight=smoothness_weight, + M=self.M, + K_admm=self.K_admm, + K_rr=self.K_rr, + K_obs=self.K_obs, + rho_xv=self.rho_xv, + rho_xc=self.rho_xc, + rho_z=self.rho_z, + mu_init=self.mu_init, + mu_max=self.mu_max, + mu_mult=self.mu_mult, + tau_init=self.tau_init, + tau_max=self.tau_max, + tau_mult=self.tau_mult, + qp_maxiter=self.qp_maxiter, + margin_threshold=self.margin_threshold, + safety_buffer=self.safety_buffer, + ) + + self._jit_solve = solve + self._cached_H = H + if self.verbose: + logger.info(f'JaxScpAdmmProjector: JIT-compiling solver for H={H}') + + def project( + self, + x: torch.Tensor, + params: Optional[Dict[str, torch.Tensor]], + ) -> torch.Tensor: + """ + Project trajectories onto the feasible set via SCP+ADMM. + + Args: + x: Trajectory tensor, shape (B, H, dim). Agent a owns indices + [a*group_size : (a+1)*group_size]. + params: Dict with: + 'dx_max' (float): max displacement per timestep. + 'pos_init' (Tensor): shape (B, dim), initial positions. + 'pos_final' (Tensor, optional): shape (B, dim), goal positions. + + Returns: + Projected trajectories, same shape as x. + """ + B, H, dim = x.shape + n = self.n_agents + group_size = B // n + + assert B % n == 0, f"Batch {B} not divisible by n_agents {n}." + + dx_max = float(params['dx_max']) + pos_init = params['pos_init'] + pos_final = params.get('pos_final', None) + + # Convert to JAX + x_jax = _torch_to_jax(x) + pos_init_jax = _torch_to_jax(pos_init) + pos_final_jax = _torch_to_jax(pos_final) if pos_final is not None else None + + # Repack batch layout: (B, H, dim) -> (group_size, H, n_agents, 2) + x_grouped, pi_grouped, pf_grouped = _repack_batch_to_grouped( + x_jax, pos_init_jax, pos_final_jax, n + ) + + # Build/rebuild JIT solver if horizon changed + if self._jit_solve is None or self._cached_H != H: + self._build_jit_solver(H) + + # Solve + if pf_grouped is None: + pf_grouped = x_grouped[:, -1] # use last waypoint as fallback + + dx_max_arr = jnp.array(dx_max) + sw_arr = jnp.array(self.smoothness_weight) + + X_sol, diagnostics = self._jit_solve(x_grouped, pi_grouped, pf_grouped, dx_max_arr, sw_arr) + + if self.verbose: + for k, v in diagnostics.items(): + logger.info(f' {k}: {v}') + + # Unpack back to (B, H, dim) + result_jax = _unpack_grouped_to_batch(X_sol, n, B) + + return _jax_to_torch(result_jax, x.dtype, x.device) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd.py new file mode 100644 index 0000000000000000000000000000000000000000..fb4ac7d87cc5808fd665eaa45a0238d783e7f6bb --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd.py @@ -0,0 +1,617 @@ +""" +Modified from https://github.com/jacarvalho/mpd-public +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import pickle +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List + +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory, CostConstraint, CostMaxVelocity +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.models.diffusion_models.sample_functions import guide_gradient_steps, ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from mmd.planners.single_agent.common import PlannerOutput +from mmd.planners.single_agent.single_agent_planner_base import SingleAgentPlanner +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.constraints import MultiPointConstraint +from mmd.common.pretty_print import * + + +class MPD(SingleAgentPlanner): + """ + A class that allows repeated calls to the same model with different inputs. + This class keeps track of constraints and feeds them to the model only when needed. + """ + + def __init__(self, + model_id: str, + planner_alg: str, + start_state_pos: torch.tensor, + goal_state_pos: torch.tensor, + use_guide_on_extra_objects_only: bool, + start_guide_steps_fraction: float, + n_guide_steps: int, + n_diffusion_steps_without_noise: int, + weight_grad_cost_collision: float, + weight_grad_cost_smoothness: float, + weight_grad_cost_constraints: float, + weight_grad_cost_soft_constraints: float, + factor_num_interpolated_points_for_collision: float, + trajectory_duration: float, + device: str, + debug: bool, + seed: int, + results_dir: str, + trained_models_dir: str, + n_samples: int, + n_local_inference_noising_steps: int, + n_local_inference_denoising_steps: int, + **kwargs + ): + super().__init__() + # The constraints are stored here. This is a list of ConstraintCost. + self.constraints = [] + self.weight_grad_cost_constraints = weight_grad_cost_constraints + self.weight_grad_cost_soft_constraints = weight_grad_cost_soft_constraints + + #################################### + fix_random_seed(seed) + + device = get_torch_device(device) + tensor_args = {'device': device, 'dtype': torch.float32} + #################################### + print(f'####################################') + print(f'Initializing Planner with Model -- {model_id}') + print(f'Algorithm -- {planner_alg}') + run_prior_only = False + run_prior_then_guidance = False + if planner_alg == 'mmd': + pass + elif planner_alg == 'diffusion_prior_then_guide': + run_prior_then_guidance = True + elif planner_alg == 'diffusion_prior': + run_prior_only = True + else: + raise NotImplementedError + + #################################### + model_dir = os.path.join(trained_models_dir, model_id) + results_dir = os.path.join(model_dir, 'results_inference', str(seed)) + os.makedirs(results_dir, exist_ok=True) + + args = load_params_from_yaml(os.path.join(model_dir, "args.yaml")) + + #################################### + # Load dataset with env, robot, task. The TrajectoryDataset type is used here. + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=tensor_args + ) + # Extract objects from the dataset. + dataset = train_subset.dataset + # Number of support points in the trajectory. + n_support_points = dataset.n_support_points + # The environment. + env = dataset.env + # The robot. Contains the dt, the joint limits, etc. + robot = dataset.robot + # The task, commonly PlanningTask, is in charge of objects, extra objects, collisions, etc. + task = dataset.task + + dt = trajectory_duration / n_support_points # time interval for finite differences + + # set robot's dt + robot.dt = dt + + #################################### + # Load prior model + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs + ) + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', 'ema_model_current_state_dict.pth' if args[ + 'use_ema'] else 'model_current_state_dict.pth'), + map_location=tensor_args['device']) + ) + diffusion_model.eval() + model = diffusion_model + + freeze_torch_model_params(model) + model = torch.compile(model) + model.warmup(horizon=n_support_points, device=device) + + #################################### + # If the args specify a test start and goal, use those. + if start_state_pos is not None and goal_state_pos is not None: + print(f'start_state_pos: {start_state_pos}') + print(f'goal_state_pos: {goal_state_pos}') + else: + # Random initial and final positions + n_tries = 100 + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2) + start_state_pos = q_free[0] + goal_state_pos = q_free[1] + + if torch.linalg.norm(start_state_pos - goal_state_pos) > dataset.threshold_start_goal_pos: + break + + if start_state_pos is None or goal_state_pos is None: + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + print(f'start_state_pos: {start_state_pos}') + print(f'goal_state_pos: {goal_state_pos}') + + #################################### + # Run motion planning inference + + ######## + # normalize start and goal positions + hard_conds = dataset.get_hard_conditions(torch.vstack((start_state_pos, goal_state_pos)), normalize=True) + context = None + + ######## + # Set up the planning costs + # Cost collisions + cost_collision_l = [] + weights_grad_cost_l = [] # for guidance, the weights_cost_l are the gradient multipliers (after gradient clipping) + if use_guide_on_extra_objects_only: + collision_fields = task.get_collision_fields_extra_objects() + else: + collision_fields = task.get_collision_fields() + + for collision_field in collision_fields: + cost_collision_l.append( + CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=1.0, + tensor_args=tensor_args + ) + ) + weights_grad_cost_l.append(weight_grad_cost_collision) + + # Cost smoothness + cost_smoothness_l = [ + CostGPTrajectory( + robot, n_support_points, dt, sigma_gp=1.0, + tensor_args=tensor_args + ) + ] + weights_grad_cost_l.append(weight_grad_cost_smoothness) + + ####### Cost composition + cost_func_list = [ + *cost_collision_l, + *cost_smoothness_l, + # *cost_max_velocity_l, + ] + # A `*cost_constraints_l` will be added as "extra cost" and removed after each planning call. + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + weights_cost_l=weights_grad_cost_l, + tensor_args=tensor_args + ) + + ######## + # Guiding manager + guide = GuideManagerTrajectoriesWithVelocity( + dataset, + cost_composite, + clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * factor_num_interpolated_points_for_collision), + tensor_args=tensor_args, + ) + + t_start_guide = ceil(start_guide_steps_fraction * model.n_diffusion_steps) + + # Keep some variables in the class as members. + self.start_state_pos = torch.clone(start_state_pos) + self.goal_state_pos = torch.clone(goal_state_pos) + self.robot = robot + self.context = context + self.run_prior_only = run_prior_only + self.run_prior_then_guidance = run_prior_then_guidance + self.n_diffusion_steps_without_noise = n_diffusion_steps_without_noise + self.hard_conds = hard_conds + self.model = model + self.n_support_points = n_support_points + self.t_start_guide = t_start_guide + self.n_guide_steps = n_guide_steps + self.guide = guide + self.tensor_args = tensor_args + # Batch-size. How many trajectories to generate at once. + self.num_samples = n_samples + # When doing local inference, how many steps to add noise for before denoising again. + self.n_local_inference_noising_steps = n_local_inference_noising_steps # n_local_inference_noising_steps + self.n_local_inference_denoising_steps = n_local_inference_denoising_steps + # Dataset. + self.dataset = dataset + # Task, e.g., planning task. + self.task = task + # Directories. + self.results_dir = results_dir + + # Cache of previous call data. + self.recent_call_data = PlannerOutput() + + self.sample_fn_kwargs = dict( + guide=None if self.run_prior_then_guidance or self.run_prior_only else self.guide, + n_guide_steps=self.n_guide_steps, + t_start_guide=self.t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + def __call__(self, start_state_pos, goal_state_pos, constraints_l=None, experience: PathBatchExperience = None, + *args, + **kwargs): + """ + Call the model with the given parameters. + :param n_samples: Number of trajectories to generate. + :param start_state_pos: The start state of the robot. + :param goal_state_pos: The goal state of the robot. + :param constraints_l: A list of constraints. + :param previous_path: The previous path of the robot. This would be used to guide the next path. + """ + # Check that the requested start and goal states are similar to the ones stored. + if not torch.allclose(start_state_pos, self.start_state_pos): + raise ValueError("The start state is different from the one stored in the planner.") + if not torch.allclose(goal_state_pos, self.goal_state_pos): + raise ValueError("The goal state is different from the one stored in the planner.") + + # Process the constraints into cost components. + if constraints_l is not None: + print("Planning with " + str(len(constraints_l)) + " constraints.") + else: + print("Planning without constraints.") + + cost_constraints_l = [] + if constraints_l is not None: + for c in constraints_l: + cost_constraints_l.append( + CostConstraint( + self.robot, + self.n_support_points, + q_l=c.get_q_l(), + traj_range_l=c.get_t_range_l(), + radius_l=c.radius_l, + is_soft=c.is_soft, + tensor_args=self.tensor_args + ) + ) + # Carry out inference with the constraints. If there is no experience, inference from scratch. + with TimerCUDA() as timer_inference: + if experience is None: + trajs_normalized_iters, _, _ = self.run_constrained_inference( + cost_constraints_l) # Shape [B (n_samples), H, D] + # Otherwise, use the experience path as a seed for a local inference call. + else: + trajs_normalized_iters, _, _ = self.run_constrained_local_inference(cost_constraints_l, experience) + t_total = timer_inference.elapsed + + # Un-normalize trajectory samples from the models. + trajs_iters = self.dataset.unnormalize_trajectories(trajs_normalized_iters) + + trajs_final = trajs_iters[-1] + trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs, _ = ( + self.task.get_trajs_collision_and_free(trajs_final, return_indices=True)) + + # Compute best traj. + idx_best_traj = None # Index of the best trajectory in the list of all trajectories (trajs_final). + idx_best_free_traj = None # Index of the best trajectory in the list of all free trajs (trajs_final_free). + cost_best_free_traj = None # Cost of the best trajectory. + cost_smoothness = None # Cost of smoothness for all free trajectories. + cost_path_length = None # Cost of path length for all free trajectories. + cost_all = None # Cost of all factors for all free trajectories. This is a combination of some costs. + variance_waypoint_trajs_final_free = None # Variance of waypoints for all free trajectories. + if trajs_final_free is not None: + cost_smoothness = compute_smoothness(trajs_final_free, self.robot) + print(f'cost smoothness: {cost_smoothness.mean():.4f}, {cost_smoothness.std():.4f}') + + cost_path_length = compute_path_length(trajs_final_free, self.robot) + print(f'cost path length: {cost_path_length.mean():.4f}, {cost_path_length.std():.4f}') + + # Compute best trajectory. + cost_all = cost_path_length + cost_smoothness + idx_best_free_traj = torch.argmin(cost_all).item() + idx_best_traj = trajs_final_free_idxs[idx_best_free_traj] + cost_best_free_traj = torch.min(cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + variance_waypoint_trajs_final_free = compute_variance_waypoints(trajs_final_free, self.robot) + + self.recent_call_data = PlannerOutput() + self.recent_call_data.trajs_iters = trajs_iters + self.recent_call_data.trajs_final = trajs_final + self.recent_call_data.trajs_final_coll = trajs_final_coll + self.recent_call_data.trajs_final_coll_idxs = trajs_final_coll_idxs + self.recent_call_data.trajs_final_free = trajs_final_free # Shape [B, H, D] + self.recent_call_data.t_total = t_total + self.recent_call_data.trajs_final_free_idxs = trajs_final_free_idxs # Shape [B] + self.recent_call_data.idx_best_traj = idx_best_traj + self.recent_call_data.idx_best_free_traj = idx_best_free_traj + self.recent_call_data.cost_best_free_traj = cost_best_free_traj + self.recent_call_data.cost_smoothness = cost_smoothness + self.recent_call_data.cost_path_length = cost_path_length + self.recent_call_data.cost_all = cost_all + self.recent_call_data.variance_waypoint_trajs_final_free = variance_waypoint_trajs_final_free + self.recent_call_data.constraints_l = constraints_l + + # Smooth the trajectories in trajs_final. + if self.recent_call_data.trajs_final is not None: + self.recent_call_data.trajs_final = smooth_trajs(self.recent_call_data.trajs_final) + + return self.recent_call_data + + def run_constrained_inference(self, cost_constraints_l: List[CostConstraint]): + # Add these cost factors, alongside their weights as specified, to the `guide` object of the model. + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters = self.model.run_inference( + self.context, self.hard_conds, + n_samples=self.num_samples, horizon=self.n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + ######## + # run extra guiding steps without diffusion + t_post_diffusion_guide = 0.0 + if self.run_prior_then_guidance: + n_post_diffusion_guide_steps = (self.t_start_guide + + self.n_diffusion_steps_without_noise) * self.n_guide_steps + print(CYAN + f'Running extra guiding steps without diffusion. Num steps:', n_post_diffusion_guide_steps, + RESET) + with TimerCUDA() as timer_post_model_sample_guide: + trajs = trajs_normalized_iters[-1] + trajs_post_diff_l = [] + for i in range(n_post_diffusion_guide_steps): + trajs = guide_gradient_steps( + trajs, + hard_conds=self.hard_conds, + guide=self.guide, + n_guide_steps=1, + unnormalize_data=False + ) + trajs_post_diff_l.append(trajs) + + chain = torch.stack(trajs_post_diff_l, dim=1) + chain = einops.rearrange(chain, 'b post_diff_guide_steps h d -> post_diff_guide_steps b h d') + trajs_normalized_iters = torch.cat((trajs_normalized_iters, chain)) + t_post_diffusion_guide = timer_post_model_sample_guide.elapsed + print(f't_post_diffusion_guide: {t_post_diffusion_guide:.3f} sec') + + # Remove the extra cost. + self.guide.reset_extra_costs() + + return trajs_normalized_iters, t_model_sampling, t_post_diffusion_guide + + def run_constrained_local_inference(self, cost_constraints_l: List[CostConstraint], + experience: PathBatchExperience): + # Add these cost factors, alongside their weights as specified, to the `guide` object of the model. + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters = self.model.run_local_inference( + experience.path_b, + self.n_local_inference_noising_steps, + self.n_local_inference_denoising_steps, + self.context, + self.hard_conds, + n_samples=self.num_samples, + horizon=self.n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + ######## + # Run extra guiding steps without diffusion. This is only done if variable `run_prior_then_guidance` is True. + t_post_diffusion_guide = 0.0 + if self.run_prior_then_guidance: + n_post_diffusion_guide_steps = (self.t_start_guide + + self.n_diffusion_steps_without_noise) * self.n_guide_steps + print(CYAN + f'Running extra guiding steps without diffusion. Num steps:', n_post_diffusion_guide_steps, + RESET) + with TimerCUDA() as timer_post_model_sample_guide: + trajs = trajs_normalized_iters[-1] + trajs_post_diff_l = [] + for i in range(n_post_diffusion_guide_steps): + trajs = guide_gradient_steps( + trajs, + hard_conds=self.hard_conds, + guide=self.guide, + n_guide_steps=1, + unnormalize_data=False + ) + trajs_post_diff_l.append(trajs) + + chain = torch.stack(trajs_post_diff_l, dim=1) + chain = einops.rearrange(chain, 'b post_diff_guide_steps h d -> post_diff_guide_steps b h d') + trajs_normalized_iters = torch.cat((trajs_normalized_iters, chain)) + t_post_diffusion_guide = timer_post_model_sample_guide.elapsed + print(f't_post_diffusion_guide: {t_post_diffusion_guide:.3f} sec') + + # Remove the extra cost. + self.guide.reset_extra_costs() + + return trajs_normalized_iters, t_model_sampling, t_post_diffusion_guide + + def save_recent_result(self): + # Compute motion planning metrics + print(f'\n----------------METRICS----------------') + print(f't_total: {self.recent_call_data.t_total:.3f} sec') + + success_free_trajs = self.task.compute_success_free_trajs(self.recent_call_data.trajs_final) + fraction_free_trajs = self.task.compute_fraction_free_trajs(self.recent_call_data.trajs_final) + collision_intensity_trajs = self.task.compute_collision_intensity_trajs(self.recent_call_data.trajs_final) + + print(f'success: {success_free_trajs}') + print(f'percentage free trajs: {fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {collision_intensity_trajs * 100:.2f}') + + # compute costs only on collision-free trajectories + traj_final_free_best = None + idx_best_traj = None + cost_best_free_traj = None + cost_smoothness = None + cost_path_length = None + cost_all = None + variance_waypoint_trajs_final_free = None + if self.recent_call_data.trajs_final_free is not None: + print(f'cost smoothness: {self.recent_call_data.cost_smoothness.mean():.4f}, ' + f'{self.recent_call_data.cost_smoothness.std():.4f}') + print(f'cost path length: {self.recent_call_data.cost_path_length.mean():.4f}, ' + f'{self.recent_call_data.cost_path_length.std():.4f}') + + # compute best trajectory + cost_best_free_traj = torch.min(self.recent_call_data.cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + # variance of waypoints + print(f'variance waypoint: {self.recent_call_data.variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + #################################### + # Save data + results_data_dict = { + 'trajs_iters': self.recent_call_data.trajs_iters, + 'trajs_final_coll': self.recent_call_data.trajs_final_coll, + 'trajs_final_coll_idxs': self.recent_call_data.trajs_final_coll_idxs, + 'trajs_final_free': self.recent_call_data.trajs_final_free, + 'trajs_final_free_idxs': self.recent_call_data.trajs_final_free_idxs, + 'success_free_trajs': success_free_trajs, + 'fraction_free_trajs': fraction_free_trajs, + 'collision_intensity_trajs': collision_intensity_trajs, + 'idx_best_traj': idx_best_traj, + 'traj_final_free_best': traj_final_free_best, + 'cost_best_free_traj': cost_best_free_traj, + 'cost_path_length_trajs_final_free': cost_path_length, + 'cost_smoothness_trajs_final_free': cost_smoothness, + 'cost_all_trajs_final_free': cost_all, + 'variance_waypoint_trajs_final_free': variance_waypoint_trajs_final_free, + 't_total': self.recent_call_data.t_total + } + with open(os.path.join(self.results_dir, 'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + def render_recent_result(self, animation_duration: float = 5.0): + # Render + planner_visualizer = PlanningVisualizer( + task=self.task, + ) + + base_file_name = Path(os.path.basename(__file__)).stem + traj_final_free_best = self.recent_call_data.trajs_final[self.recent_call_data.idx_best_traj] + pos_trajs_iters = self.robot.get_position(self.recent_call_data.trajs_iters) + + planner_visualizer.animate_opt_iters_joint_space_state( + trajs=self.recent_call_data.trajs_iters, + pos_start_state=self.start_state_pos, pos_goal_state=self.goal_state_pos, + vel_start_state=torch.zeros_like(self.start_state_pos), + vel_goal_state=torch.zeros_like(self.goal_state_pos), + traj_best=traj_final_free_best, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-joint-space-opt-iters.gif'), + n_frames=max((2, len(self.recent_call_data.trajs_iters) // 10)), + anim_time=5 + ) + + # visualize in the planning environment + planner_visualizer.animate_opt_iters_robots( + trajs=pos_trajs_iters, start_state=self.start_state_pos, goal_state=self.goal_state_pos, + traj_best=traj_final_free_best, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-traj-opt-iters.gif'), + # n_frames=max((2, len(trajs_iters))), + n_frames=2, + anim_time=5 + ) + + planner_visualizer.animate_robot_trajectories( + trajs=pos_trajs_iters[-1], start_state=self.start_state_pos, goal_state=self.goal_state_pos, + plot_trajs=True, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-robot-traj.gif'), + n_frames=max((2, pos_trajs_iters[-1].shape[1])), + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=self.recent_call_data.constraints_l + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..1466a78050c8207ec08e665c3dcf9254c7e01966 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/mpd_ensemble.py @@ -0,0 +1,721 @@ +""" +MIT License + +Copyright (c) 2024 Itamar Mishani + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import pickle +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List, Dict + +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory, CostConstraint +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.models.diffusion_models.sample_functions import guide_gradient_steps, ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer + +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.tasks.tasks_ensemble import PlanningTaskEnsemble + +from mmd.planners.single_agent.common import PlannerOutput +from mmd.planners.single_agent.single_agent_planner_base import SingleAgentPlanner +from mmd.models.diffusion_models.diffusion_ensemble import DiffusionsEnsemble + +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.constraints import MultiPointConstraint +from mmd.common.pretty_print import * + + +class MPDEnsemble(SingleAgentPlanner): + """ + A class that allows repeated calls to the same model with different inputs. + This class keeps track of constraints and feeds them to the model only when needed. + """ + + def __init__(self, + model_ids: tuple, + transforms: Dict[int, torch.tensor], + planner_alg: str, + start_state_pos: torch.tensor, + goal_state_pos: torch.tensor, + use_guide_on_extra_objects_only: bool, + start_guide_steps_fraction: float, + n_guide_steps: int, + n_diffusion_steps_without_noise: int, + weight_grad_cost_collision: float, + weight_grad_cost_smoothness: float, + weight_grad_cost_constraints: float, + weight_grad_cost_soft_constraints: float, + factor_num_interpolated_points_for_collision: float, + trajectory_duration: float, + device: str, + debug: bool, + seed: int, + results_dir: str, + trained_models_dir: str, + n_samples: int, + n_local_inference_noising_steps: int, + n_local_inference_denoising_steps: int, + **kwargs + ): + super().__init__() + # The constraints are stored here. This is a list of ConstraintCost. + self.constraints = [] + self.weight_grad_cost_constraints = weight_grad_cost_constraints + self.weight_grad_cost_soft_constraints = weight_grad_cost_soft_constraints + + #################################### + fix_random_seed(seed) + + device = get_torch_device(device) + tensor_args = {'device': device, 'dtype': torch.float32} + #################################### + print(f'################################################################################################') + print(f'Initializing Planner with Models -- {model_ids}') + print(f'Algorithm -- {planner_alg}') + run_prior_only = False + run_prior_then_guidance = False + if planner_alg == 'mmd': + pass + elif planner_alg == 'diffusion_prior_then_guide': + run_prior_then_guidance = True + elif planner_alg == 'diffusion_prior': + run_prior_only = True + else: + raise NotImplementedError + + #################################### + model_dirs, results_dirs, args = [], [], [] + self.models, tasks = {}, {} + self.guides = {} + datasets = [] + sample_kwargs = [] + contexts = None + for j, model_id in enumerate(model_ids): + model_dir = os.path.join(trained_models_dir, model_id) + model_dirs.append(model_dir) + args.append(load_params_from_yaml(os.path.join(model_dir, 'args.yaml'))) + + ## Load dataset with env, robot, task ## + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args[-1], + tensor_args=tensor_args + ) + dataset = train_subset.dataset + datasets.append(dataset) + n_support_points = dataset.n_support_points + robot = dataset.robot + if j == 0: + self.robot = robot + task = dataset.task + + dt = trajectory_duration / n_support_points # time interval for finite differences + # set robot's dt + robot.dt = dt + + # Load prior model + diffusion_configs = dict( + variance_schedule=args[-1]['variance_schedule'], + n_diffusion_steps=args[-1]['n_diffusion_steps'], + predict_epsilon=args[-1]['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args[-1]['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args[-1]['unet_dim_mults_option']] + ) + diffusion_model = get_model( + model_class=args[-1]['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs + ) + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', + 'ema_model_current_state_dict.pth' if args[-1][ + 'use_ema'] else 'model_current_state_dict.pth'), + map_location=tensor_args['device']) + ) + diffusion_model.eval() + model = diffusion_model + freeze_torch_model_params(model) + model = torch.compile(model) + model.warmup(horizon=n_support_points, device=device) + + self.models[j] = model + tasks[j] = task + # Cost collisions + cost_collision_l = [] + weights_grad_cost_l = [] # for guidance, the weights_cost_l are the gradient multipliers (after gradient clipping) + if use_guide_on_extra_objects_only: + collision_fields = task.get_collision_fields_extra_objects() + else: + collision_fields = task.get_collision_fields() + + for collision_field in collision_fields: + cost_collision_l.append( + CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=1.0, + tensor_args=tensor_args + ) + ) + weights_grad_cost_l.append(weight_grad_cost_collision) + + # Cost smoothness + cost_smoothness_l = [ + CostGPTrajectory( + # CostGPTrajectoryPositionOnlyWrapper( + robot, n_support_points, dt, sigma_gp=1.0, + tensor_args=tensor_args + ) + ] + weights_grad_cost_l.append(weight_grad_cost_smoothness) + + # Cost composition + cost_func_list = [ + *cost_collision_l, + *cost_smoothness_l + ] + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + weights_cost_l=weights_grad_cost_l, + tensor_args=tensor_args + ) + + ######## + # Guiding manager + guide = GuideManagerTrajectoriesWithVelocity( + dataset, + cost_composite, + clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * factor_num_interpolated_points_for_collision), + tensor_args=tensor_args, + ) + self.guides[j] = guide + + t_start_guide = ceil(start_guide_steps_fraction * model.n_diffusion_steps) + sample_fn_kwargs = dict( + guide=None if run_prior_then_guidance or run_prior_only else guide, + n_guide_steps=n_guide_steps, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + sample_kwargs.append(sample_fn_kwargs) + + #################################### + # If the args specify a test start and goal, use those. + # if 'inference_config' in args and args['inference_config']['is_use_random_pos'] is False: + # start_state_pos = torch.tensor(args['inference_config']['start_state_pos'], **tensor_args) + # goal_state_pos = torch.tensor(args['inference_config']['goal_state_pos'], **tensor_args) + + tasks_ensemble = PlanningTaskEnsemble(tasks, + transforms, + tensor_args=tensor_args) + + if start_state_pos is not None and goal_state_pos is not None: + print(f'start_state_pos: {start_state_pos}') + print(f'goal_state_pos: {goal_state_pos}') + else: + # Random initial and final positions + n_tries = 100 + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = tasks[0].random_coll_free_q(n_samples=1) + start_state_pos = q_free + q_free = tasks[-1].random_coll_free_q(n_samples=1) + goal_state_pos = q_free + + if start_state_pos is None or goal_state_pos is None: + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + print(f'start_state_pos: {start_state_pos}') + print(f'goal_state_pos: {goal_state_pos}') + + #################################### + # Run motion planning inference + + ######## + # The start and goal states are in the global frame. We need to convert them to the local frame of the task. + start_state_pos_local = tasks_ensemble.inverse_transform_q(0, start_state_pos) + goal_state_pos_local = tasks_ensemble.inverse_transform_q(len(tasks)-1, goal_state_pos) + # A hard condition is a Dict[int, torch.tensor], traj ix to state. + start_state_hard_cond = datasets[0].get_single_pt_hard_conditions(start_state_pos_local, 0, True) + goal_state_hard_cond = datasets[len(model_ids) - 1].get_single_pt_hard_conditions(goal_state_pos_local, -1, True) + # Add to the hard conds dict. Mapping model id to its hard conditions dictionary. + hard_conds = {0: start_state_hard_cond} + if len(model_ids) - 1 in hard_conds: + hard_conds[len(model_ids) - 1].update(goal_state_hard_cond) + else: + hard_conds[len(model_ids) - 1] = goal_state_hard_cond + + self.transforms = transforms + ensemble = DiffusionsEnsemble(self.models, transforms) + # cross conditioning + self.cross_conds = {} + for i in range(len(model_ids) - 1): + self.cross_conds[(i, i + 1)] = (params.horizon - 1, 0) + # Keep some variables in the class as members. + self.start_state_pos = torch.clone(start_state_pos) + self.goal_state_pos = torch.clone(goal_state_pos) + self.sample_kwargs = sample_kwargs + # self.robot = robot + self.contexts = contexts + self.run_prior_only = run_prior_only + self.run_prior_then_guidance = run_prior_then_guidance + self.n_diffusion_steps_without_noise = n_diffusion_steps_without_noise + self.hard_conds = hard_conds + self.model = ensemble + self.n_support_points = n_support_points + self.t_start_guide = t_start_guide + self.n_guide_steps = n_guide_steps + # self.guide = guide + self.tensor_args = tensor_args + # Batch-size. How many trajectories to generate at once. + self.num_samples = n_samples + # When doing local inference, how many steps to add noise for before denoising again. + self.n_local_inference_noising_steps = n_local_inference_noising_steps # n_local_inference_noising_steps + self.n_local_inference_denoising_steps = n_local_inference_denoising_steps + # Dataset. + self.datasets = datasets + # Task, e.g., planning task. + self.task = tasks_ensemble + # Directories. + self.results_dir = results_dir + + # Cache of previous call data. + self.recent_call_data = PlannerOutput() + + def __call__(self, start_state_pos, goal_state_pos, constraints_l=None, experience: PathBatchExperience = None, + *args, + **kwargs): + """ + Call the model with the given parameters. + :param n_samples: Number of trajectories to generate. + :param start_state_pos: The start state of the robot. + :param goal_state_pos: The goal state of the robot. + :param constraints_l: A list of constraints. These are in local time (accounting for different start times) + and in the global frame. + :param previous_path: The previous path of the robot. This would be used to guide the next path. + """ + # Check that the requested start and goal states are similar to the ones stored. + if not torch.allclose(start_state_pos, self.start_state_pos): + raise ValueError("The start state is different from the one stored in the planner.") + if not torch.allclose(goal_state_pos, self.goal_state_pos): + raise ValueError("The goal state is different from the one stored in the planner.") + + # Process the constraints into cost components. + if constraints_l is not None: + print("Planning with " + str(len(constraints_l)) + " constraints.") + else: + print("Planning without constraints.") + + cost_constraints_l = [] + if constraints_l is not None: + for c in constraints_l: + cost_constraints_l.append( + CostConstraint( + self.robot, + self.n_support_points, + q_l=c.get_q_l(), + traj_range_l=c.get_t_range_l(), + radius_l=c.radius_l, + is_soft=c.is_soft, + tensor_args=self.tensor_args + ) + ) + # Carry out inference with the constraints. If there is no experience, inference from scratch. + with TimerCUDA() as timer_inference: + if experience is None: + trajs_normalized_iters_dict, _, _ = self.run_constrained_inference( + cost_constraints_l) # Shape [B (n_samples), H, D] + # Otherwise, use the experience path as a seed for a local inference call. + else: + trajs_normalized_iters_dict, _, _ = self.run_constrained_local_inference(cost_constraints_l, experience) + t_total = timer_inference.elapsed + + results_ensemble = {} + for model_index in self.models.keys(): + trajs_normalized_iters = trajs_normalized_iters_dict[model_index] + # Unnormalize trajectory samples from the models. + trajs_iters, trajs_final, trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs = ( + self.task.get_traj_unnormalized(model_index, self.datasets, trajs_normalized_iters)) + results_ensemble[model_index] = self.task.get_stats(model_index, trajs_iters, trajs_final, + trajs_final_coll, + trajs_final_coll_idxs, trajs_final_free, + trajs_final_free_idxs, t_total, save_data=False) + + results_ensemble = self.task.combine_trajs(results_ensemble) + + self.recent_call_data = PlannerOutput() + self.recent_call_data.trajs_iters = results_ensemble['trajs_iters'] + self.recent_call_data.trajs_final = results_ensemble['trajs_iters'][-1] + self.recent_call_data.trajs_final_coll = results_ensemble['trajs_final_coll'] + self.recent_call_data.trajs_final_coll_idxs = results_ensemble['trajs_final_coll_idxs'] + self.recent_call_data.trajs_final_free = results_ensemble['trajs_final_free'] # Shape [B, H, D] + self.recent_call_data.trajs_final_free_idxs = results_ensemble['trajs_final_free_idxs'] # Shape [B] + self.recent_call_data.success_free_trajs = results_ensemble['success_free_trajs'] + self.recent_call_data.fraction_free_trajs = results_ensemble['fraction_free_trajs'] + self.recent_call_data.collision_intensity_trajs = results_ensemble['collision_intensity_trajs'] + if self.recent_call_data.success_free_trajs: + self.recent_call_data.idx_best_traj = results_ensemble['idx_best_traj'] + self.recent_call_data.traj_final_free_best = results_ensemble['traj_final_free_best'] + self.recent_call_data.cost_best_free_traj = results_ensemble['cost_best_free_traj'] + self.recent_call_data.cost_smoothness = results_ensemble['cost_smoothness_trajs_final_free'] + self.recent_call_data.cost_path_length = results_ensemble['cost_path_length_trajs_final_free'] + self.recent_call_data.cost_all = results_ensemble['cost_all_trajs_final_free'] + self.recent_call_data.variance_waypoint_trajs_final_free = results_ensemble['variance_waypoint_trajs_final_free'] + else: + self.recent_call_data.idx_best_traj = None + self.recent_call_data.traj_final_free_best = None + self.recent_call_data.cost_best_free_traj = None + self.recent_call_data.cost_smoothness = None + self.recent_call_data.cost_path_length = None + self.recent_call_data.cost_all = None + self.recent_call_data.variance_waypoint_trajs_final_free = None + self.recent_call_data.t_total = results_ensemble['t_total'] + self.recent_call_data.constraints_l = constraints_l + + # Smooth the trajectories in trajs_final. + if self.recent_call_data.trajs_final is not None: + self.recent_call_data.trajs_final = smooth_trajs(self.recent_call_data.trajs_final) + + return self.recent_call_data + + def split_cost_constraints_to_tasks(self, cost_constraints_l: List[CostConstraint]): + """ + A cost constraint may have multiple configurations, each associated with a different task. This method + splits the cost constraint into multiple cost constraints, each associated with a different task. + :param cost_constraint: The cost constraint to split. + :return: A dictionary of task ids to list of cost constraints. + """ + task_id_to_cost_constraints_l = {} + # We have two categories of constraints for now: hard and soft. + task_id_to_q_traj_range_radius_hard = {} # int: List[CostConstraint] + task_id_to_q_traj_range_radius_soft = {} # int: List[CostConstraint] + if len(cost_constraints_l) > 0: + # There may be multiple configurations/time-intervals in each constraint. + for i, constraint in enumerate(cost_constraints_l): + qs = constraint.qs + traj_ranges = constraint.traj_ranges + # Go through each one of the constraining configurations, get its task id, and add it to the + # corresponding list of constraints. + for j in range(len(qs)): + q = qs[j] # Shape (q_dim,) + traj_range = traj_ranges[j] # Shape (2,). The start and end indices of the trajectory. Inclusive. + radius = constraint.radii[j] + # NOTE(yoraish): we assume that each q is associated with a single task. We do not break down long constraint intervals here. + task_id, _ = self.task.infer_task_id_from_q_idx(traj_range[0].item()) + if constraint.is_soft: + if task_id not in task_id_to_q_traj_range_radius_soft: + task_id_to_q_traj_range_radius_soft[task_id] = [] + task_id_to_q_traj_range_radius_soft[task_id].append((q, traj_range, radius)) + else: + if task_id not in task_id_to_q_traj_range_radius_hard: + task_id_to_q_traj_range_radius_hard[task_id] = [] + task_id_to_q_traj_range_radius_hard[task_id].append((q, traj_range, radius)) + + # Aggregate the constraints for each task. Create one for hard and one for soft constraints. + for task_id, q_traj_range_radius_hard in task_id_to_q_traj_range_radius_hard.items(): + q_l, traj_range_l, radius_l = zip(*q_traj_range_radius_hard) + # Convert lists from lists of tensors to lists of tensors, tuples, or floats. + traj_range_l = [(traj_range[0].item(), traj_range[1].item()) for traj_range in traj_range_l] + radius_l = [radius.item() for radius in radius_l] + + cost_constraint = CostConstraint( + self.robot, + self.n_support_points, + q_l=q_l, + traj_range_l=traj_range_l, + radius_l=radius_l, + is_soft=False, + tensor_args=self.tensor_args + ) + if task_id not in task_id_to_cost_constraints_l: + task_id_to_cost_constraints_l[task_id] = [] + task_id_to_cost_constraints_l[task_id].append(cost_constraint) + + for task_id, q_traj_range_radius_soft in task_id_to_q_traj_range_radius_soft.items(): + q_l, traj_range_l, radius_l = zip(*q_traj_range_radius_soft) + # Convert lists from lists of tensors to lists of tensors, tuples, or floats. + traj_range_l = [(traj_range[0].item(), traj_range[1].item()) for traj_range in traj_range_l] + radius_l = [radius.item() for radius in radius_l] + + cost_constraint = CostConstraint( + self.robot, + self.n_support_points, + q_l=q_l, + traj_range_l=traj_range_l, + radius_l=radius_l, + is_soft=True, + tensor_args=self.tensor_args + ) + if task_id not in task_id_to_cost_constraints_l: + task_id_to_cost_constraints_l[task_id] = [] + task_id_to_cost_constraints_l[task_id].append(cost_constraint) + + print("==============") + print("Got #constraints:", len(cost_constraints_l)) + print(f'Constraints split to tasks: {task_id_to_cost_constraints_l.keys()}, with num constraints: {[len(v) for v in task_id_to_cost_constraints_l.values()]}') + + return task_id_to_cost_constraints_l + + def run_constrained_inference(self, cost_constraints_l: List[CostConstraint]): + # Add these cost factors, alongside their weights as specified, to the `guide` object of the model. + # Here we add specific constraints to each model. We may need to break down constraints + # that span multiple models. + task_id_to_cost_constraints_l = self.split_cost_constraints_to_tasks(cost_constraints_l) + + for task_id, cost_constraints_l in task_id_to_cost_constraints_l.items(): + for cost_constraint in cost_constraints_l: + cost_constraint.traj_ranges -= task_id * self.n_support_points + cost_constraint.qs -= self.transforms[task_id] # TODO(yorai): check this. + self.guides[task_id].add_extra_costs([cost_constraint], + [self.weight_grad_cost_constraints + if not cost_constraint.is_soft else + self.weight_grad_cost_soft_constraints]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters_dict = self.model.run_inference( + self.contexts, self.hard_conds, cross_conds=self.cross_conds, + n_samples=self.num_samples, + # horizon=self.n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + sample_kwargs=self.sample_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + ######## + # run extra guiding steps without diffusion + t_post_diffusion_guide = 0.0 + if self.run_prior_then_guidance: + n_post_diffusion_guide_steps = (self.t_start_guide + + self.n_diffusion_steps_without_noise) * self.n_guide_steps + print(CYAN + f'Running extra guiding steps without diffusion. Num steps:', n_post_diffusion_guide_steps, + RESET) + with TimerCUDA() as timer_post_model_sample_guide: + for task_id, guide in self.guides.items(): + trajs_normalized_iters = trajs_normalized_iters_dict[task_id] + trajs = trajs_normalized_iters[-1] + trajs_post_diff_l = [] + for i in range(n_post_diffusion_guide_steps): + trajs = guide_gradient_steps( + trajs, + hard_conds=self.hard_conds[task_id], + guide=self.guides[task_id], + n_guide_steps=1, + unnormalize_data=False + ) + trajs_post_diff_l.append(trajs) + chain = torch.stack(trajs_post_diff_l, dim=1) + chain = einops.rearrange(chain, 'b post_diff_guide_steps h d -> post_diff_guide_steps b h d') + trajs_normalized_iters_dict[task_id] = torch.cat((trajs_normalized_iters, chain)) + t_post_diffusion_guide = timer_post_model_sample_guide.elapsed + print(f't_post_diffusion_guide: {t_post_diffusion_guide:.3f} sec') + + # Remove the extra cost. + for task_id in task_id_to_cost_constraints_l: + self.guides[task_id].reset_extra_costs() + # self.guide.reset_extra_costs() + + return trajs_normalized_iters_dict, t_model_sampling, t_post_diffusion_guide + + def run_constrained_local_inference(self, cost_constraints_l: List[CostConstraint], + experience: PathBatchExperience): + task_id_to_cost_constraints_l = self.split_cost_constraints_to_tasks(cost_constraints_l) + + for task_id, cost_constraints_l in task_id_to_cost_constraints_l.items(): + for cost_constraint in cost_constraints_l: + cost_constraint.traj_ranges -= task_id * self.n_support_points + cost_constraint.qs -= self.transforms[task_id] # TODO(yorai): check this. + self.guides[task_id].add_extra_costs( + [cost_constraint], + [self.weight_grad_cost_constraints if not cost_constraint.is_soft + else self.weight_grad_cost_soft_constraints]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters_dict = self.model.run_local_inference( + experience.path_b, + self.n_local_inference_noising_steps, + self.n_local_inference_denoising_steps, + self.contexts, + self.hard_conds, + cross_conds=self.cross_conds, + n_samples=self.num_samples, + horizon=self.n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + sample_kwargs=self.sample_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + ######## + # Run extra guiding steps without diffusion. This is only done if variable `run_prior_then_guidance` is True. + t_post_diffusion_guide = 0.0 + if self.run_prior_then_guidance: + n_post_diffusion_guide_steps = (self.t_start_guide + + self.n_diffusion_steps_without_noise) * self.n_guide_steps + print(CYAN + f'Running extra guiding steps without diffusion. Num steps:', n_post_diffusion_guide_steps, + RESET) + with TimerCUDA() as timer_post_model_sample_guide: + for task_id, guide in self.guides.items(): + trajs_normalized_iters = trajs_normalized_iters_dict[task_id] + trajs = trajs_normalized_iters[-1] + trajs_post_diff_l = [] + for i in range(n_post_diffusion_guide_steps): + trajs = guide_gradient_steps( + trajs, + hard_conds=self.hard_conds[task_id], + guide=self.guides[task_id], + n_guide_steps=1, + unnormalize_data=False + ) + trajs_post_diff_l.append(trajs) + chain = torch.stack(trajs_post_diff_l, dim=1) + chain = einops.rearrange(chain, 'b post_diff_guide_steps h d -> post_diff_guide_steps b h d') + trajs_normalized_iters_dict[task_id] = torch.cat((trajs_normalized_iters, chain)) + t_post_diffusion_guide = timer_post_model_sample_guide.elapsed + print(f't_post_diffusion_guide: {t_post_diffusion_guide:.3f} sec') + + # Remove the extra cost. + for task_id in task_id_to_cost_constraints_l: + self.guides[task_id].reset_extra_costs() + # self.guide.reset_extra_costs() + + return trajs_normalized_iters_dict, t_model_sampling, t_post_diffusion_guide + + def save_recent_result(self): + # Compute motion planning metrics + print(f'\n----------------METRICS----------------') + print(f't_total: {self.recent_call_data.t_total:.3f} sec') + print(f'success: {self.recent_call_data.success_free_trajs}') + print(f'percentage free trajs: {self.recent_call_data.fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {self.recent_call_data.collision_intensity_trajs * 100:.2f}') + print(f'cost smoothness: {self.recent_call_data.cost_smoothness.mean():.4f}, ' + f'{self.recent_call_data.cost_smoothness.std():.4f}') + print(f'cost path length: {self.recent_call_data.cost_path_length.mean():.4f}, ' + f'{self.recent_call_data.cost_path_length.std():.4f}') + cost_best_free_traj = torch.min(self.recent_call_data.cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + # variance of waypoints + print(f'variance waypoint: {self.recent_call_data.variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + #################################### + # Save data + results_data_dict = { + 'trajs_iters': self.recent_call_data.trajs_iters, + 'trajs_final': self.recent_call_data.trajs_final, + 'trajs_final_coll': self.recent_call_data.trajs_final_coll, + 'trajs_final_coll_idxs': self.recent_call_data.trajs_final_coll_idxs, + 'trajs_final_free': self.recent_call_data.trajs_final_free, + 'trajs_final_free_idxs': self.recent_call_data.trajs_final_free_idxs, + 'success_free_trajs': self.recent_call_data.success_free_trajs, + 'fraction_free_trajs': self.recent_call_data.fraction_free_trajs, + 'collision_intensity_trajs': self.recent_call_data.collision_intensity_trajs, + 'idx_best_traj': self.recent_call_data.idx_best_traj, + 'traj_final_free_best': self.recent_call_data.traj_final_free_best, + 'cost_best_free_traj': self.recent_call_data.cost_best_free_traj, + 'cost_smoothness': self.recent_call_data.cost_smoothness, + 'cost_path_length': self.recent_call_data.cost_path_length, + 'cost_all': self.recent_call_data.cost_all, + 'variance_waypoint_trajs_final_free': self.recent_call_data.variance_waypoint_trajs_final_free, + 't_total': self.recent_call_data.t_total, + 'constraints_l': self.recent_call_data.constraints_l, + } + with open(os.path.join(self.results_dir, 'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + def render_recent_result(self, animation_duration: float = 5.0): + # Render + planner_visualizer = PlanningVisualizer( + task=self.task, + ) + + base_file_name = Path(os.path.basename(__file__)).stem + traj_final_free_best = self.recent_call_data.trajs_final[self.recent_call_data.idx_best_traj] + pos_trajs_iters = self.robot.get_position(self.recent_call_data.trajs_iters) + + # planner_visualizer.animate_opt_iters_joint_space_state( + # trajs=self.recent_call_data.trajs_iters, + # pos_start_state=self.start_state_pos, pos_goal_state=self.goal_state_pos, + # vel_start_state=torch.zeros_like(self.start_state_pos), + # vel_goal_state=torch.zeros_like(self.goal_state_pos), + # traj_best=traj_final_free_best, + # video_filepath=os.path.join(self.results_dir, f'{base_file_name}-joint-space-opt-iters.gif'), + # n_frames=max((2, len(self.recent_call_data.trajs_iters) // 10)), + # anim_time=5 + # ) + + # visualize in the planning environment + planner_visualizer.animate_opt_iters_robots( + trajs=pos_trajs_iters, start_state=self.start_state_pos, goal_state=self.goal_state_pos, + traj_best=traj_final_free_best, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-traj-opt-iters.gif'), + # n_frames=max((2, len(trajs_iters))), + n_frames=2, + anim_time=5 + ) + + planner_visualizer.animate_robot_trajectories( + trajs=pos_trajs_iters[-1], start_state=self.start_state_pos, goal_state=self.goal_state_pos, + plot_trajs=True, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-robot-traj.gif'), + n_frames=max((2, pos_trajs_iters[-1].shape[1])), + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=self.recent_call_data.constraints_l + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/pcd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/pcd.py new file mode 100644 index 0000000000000000000000000000000000000000..c7841247cdd86005c0b0d4e52c80342033b6463c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/pcd.py @@ -0,0 +1,642 @@ +""" +Modified from +- https://github.com/jacarvalho/mpd-public +- https://github.com/yoraish/mmd +MIT License + +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import pickle +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List, Dict, Optional, Union +import logging + +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostConstraint, CostMaxVelocity +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.diffusion_model_base import GaussianDiffusionModel +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.datasets.trajectories import TrajectoryDataset +from mmd.models.diffusion_models.sample_functions import guide_gradient_steps, projected_coupling_ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from mmd.planners.single_agent.common import PlannerOutput +from mmd.planners.single_agent.single_agent_planner_base import SingleAgentPlanner +from mmd.common.experiences import PathExperience, PathBatchExperience +from mmd.common.constraints import MultiPointConstraint +from mmd.common.pretty_print import * +from mmd.models.projection.projectors import BaseProjector, ADMMProjectionOperatorCUDAGraph +from mmd.coupling_costs.coupling_cost_functions import DummyCost, RobotCollisionCost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +PLANNER_ALGORITHMS = ['pcd', 'projected', 'coupling', 'vanilla'] +PROJECTORS = ['ADMMProjectionOperatorCUDAGraph', 'ADMMProjectionOperator'] + +class PCD(SingleAgentPlanner): + """ + A class that allows repeated calls to the same model with different inputs. + This class keeps track of constraints and feeds them to the model only when needed. + """ + + def __init__(self, + model_id: str, + planner_alg: str, + n_agents: int, + start_state_pos: torch.Tensor, + goal_state_pos: torch.Tensor, + use_guide_on_extra_objects_only: bool, + start_guide_steps_fraction: float, + n_guide_steps: int, + n_diffusion_steps_without_noise: int, + cost_func_key:str, + weight_grad_cost_collision: float, + weight_grad_cost_smoothness: float, + weight_grad_cost_constraints: float, + weight_grad_cost_soft_constraints: float, + factor_num_interpolated_points_for_collision: float, + trajectory_duration: float, + device: str, + debug: bool, + seed: int, + results_dir: str, + trained_models_dir: str, + n_samples: int, + n_local_inference_noising_steps: int, + n_local_inference_denoising_steps: int, + projection_args: Dict = None, + cost_func_param: Dict = {}, + timeit: bool = False, + **kwargs + ) -> None: + + assert n_samples % n_agents == 0, \ + f"Number of samples ({n_samples}) must be divisible by number of agents ({n_agents})." + + super().__init__() + # The constraints are stored here. This is a list of ConstraintCost. + self.constraints = [] + fix_random_seed(seed) + + device = get_torch_device(device) + tensor_args = {'device': device, 'dtype': torch.float32} + logger.info(f'Initializing Planner with Model -- {model_id}') + logger.info(f'Algorithm --- {planner_alg}') + + assert planner_alg in PLANNER_ALGORITHMS, \ + f"Unknown planner algorithm: '{planner_alg}'. Supported algorithms are {PLANNER_ALGORITHMS}." + + use_projection = True + use_coupling = True + if planner_alg == 'pcd': + pass + elif planner_alg == 'projected': + use_coupling = False + elif planner_alg == 'coupling': + use_projection = False + elif planner_alg == 'vanilla': + use_projection = False + use_coupling = False + else: + raise NotImplementedError( + f"Unknown planner algorithm: '{planner_alg}'. " + f"Supported algorithms are {PLANNER_ALGORITHMS}.") + + + model_dir = os.path.join(trained_models_dir, model_id) + results_dir = os.path.join(model_dir, 'results_inference', str(seed)) + os.makedirs(results_dir, exist_ok=True) + + args = load_params_from_yaml(os.path.join(model_dir, "args.yaml")) + + # Load dataset with env, robot, task. The TrajectoryDataset type is used here. + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=tensor_args + ) + # Extract objects from the dataset. + dataset:TrajectoryDataset = train_subset.dataset + # Number of support points in the trajectory. + n_support_points = dataset.n_support_points + # The environment. + env = dataset.env + # The robot. Contains the dt, the joint limits, etc. + robot = dataset.robot + # The task, commonly PlanningTask, is in charge of objects, extra objects, collisions, etc. + task:PlanningTask = dataset.task + + dt = trajectory_duration / n_support_points # time interval for finite differences + + # set robot's dt + robot.dt = dt + logger.info(f'Robot dt set to {robot.dt}') + + # Load prior model + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model:GaussianDiffusionModel = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs + ) + diffusion_model.load_state_dict( + torch.load( + os.path.join(model_dir, 'checkpoints', 'ema_model_current_state_dict.pth' \ + if args['use_ema'] \ + else 'model_current_state_dict.pth'), + map_location=tensor_args['device']) + ) + diffusion_model.eval() + model = diffusion_model + + freeze_torch_model_params(model) + model = torch.compile(model) + model.warmup(horizon=n_support_points, device=device) + + # If the args specify a test start and goal, use those. + if start_state_pos is not None and goal_state_pos is not None: + assert start_state_pos.shape[0] == n_agents, \ + f"Start state position shape {start_state_pos.shape} does not match number of agents {n_agents}." + assert goal_state_pos.shape[0] == n_agents, \ + f"Goal state position shape {goal_state_pos.shape} does not match number of agents {n_agents}." + else: + # Random initial and final positions pairs + n_tries = 1000 + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2 * n_agents) + starts = q_free[:n_agents] + goals = q_free[n_agents:] + + cmp_a = starts + cmp_b = goals + + norms = torch.linalg.norm(cmp_a - cmp_b, dim=-1) # [n_agents, state_dim] -> [n_agents] + if ( norms > dataset.threshold_start_goal_pos).all(): + start_state_pos = starts + goal_state_pos = goals + break + if start_state_pos is None or goal_state_pos is None: + logger.error(f"Could not find a collision-free configuration for start and goal positions after {n_tries} tries.") + logger.error(f"Distance threshold: {dataset.threshold_start_goal_pos}; Norms of all checked pairs: {norms}. ") + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + logger.info(f'start_state_pos: {start_state_pos}') + logger.info(f'goal_state_pos: {goal_state_pos}') + + # Run motion planning inference + # normalize start and goal positions + hard_conds = dataset.get_hard_conditions(torch.stack((start_state_pos, goal_state_pos)), normalize=True) + hard_conds["group_size"] = n_samples // n_agents # For groupwise hard conditioning + context = None + + # Set up the planning costs + cost_collision_l = [] + weights_grad_cost_l = [] # for guidance, the weights_cost_l are the gradient multipliers (after gradient clipping) + + # Inter-robot collisions + if use_coupling: + cost_collision_l.append( + RobotCollisionCost( + robot_radius=robot.radius, + cost_func_key=cost_func_key, + **cost_func_param, + ) + ) + else: + cost_collision_l.append(DummyCost()) + weights_grad_cost_l.append(weight_grad_cost_constraints if use_coupling else 0.0) + + # Static obstacles + if use_guide_on_extra_objects_only: + logger.error("Extra objects guiding is not implemented yet.") + raise NotImplementedError("Extra objects guiding is not implemented yet.") + collision_fields = task.get_collision_fields_extra_objects() + else: + collision_fields = task.get_collision_fields() + + for collision_field in collision_fields: + cost_collision_l.append( + CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=1.0, + tensor_args=tensor_args + ) + ) + weights_grad_cost_l.append(weight_grad_cost_collision) + + cost_func_list = [ + *cost_collision_l, + ] + # A `*cost_constraints_l` will be added as "extra cost" and removed after each planning call. + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + weights_cost_l=weights_grad_cost_l, + tensor_args=tensor_args + ) + + # Guiding manager + guide = GuideManagerTrajectoriesWithVelocity( + dataset, + cost_composite, + clip_grad=True, + interpolate_trajectories_for_collision=False, + num_interpolated_points=ceil(n_support_points * factor_num_interpolated_points_for_collision), + tensor_args=tensor_args, + ) + + t_start_guide = ceil(start_guide_steps_fraction * model.n_diffusion_steps) + + # Projector + if use_projection: + projector_args:dict = projection_args.get('projector', None) + assert projector_args is not None, \ + "Projector parameters must be provided in the model args." + projector_kls_str = projector_args.pop('_target_', 'ADMMProjectionOperatorCUDAGraph') + if projector_kls_str == 'ADMMProjectionOperatorCUDAGraph': + projector = ADMMProjectionOperatorCUDAGraph( + **projector_args, + ) + else: + logger.error(f"Unknown projector class: '{projector_kls_str}'.") + raise NotImplementedError( + f"Unknown projector class: '{projector_kls_str}'. " + f"Supported classes are {PROJECTORS}." + ) + + proj_params = projection_args.get('project_params', None) + assert proj_params is not None, \ + "Projector parameters must be provided in the model args." + + # Prepare projection parameters for the sample function + sample_fn_proj_params = { + 'pos_init': hard_conds[0][..., :start_state_pos.shape[-1]].clone().detach().repeat_interleave(n_samples//n_agents, dim=0), + 'dx_max': proj_params['vel_max'] * dt + } + else: + projector = None + sample_fn_proj_params = None + + # Keep some variables in the class as members. + self.start_state_pos = torch.clone(start_state_pos) + self.goal_state_pos = torch.clone(goal_state_pos) + self.robot = robot + self.context = context + self.use_projection = use_projection + self.use_coupling = use_coupling + self.n_diffusion_steps_without_noise = n_diffusion_steps_without_noise + self.hard_conds = hard_conds + self.model:GaussianDiffusionModel = model + self.n_support_points = n_support_points + self.t_start_guide = t_start_guide + self.n_guide_steps = n_guide_steps + self.guide = guide + self.tensor_args = tensor_args + self.projector = projector + self.num_samples = n_samples + self.num_agents = n_agents + # When doing local inference, how many steps to add noise for before denoising again. + self.n_local_inference_noising_steps = n_local_inference_noising_steps + self.n_local_inference_denoising_steps = n_local_inference_denoising_steps + self.dataset:TrajectoryDataset = dataset + self.task = task + self.results_dir = results_dir + + self.recent_call_data = PlannerOutput() + + sample_fn_guide_kwargs = { + "group_size": n_agents, + "margin_coeff": 1.99, # No margin for the guide + } + + self.sample_fn_kwargs = dict( + guide=self.guide, + projector=self.projector if use_projection else None, + guide_kwargs=sample_fn_guide_kwargs if use_coupling else {}, + project_params=sample_fn_proj_params, + n_guide_steps=self.n_guide_steps, + t_start_guide=self.t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + timeit=timeit, + ) + + self.agent_batch_inds = torch.tensor_split(torch.arange(n_samples, device=device), n_agents) + + + def __call__(self, + start_state_pos, goal_state_pos, + constraints_l=None, + experience: Optional[PathBatchExperience] = None, + *args, + **kwargs + )->PlannerOutput: + """ + Call the model with the given parameters. + :param n_samples: Number of trajectories to generate. + :param start_state_pos: The start state of the robot. + :param goal_state_pos: The goal state of the robot. + :param constraints_l: A list of constraints. + :param previous_path: The previous path of the robot. This would be used to guide the next path. + """ + if torch.allclose(start_state_pos, self.start_state_pos) and torch.allclose(goal_state_pos, self.goal_state_pos): + pass + else: + logger.info("Start or/and goal states changed. ") + self.update_start_goal_states(start_state_pos, goal_state_pos) + + # Carry out inference with the constraints. If there is no experience, inference from scratch. + with TimerCUDA() as timer_inference: + if experience is None: + trajs_normalized_iters, t_profile, _, _ = self.run_constrained_inference( + cost_constraints_l=constraints_l) # Shape [B (n_samples), H, D] + # Otherwise, use the experience path as a seed for a local inference call. + else: + trajs_normalized_iters, _, _ = self.run_constrained_local_inference(constraints_l, experience) + t_total = timer_inference.elapsed + + # Un-normalize trajectory samples from the models. + trajs_iters = self.dataset.unnormalize_trajectories(trajs_normalized_iters) + + trajs_final = trajs_iters[-1] + trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs, _ = ( + self.task.get_trajs_collision_and_free(trajs_final, return_indices=True)) + + self.recent_call_data = PlannerOutput() + self.recent_call_data.trajs_iters = trajs_iters + self.recent_call_data.trajs_final = trajs_final + self.recent_call_data.trajs_final_coll = trajs_final_coll + self.recent_call_data.trajs_final_coll_idxs = trajs_final_coll_idxs + self.recent_call_data.trajs_final_free = trajs_final_free # Shape [B, H, D] + self.recent_call_data.t_total = t_total + self.recent_call_data.trajs_final_free_idxs = trajs_final_free_idxs # Shape [B] + self.recent_call_data.constraints_l = constraints_l + if t_profile is not None: + t_inner_total = sum(t_profile.values()) + t_profile['others'] = t_total - t_inner_total + self.recent_call_data.t_profile = t_profile + + return self.recent_call_data + + def update_start_goal_states(self, start_state_pos: torch.Tensor, goal_state_pos: torch.Tensor): + """ + Update the start and goal states of the planner. + :param start_state_pos: The new start state position. + :param goal_state_pos: The new goal state position. + """ + assert start_state_pos.shape[0] == self.num_agents, \ + f"Start state position shape {start_state_pos.shape} does not match number of agents {self.num_agents}." + assert goal_state_pos.shape[0] == self.num_agents, \ + f"Goal state position shape {goal_state_pos.shape} does not match number of agents {self.num_agents}." + + self.start_state_pos = start_state_pos.clone().detach() + self.goal_state_pos = goal_state_pos.clone().detach() + + self.hard_conds = self.dataset.get_hard_conditions(torch.stack((start_state_pos, goal_state_pos)), normalize=True) + group_sz = self.num_samples // self.num_agents + self.hard_conds["group_size"] = group_sz + + if self.use_projection: + self.sample_fn_kwargs['project_params'].update({ + 'pos_init': self.hard_conds[0][..., :start_state_pos.shape[-1]].repeat_interleave(group_sz, dim=0), + }) + + + def run_constrained_inference(self, + cost_constraints_l: Union[List[CostConstraint], None] = None + ) -> Tuple[torch.Tensor, float, float]: + if cost_constraints_l is not None: + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l] + ) + + timeit = self.sample_fn_kwargs.get('timeit', False) + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + if timeit: + trajs_normalized_iters, t_sampling_profile = self.model.run_inference( + self.context, self.hard_conds, + n_samples=self.num_samples, horizon=self.n_support_points, + return_chain=True, + sample_fn=projected_coupling_ddpm_sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + else: + trajs_normalized_iters = self.model.run_inference( + self.context, self.hard_conds, + n_samples=self.num_samples, horizon=self.n_support_points, + return_chain=True, + sample_fn=projected_coupling_ddpm_sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + # logger.info(f't_model_sampling: {t_model_sampling:.3f} sec') + + # run extra guiding steps without diffusion + t_post_diffusion_guide = 0.0 + # Remove the extra cost. + self.guide.reset_extra_costs() + + if not timeit: + t_sampling_profile = None + + return trajs_normalized_iters, t_sampling_profile, t_model_sampling, t_post_diffusion_guide + + def run_constrained_local_inference(self, cost_constraints_l: List[CostConstraint], + experience: PathBatchExperience): + + raise NotImplementedError( + "The method `run_constrained_local_inference` is not implemented. " + "This method should be implemented in the derived class." + ) + + self.guide.add_extra_costs(cost_constraints_l, + [self.weight_grad_cost_soft_constraints if c.is_soft else + self.weight_grad_cost_constraints + for c in cost_constraints_l]) + + # Sample trajectories with the diffusion/cvae model + with TimerCUDA() as timer_model_sampling: + trajs_normalized_iters = self.model.run_local_inference( + experience.path_b, + self.n_local_inference_noising_steps, + self.n_local_inference_denoising_steps, + self.context, + self.hard_conds, + n_samples=self.num_samples, + horizon=self.n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **self.sample_fn_kwargs, + n_diffusion_steps_without_noise=self.n_diffusion_steps_without_noise, + # ddim=True + ) + t_model_sampling = timer_model_sampling.elapsed + print(f't_model_sampling: {t_model_sampling:.3f} sec') + + t_post_diffusion_guide = 0.0 + + # Remove the extra cost. + self.guide.reset_extra_costs() + + return trajs_normalized_iters, t_model_sampling, t_post_diffusion_guide + + def save_recent_result(self): + # Compute motion planning metrics + print(f'\n----------------METRICS----------------') + print(f't_total: {self.recent_call_data.t_total:.3f} sec') + + success_free_trajs = self.task.compute_success_free_trajs(self.recent_call_data.trajs_final) + fraction_free_trajs = self.task.compute_fraction_free_trajs(self.recent_call_data.trajs_final) + collision_intensity_trajs = self.task.compute_collision_intensity_trajs(self.recent_call_data.trajs_final) + + print(f'success: {success_free_trajs}') + print(f'percentage free trajs: {fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {collision_intensity_trajs * 100:.2f}') + + # compute costs only on collision-free trajectories + traj_final_free_best = None + idx_best_traj = None + cost_best_free_traj = None + cost_smoothness = None + cost_path_length = None + cost_all = None + variance_waypoint_trajs_final_free = None + if self.recent_call_data.trajs_final_free is not None: + print(f'cost smoothness: {self.recent_call_data.cost_smoothness.mean():.4f}, ' + f'{self.recent_call_data.cost_smoothness.std():.4f}') + print(f'cost path length: {self.recent_call_data.cost_path_length.mean():.4f}, ' + f'{self.recent_call_data.cost_path_length.std():.4f}') + + # compute best trajectory + cost_best_free_traj = torch.min(self.recent_call_data.cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + # variance of waypoints + print(f'variance waypoint: {self.recent_call_data.variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + results_data_dict = { + 'trajs_iters': self.recent_call_data.trajs_iters, + 'trajs_final_coll': self.recent_call_data.trajs_final_coll, + 'trajs_final_coll_idxs': self.recent_call_data.trajs_final_coll_idxs, + 'trajs_final_free': self.recent_call_data.trajs_final_free, + 'trajs_final_free_idxs': self.recent_call_data.trajs_final_free_idxs, + 'success_free_trajs': success_free_trajs, + 'fraction_free_trajs': fraction_free_trajs, + 'collision_intensity_trajs': collision_intensity_trajs, + 'idx_best_traj': idx_best_traj, + 'traj_final_free_best': traj_final_free_best, + 'cost_best_free_traj': cost_best_free_traj, + 'cost_path_length_trajs_final_free': cost_path_length, + 'cost_smoothness_trajs_final_free': cost_smoothness, + 'cost_all_trajs_final_free': cost_all, + 'variance_waypoint_trajs_final_free': variance_waypoint_trajs_final_free, + 't_total': self.recent_call_data.t_total + } + with open(os.path.join(self.results_dir, 'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + def render_recent_result(self, animation_duration: float = 5.0): + # Render + planner_visualizer = PlanningVisualizer( + task=self.task, + ) + + base_file_name = Path(os.path.basename(__file__)).stem + traj_final_free_best = self.recent_call_data.trajs_final[self.recent_call_data.idx_best_traj] + pos_trajs_iters = self.robot.get_position(self.recent_call_data.trajs_iters) + + planner_visualizer.animate_opt_iters_joint_space_state( + trajs=self.recent_call_data.trajs_iters, + pos_start_state=self.start_state_pos, pos_goal_state=self.goal_state_pos, + vel_start_state=torch.zeros_like(self.start_state_pos), + vel_goal_state=torch.zeros_like(self.goal_state_pos), + traj_best=traj_final_free_best, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-joint-space-opt-iters.gif'), + n_frames=max((2, len(self.recent_call_data.trajs_iters) // 10)), + anim_time=5 + ) + + # visualize in the planning environment + planner_visualizer.animate_opt_iters_robots( + trajs=pos_trajs_iters, start_state=self.start_state_pos, goal_state=self.goal_state_pos, + traj_best=traj_final_free_best, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-traj-opt-iters.gif'), + # n_frames=max((2, len(trajs_iters))), + n_frames=2, + anim_time=5 + ) + + planner_visualizer.animate_robot_trajectories( + trajs=pos_trajs_iters[-1], start_state=self.start_state_pos, goal_state=self.goal_state_pos, + plot_trajs=True, + video_filepath=os.path.join(self.results_dir, f'{base_file_name}-robot-traj.gif'), + n_frames=max((2, pos_trajs_iters[-1].shape[1])), + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=animation_duration, + constraints=self.recent_call_data.constraints_l + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/projection_metrics.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/projection_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..bfa7a204f5b0c960e30806fe8061221f183bc166 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/projection_metrics.py @@ -0,0 +1,262 @@ +import json +import os +import time +import logging +import numpy as np +import torch + +from mmd.common.eval_utils import collision_detect, calc_velocity + +logger = logging.getLogger(__name__) + + +class ProjectionMetricsRecorder: + """Per-projection-call metrics for the projector ablation, using the + *exact same* violation definitions as the final reported metrics. + + Every ``projector.project()`` output is treated as if it were a final + solution and pushed through the identical predicates that + ``scripts/inference/inference_pcdiff.py`` uses to build + ``processed_data.npz`` (and hence every number in the main tables): + + inter-robot : ``collision_detect(world_pos, safe_dist=2*robot_radius, + norm_order=2)`` -- WORLD frame, strict ``<``, + zero tolerance + obstacle : ``task.get_trajs_collision_and_free(world_traj, + num_interpolation=)`` -> per-waypoint + occupancy with ``margin=robot.radius`` -- WORLD frame, + zero tolerance + velocity : ``calc_velocity(norm_pos, dt=robot.dt) > vel_max + + VEL_TOL`` -- NORMALIZED frame, VEL_TOL = 5e-5 + + The same three functions/objects the final pipeline calls are used here, so + a violation counted by this recorder is a violation by the reported metric's + own definition -- no tolerance band, no re-derived predicate. + + A *solver failure* is a (scene, denoising-step) solve whose projected + trajectory has >= 1 inter-robot collision, >= 1 static-obstacle collision, + or >= 1 velocity violation, under those definitions. + + Frames matter and are not interchangeable: the projectors work in + normalized [-1, 1] space using a single scalar ``norm_scale = + (2/pos_range).min()``, which is only exact when the position ranges of the + two axes are equal. The collision predicates therefore run in WORLD + coordinates, exactly as the final metrics do. The normalized -> world map + is applied here directly rather than via + ``dataset.unnormalize_trajectories``, because that method CLIPS to + [-1, 1] and would hide out-of-bounds violations we are trying to measure. + + Batch layout matches the projectors: B = n_agents * G with batch index + a * G + g; scene g couples the n_agents trajectories at group offset g. + + Results are appended as one JSON line per trial to ``out_path``. + Enable by setting env var PROJ_METRICS_OUT= (see ``from_env``). + + Set PROJ_METRICS_TRAJ_DIR to also dump the raw projector output of every + call, so any future metric can be recomputed offline without re-running. + One ``.npz`` per trial (written at finalize_trial, so a cancelled job keeps + the trials it finished) holding normalized positions (n_steps, B, H, 2) + plus the constants needed to map them to world coordinates. + """ + + def __init__(self, task, robot_radius, pos_mins, pos_range, n_agents, + out_path, vel_max, dt, vel_tol=5e-5, num_interpolation=0, + traj_dir=None): + self.task = task + self.robot_radius = float(robot_radius) # WORLD units + self.n_agents = int(n_agents) + self.out_path = out_path + self.vel_max = float(vel_max) + self.dt = float(dt) + self.vel_tol = float(vel_tol) + self.num_interpolation = int(num_interpolation) + self.traj_dir = traj_dir + if self.traj_dir: + os.makedirs(self.traj_dir, exist_ok=True) + self._traj = [] + + pm = torch.as_tensor(pos_mins).reshape(-1).float() + pr = torch.as_tensor(pos_range).reshape(-1).float() + self._pos_mins = pm + self._pos_range = pr + + self._steps = [] + self._trial = 0 + logger.info( + f'ProjectionMetricsRecorder (final-metric definitions): ' + f'r_world={self.robot_radius:.4f}, n_agents={self.n_agents}, ' + f'vel_max={self.vel_max} (+{self.vel_tol} tol), dt={self.dt:.6f}, ' + f'num_interpolation={self.num_interpolation}, out={self.out_path}, ' + f'traj_dir={self.traj_dir or "(disabled)"}') + + @classmethod + def from_env(cls, task, robot_radius, pos_mins, pos_range, n_agents, + vel_max, dt, num_interpolation=0): + """Build a recorder iff PROJ_METRICS_OUT is set; else return None.""" + out = os.environ.get('PROJ_METRICS_OUT', '').strip() + if not out: + return None + vel_tol = float(os.environ.get('PROJ_METRICS_VEL_TOL', '5e-5')) + traj_dir = os.environ.get('PROJ_METRICS_TRAJ_DIR', '').strip() or None + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + return cls(task=task, robot_radius=robot_radius, pos_mins=pos_mins, + pos_range=pos_range, n_agents=n_agents, out_path=out, + vel_max=vel_max, dt=dt, vel_tol=vel_tol, + num_interpolation=num_interpolation, traj_dir=traj_dir) + + def _to_world(self, x_norm): + """LimitsNormalizer inverse for positions, WITHOUT the clip.""" + pr = self._pos_range.to(x_norm.device, x_norm.dtype) + pm = self._pos_mins.to(x_norm.device, x_norm.dtype) + return (x_norm + 1.0) / 2.0 * pr + pm + + def record(self, t_step, x_dofs, project_params, wall_s): + """Record one projection call. + + Args: + t_step: denoising step index (t_single). + x_dofs: projector OUTPUT positions, tensor (B, H-1, 2), normalized. + project_params: dict with 'pos_init' (B, 2) and 'dx_max'. + wall_s: projector wall-clock seconds for this call (TimerCUDA). + """ + t0 = time.time() + n = self.n_agents + B, Hm1, dim = x_dofs.shape + G = B // n + + # Full trajectory incl. the pinned start -- the same object the final + # metrics see (all_paths_array starts at the start state). + pos_init = project_params['pos_init'][..., :dim].to( + device=x_dofs.device, dtype=x_dofs.dtype) + full_norm = torch.cat([pos_init[:, None, :], x_dofs.detach()], dim=1) + H = full_norm.shape[1] + + # Raw projector output, kept verbatim for offline post-processing. + if self.traj_dir is not None: + self._traj.append(full_norm.cpu().numpy().astype(np.float32)) + + # ---- velocity: inference_pcdiff.py:263 + analyze_velocity ---------- + # calc_velocity on NORMALIZED positions, ||dx||/dt vs vel_max + VEL_TOL + pos_np = full_norm.cpu().numpy().astype(np.float64) + vel = calc_velocity(pos_np, dt=self.dt) # (B, H-1) + vel_thresh = self.vel_max + self.vel_tol + vel_mask = vel > vel_thresh + vel_scene_fail = vel_mask.reshape(n, G, H - 1).any(axis=(0, 2)) + vel = dict( + viol=int(vel_mask.sum()), checked=int(B * (H - 1)), + max_pen=float(max(float(vel.max()) - vel_thresh, 0.0)), + scenes_fail=int(vel_scene_fail.sum())) + + # ---- world frame for the two collision predicates ------------------ + full_world = self._to_world(full_norm) + world_np = full_world.cpu().numpy().astype(np.float64) + + # ---- inter-robot: inference_pcdiff.py:206 ------------------------- + # collision_detect(pos, safe_dist=2r, norm_order=2), strict '<' + xs = world_np.reshape(n, G, H, dim).transpose(1, 0, 2, 3) # (G,n,H,dim) + rob_mask = collision_detect( + xs, safe_dist=2.0 * self.robot_radius, norm_order=2) # (G,n,n,H) + rob_scene_fail = rob_mask.any(axis=(1, 2, 3)) # (G,) + n_pairs = n * (n - 1) // 2 + # max penetration (diagnostic only; not part of the predicate) + d = np.linalg.norm(xs[:, :, None] - xs[:, None, :], axis=-1) # (G,n,n,H) + iu = np.triu_indices(n, k=1) + rob_max_pen = (float((2.0 * self.robot_radius - d[:, iu[0], iu[1]]).max()) + if n_pairs else 0.0) + rob = dict( + viol=int(rob_mask.sum()) // 2, # pairs counted twice + checked=int(G * n_pairs * H), + max_pen=max(rob_max_pen, 0.0), + scenes_fail=int(rob_scene_fail.sum())) + + # ---- obstacle: inference_pcdiff.py:231 ---------------------------- + # task.get_trajs_collision_and_free -> compute_collision(margin=radius) + trajs = full_world.to(**self.task.tensor_args) + _, _, _, _, wp_coll = self.task.get_trajs_collision_and_free( + trajs, return_indices=True, + num_interpolation=self.num_interpolation) + obs_mask = wp_coll.detach().cpu().numpy().astype(bool).reshape(n, G, -1) + obs_scene_fail = obs_mask.any(axis=(0, 2)) + obs = dict( + viol=int(obs_mask.sum()), checked=int(obs_mask.size), + max_pen=0.0, # occupancy is boolean; no signed depth available + scenes_fail=int(obs_scene_fail.sum())) + + any_fail = vel_scene_fail | rob_scene_fail | obs_scene_fail + self._steps.append(dict( + t=int(t_step), wall_s=float(wall_s), n_scenes=int(G), + obs=obs, rob=rob, vel=vel, + scenes_fail_any=int(any_fail.sum()), + dx_max=float(project_params['dx_max']), + overhead_s=round(time.time() - t0, 4))) + + def finalize_trial(self): + """Aggregate the accumulated steps into one JSONL line and reset.""" + if not self._steps: + return + steps = self._steps + tot_solves = sum(s['n_scenes'] for s in steps) + agg_types = {} + for k in ('obs', 'rob', 'vel'): + viol = sum(s[k]['viol'] for s in steps) + checked = sum(s[k]['checked'] for s in steps) + fails = sum(s[k]['scenes_fail'] for s in steps) + agg_types[k] = dict( + viol=viol, checked=checked, + viol_rate=(viol / checked) if checked else 0.0, + viol_per_solve=(viol / tot_solves) if tot_solves else 0.0, + fail_rate=(fails / tot_solves) if tot_solves else 0.0, + max_pen=max(s[k]['max_pen'] for s in steps)) + walls = [s['wall_s'] for s in steps] + line = dict( + trial=self._trial, + n_steps=len(steps), + n_scenes=steps[0]['n_scenes'], + n_agents=self.n_agents, + metric_defs='final', # provenance: matches inference_pcdiff + robot_radius_world=self.robot_radius, + vel_max=self.vel_max, + vel_tol=self.vel_tol, + dt=self.dt, + num_interpolation=self.num_interpolation, + solver_fail_rate=(sum(s['scenes_fail_any'] for s in steps) + / tot_solves) if tot_solves else 0.0, + proj_wall_s_total=float(np.sum(walls)), + proj_wall_s_per_step=float(np.mean(walls)), + agg=agg_types, + steps=steps, + ts=time.time(), + ) + with open(self.out_path, 'a') as f: + f.write(json.dumps(line) + '\n') + + # Dump this trial's raw projector outputs. Written per trial (not at + # the end of the run) so a cancelled job keeps what it finished. + if self.traj_dir is not None and self._traj: + stem = os.path.splitext(os.path.basename(self.out_path))[0] + np.savez_compressed( + os.path.join(self.traj_dir, + f'{stem}_trial{self._trial:03d}.npz'), + x_norm=np.stack(self._traj), # (n_steps, B, H, 2) + t_steps=np.array([s['t'] for s in steps], dtype=np.int32), + wall_s=np.array(walls, dtype=np.float64), + n_agents=self.n_agents, + n_scenes=steps[0]['n_scenes'], + pos_mins=self._pos_mins.numpy(), + pos_range=self._pos_range.numpy(), + robot_radius_world=self.robot_radius, + vel_max=self.vel_max, vel_tol=self.vel_tol, dt=self.dt, + num_interpolation=self.num_interpolation, + trial=self._trial, + ) + self._traj = [] + + self._steps = [] + self._trial += 1 + logger.info( + f'ProjectionMetrics trial {line["trial"]}: ' + f'fail_rate={line["solver_fail_rate"]:.3f}, ' + f'viol/solve obs={agg_types["obs"]["viol_per_solve"]:.2f} ' + f'rob={agg_types["rob"]["viol_per_solve"]:.2f} ' + f'vel={agg_types["vel"]["viol_per_solve"]:.2f}, ' + f'proj {line["proj_wall_s_per_step"]:.2f}s/step') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/single_agent_planner_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/single_agent_planner_base.py new file mode 100644 index 0000000000000000000000000000000000000000..0b77d930ce92f9720b90cfa6742a6ba6dbbe6bb1 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/planners/single_agent/single_agent_planner_base.py @@ -0,0 +1,34 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from abc import ABC, abstractmethod +import torch + +# MMD imports. +from mmd.planners.single_agent.common import PlannerOutput + + +class SingleAgentPlanner(ABC): + @abstractmethod + def __call__(self, start: torch.Tensor, goal: torch.Tensor, constraint_l=None, *args, **kwargs) -> PlannerOutput: + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/plotting/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/plotting/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/plotting/base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/plotting/base.py new file mode 100644 index 0000000000000000000000000000000000000000..f64387c49b8fd714759e56dbf148445e0632ca14 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/plotting/base.py @@ -0,0 +1,142 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import matplotlib as mpl +import matplotlib.pyplot as plt +import matplotlib.transforms as transforms +import numpy as np +import os +import scipy.stats +from matplotlib.patches import Ellipse + + +def save_fig(fig, name, dir=f"./{os.path.basename(__file__).split('.py')[0]}"): + fig.tight_layout() + os.makedirs(dir, exist_ok=True) + fig.savefig(f"{dir}/{name}.png") + fig.savefig(f"{dir}/{name}.pdf") + + +def export_legend(ax, filename="legend.pdf", plot_dir='', ncol=10, linewidth=7): + fig2 = plt.figure() + ax2 = fig2.add_subplot() + ax2.axis('off') + legend = ax2.legend(*ax.get_legend_handles_labels(), frameon=False, loc='lower center', ncol=ncol) + for legobj in legend.legendHandles: + legobj.set_linewidth(linewidth) + fig1 = legend.figure + fig1.canvas.draw() + bbox = legend.get_window_extent().transformed(fig1.dpi_scale_trans.inverted()) + fig1.savefig(os.path.join(plot_dir, filename), dpi="figure", bbox_inches=bbox) +def export_legendv2(plot_options_d, filename="legend.pdf", plot_dir='', ncol=10, linewidth=7, ): + fig2 = plt.figure() + ax2 = fig2.add_subplot() + for k, v in plot_options_d.items(): + v['linewidth'] = linewidth + ax2.plot([], [], label=k, **v) + ax2.axis('off') + legend = ax2.legend(frameon=False, loc='lower center', ncol=ncol) + #for legobj in legend.legendHandles: + # legobj.set_linewidth(linewidth) + fig1 = legend.figure + fig1.canvas.draw() + bbox = legend.get_window_extent().transformed(fig1.dpi_scale_trans.inverted()) + fig1.savefig(os.path.join(plot_dir, filename), dpi="figure", bbox_inches=bbox) + plt.close(fig1) + plt.close(fig2) + + +def set_small_ticks(ax, fontsize=6, set_minor_ticks=False): + if set_minor_ticks: + ax.tick_params(which='minor', grid_linestyle='--') + else: + ax.get_yaxis().set_tick_params(which='minor', size=0) + ax.get_yaxis().set_tick_params(which='minor', width=0) + + for tick in ax.xaxis.get_major_ticks(): + tick.label.set_fontsize(fontsize) + + for tick in ax.xaxis.get_minor_ticks(): + tick.label.set_fontsize(fontsize) + + for tick in ax.yaxis.get_major_ticks(): + tick.label.set_fontsize(fontsize) + + for tick in ax.yaxis.get_minor_ticks(): + tick.label.set_fontsize(fontsize) + + +def remove_borders(ax): + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['bottom'].set_visible(False) + ax.spines['left'].set_visible(False) + + +def remove_axes_labels_ticks(ax): + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_xlabel('') + ax.set_ylabel('') + + +def confidence_ellipse(x, y, ax, n_std=3.0, facecolor='none', **kwargs): + """ + Create a plot of the covariance confidence ellipse of *x* and *y*. + + Parameters + ---------- + x, y : array-like, shape (n, ) + Input data. + + ax : matplotlib.axes.Axes + The axes object to draw the ellipse into. + + n_std : float + The number of standard deviations to determine the ellipse's radiuses. + + **kwargs + Forwarded to `~matplotlib.patches.Ellipse` + + Returns + ------- + matplotlib.patches.Ellipse + """ + if x.size != y.size: + raise ValueError("x and y must be the same size") + + cov = np.cov(x, y) + pearson = cov[0, 1] / np.sqrt(cov[0, 0] * cov[1, 1]) + # Using a special case to obtain the eigenvalues of this + # two-dimensionl dataset. + ell_radius_x = np.sqrt(1 + pearson) + ell_radius_y = np.sqrt(1 - pearson) + ellipse = Ellipse((0, 0), width=ell_radius_x * 2, height=ell_radius_y * 2, + facecolor=facecolor, **kwargs) + + # Calculating the stdandard deviation of x from + # the squareroot of the variance and multiplying + # with the given number of standard deviations. + scale_x = np.sqrt(cov[0, 0]) * n_std + mean_x = np.mean(x) + + # calculating the stdandard deviation of y ... + scale_y = np.sqrt(cov[1, 1]) * n_std + mean_y = np.mean(y) + + transf = transforms.Affine2D() \ + .rotate_deg(45) \ + .scale(scale_x, scale_y) \ + .translate(mean_x, mean_y) + + ellipse.set_transform(transf + ax.transData) + return ax.add_patch(ellipse) + + +def mean_confidence_interval(data, confidence=0.95, axis=0): + n = data.shape[axis] + m, se = np.mean(data, axis=axis), scipy.stats.sem(data, axis=axis) + h = se * scipy.stats.t.ppf((1 + confidence) / 2., n - 1) + return m, m - h, m + h + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..aff1c942b2871976a8061d13c1b1d804498c27bf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/__init__.py @@ -0,0 +1 @@ +from .summary_trajectory_generation import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_base.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_base.py new file mode 100644 index 0000000000000000000000000000000000000000..94253082f2b8c72b54953877f08535f78d48d144 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_base.py @@ -0,0 +1,14 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import abc + + +class SummaryBase: + + def __init__(self, **kwargs): + pass + + @abc.abstractmethod + def summary_fn(self, *args, **kwargs): + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_trajectory_generation.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_trajectory_generation.py new file mode 100644 index 0000000000000000000000000000000000000000..b3b82ff80d9fe900999349867540b471caca3c8b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/summaries/summary_trajectory_generation.py @@ -0,0 +1,100 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import einops +import matplotlib.pyplot as plt +import numpy as np +import torch +import wandb + +from mmd.models import build_context +from mmd.summaries.summary_base import SummaryBase + + +class SummaryTrajectoryGeneration(SummaryBase): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def summary_fn(self, train_step=None, model=None, datasubset=None, prefix='', debug=False, **kwargs): + + dataset = datasubset.dataset + + # ------------------------------------------------------------------------------------ + # Get a random task from the dataset + trajectory_id = np.random.choice(datasubset.indices) + task_id = dataset.map_trajectory_id_to_task_id[trajectory_id] + + data_normalized = dataset[trajectory_id] + context = build_context(model, dataset, data_normalized) + + # ------------------------------------------------------------------------------------ + # Sample trajectories with the diffusion/cvae model + n_samples = 25 + horizon = dataset.n_support_points + hard_conds = data_normalized['hard_conds'] + + trajs_normalized = model.run_inference( + context, hard_conds, + n_samples=n_samples, horizon=horizon, + deterministic_steps=0 + ) + + # unnormalize trajectory samples from the diffusion model + trajs = dataset.unnormalize(trajs_normalized, dataset.field_key_traj) + + # ------------------------------------------------------------------------------------ + # STATISTICS + wandb.log({f'{prefix}percentage free trajs': dataset.task.compute_fraction_free_trajs(trajs)}, step=train_step) + wandb.log({f'{prefix}percentage collision intensity': dataset.task.compute_collision_intensity_trajs(trajs)}, step=train_step) + wandb.log({f'{prefix}success': dataset.task.compute_success_free_trajs(trajs)}, step=train_step) + + # ------------------------------------------------------------------------------------ + # Render + + # dataset trajectory + fig_joint_trajs_dataset, _, fig_robot_trajs_dataset, _ = dataset.render( + task_id=task_id, + render_joint_trajectories=True + ) + + # diffusion trajectory + pos_trajs = dataset.robot.get_position(trajs) + start_state_pos = pos_trajs[0][0] + goal_state_pos = pos_trajs[0][-1] + + fig_joint_trajs_diffusion, _ = dataset.planner_visualizer.plot_joint_space_state_trajectories( + trajs=pos_trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), + linestyle='dashed' + ) + + fig_robot_trajs_diffusion = None + # fig_robot_trajs_diffusion, _ = dataset.planner_visualizer.render_robot_trajectories( + # trajs=pos_trajs, start_state=start_state_pos, goal_state=goal_state_pos, + # linestyle='dashed' + # ) + + if fig_joint_trajs_dataset is not None: + wandb.log({f"{prefix}joint trajectories DATASET": wandb.Image(fig_joint_trajs_dataset)}, step=train_step) + if fig_robot_trajs_dataset is not None: + wandb.log({f"{prefix}robot trajectories DATASET": wandb.Image(fig_robot_trajs_dataset)}, step=train_step) + + if fig_joint_trajs_diffusion is not None: + wandb.log({f"{prefix}joint trajectories DIFFUSION": wandb.Image(fig_joint_trajs_diffusion)}, step=train_step) + if fig_robot_trajs_diffusion is not None: + wandb.log({f"{prefix}robot trajectories DIFFUSION": wandb.Image(fig_robot_trajs_diffusion)}, step=train_step) + + if debug: + plt.show() + + if fig_joint_trajs_dataset is not None: + plt.close(fig_joint_trajs_dataset) + if fig_robot_trajs_dataset is not None: + plt.close(fig_robot_trajs_dataset) + if fig_joint_trajs_diffusion is not None: + plt.close(fig_joint_trajs_diffusion) + if fig_robot_trajs_diffusion is not None: + plt.close(fig_robot_trajs_diffusion) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..48e691ddcb15111972681056f42b51781f1e0017 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/__init__.py @@ -0,0 +1,2 @@ +from .train_loaders import * +from .trainer import train diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/train_loaders.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/train_loaders.py new file mode 100644 index 0000000000000000000000000000000000000000..427d1c2f46f9c9cee32b8291e8cdb9aedbd18daf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/train_loaders.py @@ -0,0 +1,90 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import os + +import torch +from torch.utils.data import DataLoader, random_split + +from mmd import models, losses, datasets, summaries +from mmd.utils import model_loader, pretrain_helper +from torch_robotics.torch_utils.torch_utils import freeze_torch_model_params + + +@model_loader +def get_model(model_class=None, checkpoint_path=None, + freeze_loaded_model=False, + tensor_args=None, + **kwargs): + + if checkpoint_path is not None: + model = torch.load(checkpoint_path) + if freeze_loaded_model: + freeze_torch_model_params(model) + else: + ModelClass = getattr(models, model_class) + model = ModelClass(**kwargs).to(tensor_args['device']) + + return model + +@pretrain_helper +def get_pretrain_model(model_class=None, device=None, checkpoint_path=None, **kwargs): + Model = getattr(models, model_class) + model = Model(**kwargs).to(device) + + if checkpoint_path is not None: + model.load_state_dict(torch.load(checkpoint_path)) + + return model + + +def build_module(model_class=None, submodules=None, **kwargs): + if submodules is not None: + for key, value in submodules.items(): + kwargs[key] = build_module(**value) + + Model = getattr(models, model_class) + model = Model(**kwargs) + + return model + + +def get_loss(loss_class=None, **kwargs): + LossClass = getattr(losses, loss_class) + loss = LossClass(**kwargs) + loss_fn = loss.loss_fn + return loss_fn + + +def get_dataset(dataset_class=None, + dataset_subdir=None, + batch_size=2, + val_set_size=0.05, + results_dir=None, + save_indices=False, + **kwargs): + dataset_class = getattr(datasets, dataset_class) + print('\n---------------Loading data') + full_dataset = dataset_class(dataset_subdir=dataset_subdir, **kwargs) + print(full_dataset) + + # split into train and validation + train_subset, val_subset = random_split(full_dataset, [1-val_set_size, val_set_size]) + train_dataloader = DataLoader(train_subset, batch_size=batch_size) + val_dataloader = DataLoader(val_subset, batch_size=batch_size) + + if save_indices: + # save the indices of training and validation sets (for later evaluation) + torch.save(train_subset.indices, os.path.join(results_dir, f'train_subset_indices.pt')) + torch.save(val_subset.indices, os.path.join(results_dir, f'val_subset_indices.pt')) + + return train_subset, train_dataloader, val_subset, val_dataloader + + +def get_summary(summary_class=None, **kwargs): + if summary_class is None: + return None + SummaryClass = getattr(summaries, summary_class) + summary_fn = SummaryClass(**kwargs).summary_fn + return summary_fn + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/trainer.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..8e4520fc24368c6492b3b0a5a8570e78e58cadda --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/trainer/trainer.py @@ -0,0 +1,337 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import copy +from math import ceil + +import numpy as np +import os +import time +import torch +import wandb +from collections import defaultdict +from tqdm.autonotebook import tqdm + +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import dict_to_device, DEFAULT_TENSOR_ARGS, to_numpy + + +def get_num_epochs(num_train_steps, batch_size, dataset_len): + return ceil(num_train_steps * batch_size / dataset_len) + + +def save_models_to_disk(models_prefix_l, epoch, total_steps, checkpoints_dir=None): + for model, prefix in models_prefix_l: + if model is not None: + save_model_to_disk(model, epoch, total_steps, checkpoints_dir, prefix=f'{prefix}_') + for submodule_key, submodule_value in model.submodules.items(): + save_model_to_disk(submodule_value, epoch, total_steps, checkpoints_dir, + prefix=f'{prefix}_{submodule_key}_') + + +def save_model_to_disk(model, epoch, total_steps, checkpoints_dir=None, prefix='model_'): + # If the model is frozen we do not save it again, since the parameters did not change + if hasattr(model, 'is_frozen') and model.is_frozen: + return + + torch.save(model.state_dict(), os.path.join(checkpoints_dir, f'{prefix}current_state_dict.pth')) + torch.save(model.state_dict(), os.path.join(checkpoints_dir, f'{prefix}epoch_{epoch:04d}_iter_{total_steps:06d}_state_dict.pth')) + torch.save(model, os.path.join(checkpoints_dir, f'{prefix}current.pth')) + torch.save(model, os.path.join(checkpoints_dir, f'{prefix}epoch_{epoch:04d}_iter_{total_steps:06d}.pth')) + + +def save_losses_to_disk(train_losses, val_losses, checkpoints_dir=None): + np.save(os.path.join(checkpoints_dir, f'train_losses.npy'), train_losses) + np.save(os.path.join(checkpoints_dir, f'val_losses.npy'), val_losses) + + +class EarlyStopper: + # https://stackoverflow.com/questions/71998978/early-stopping-in-pytorch + + def __init__(self, patience=10, min_delta=0): + self.patience = patience # use -1 to deactivate it + self.min_delta = min_delta + self.counter = 0 + self.min_validation_loss = torch.inf + + def early_stop(self, validation_loss): + if self.patience == -1: + return + if validation_loss < self.min_validation_loss: + self.min_validation_loss = validation_loss + self.counter = 0 + elif validation_loss > (self.min_validation_loss + self.min_delta): + self.counter += 1 + if self.counter >= self.patience: + return True + return False + + +class EMA: + """ + https://github.com/jannerm/diffuser + (empirical) exponential moving average parameters + """ + + def __init__(self, beta=0.995): + super().__init__() + self.beta = beta + + def update_model_average(self, ema_model, current_model): + for ema_params, current_params in zip(ema_model.parameters(), current_model.parameters()): + old_weight, up_weight = ema_params.data, current_params.data + ema_params.data = self.update_average(old_weight, up_weight) + + def update_average(self, old, new): + if old is None: + return new + return old * self.beta + (1 - self.beta) * new + + +def do_summary( + summary_fn, + train_steps_current, + model, + batch_dict, + loss_info, + datasubset, + **kwargs +): + if summary_fn is None: + return + + with torch.no_grad(): + # set model to evaluation mode + model.eval() + + summary_fn(train_steps_current, + model, + batch_dict=batch_dict, + loss_info=loss_info, + datasubset=datasubset, + **kwargs + ) + + # set model to training mode + model.train() + + +def train(model=None, train_dataloader=None, epochs=None, lr=None, steps_til_summary=None, model_dir=None, loss_fn=None, + train_subset=None, + summary_fn=None, steps_til_checkpoint=None, + val_dataloader=None, val_subset=None, + clip_grad=False, + clip_grad_max_norm=1.0, + val_loss_fn=None, + optimizers=None, steps_per_validation=10, max_steps=None, + use_ema: bool = True, + ema_decay: float = 0.995, step_start_ema: int = 1000, update_ema_every: int = 10, + use_amp=False, + early_stopper_patience=-1, + debug=False, + tensor_args=DEFAULT_TENSOR_ARGS, + **kwargs + ): + + print(f'\n------- TRAINING STARTED -------\n') + + ema_model = None + if use_ema: + # Exponential moving average model + ema = EMA(beta=ema_decay) + ema_model = copy.deepcopy(model) + + # Model optimizers + if optimizers is None: + optimizers = [torch.optim.Adam(lr=lr, params=model.parameters())] + + # Automatic Mixed Precision + scaler = torch.cuda.amp.GradScaler(enabled=use_amp) + + if val_dataloader is not None: + assert val_loss_fn is not None, "If validation set is passed, have to pass a validation loss_fn!" + + ## Build saving directories + os.makedirs(model_dir, exist_ok=True) + + summaries_dir = os.path.join(model_dir, 'summaries') + os.makedirs(summaries_dir, exist_ok=True) + + checkpoints_dir = os.path.join(model_dir, 'checkpoints') + os.makedirs(checkpoints_dir, exist_ok=True) + + # Early stopping + early_stopper = EarlyStopper(patience=early_stopper_patience, min_delta=0) + + stop_training = False + train_steps_current = 0 + + # save models before training + save_models_to_disk([(model, 'model'), (ema_model, 'ema_model')], 0, 0, checkpoints_dir) + + with tqdm(total=len(train_dataloader) * epochs, mininterval=1 if debug else 60) as pbar: + train_losses_l = [] + validation_losses_l = [] + for epoch in range(epochs): + model.train() # set model to training mode + for step, train_batch_dict in enumerate(train_dataloader): + #################################################################################################### + # TRAINING LOSS + #################################################################################################### + with TimerCUDA() as t_training_loss: + train_batch_dict = dict_to_device(train_batch_dict, tensor_args['device']) + + # Compute losses + with torch.autocast(device_type='cuda', dtype=torch.float16, enabled=use_amp): + train_losses, train_losses_info = loss_fn(model, train_batch_dict, train_subset.dataset) + + train_loss_batch = 0. + train_losses_log = {} + for loss_name, loss in train_losses.items(): + single_loss = loss.mean() + train_loss_batch += single_loss + train_losses_log[loss_name] = to_numpy(single_loss).item() + + #################################################################################################### + # SUMMARY + if train_steps_current % steps_til_summary == 0: + # TRAINING + print(f"\n-----------------------------------------") + print(f"train_steps_current: {train_steps_current}") + print(f"t_training_loss: {t_training_loss.elapsed:.4f} sec") + print(f"Total training loss {train_loss_batch:.4f}") + print(f"Training losses {train_losses}") + + train_losses_l.append((train_steps_current, train_losses_log)) + + with TimerCUDA() as t_training_summary: + do_summary( + summary_fn, + train_steps_current, + ema_model if ema_model is not None else model, + train_batch_dict, + train_losses_info, + train_subset, + prefix='TRAINING ', + debug=debug, + tensor_args=tensor_args + ) + print(f"t_training_summary: {t_training_summary.elapsed:.4f} sec") + + ################################################################################################ + # VALIDATION LOSS and SUMMARY + validation_losses_log = {} + if val_dataloader is not None: + with TimerCUDA() as t_validation_loss: + print("Running validation...") + val_losses = defaultdict(list) + total_val_loss = 0. + for step_val, batch_dict_val in enumerate(val_dataloader): + batch_dict_val = dict_to_device(batch_dict_val, tensor_args['device']) + val_loss, val_loss_info = loss_fn( + model, batch_dict_val, val_subset.dataset, step=train_steps_current) + for name, value in val_loss.items(): + single_loss = to_numpy(value) + val_losses[name].append(single_loss) + total_val_loss += np.mean(single_loss).item() + + if step_val == steps_per_validation: + break + + validation_losses = {} + for loss_name, loss in val_losses.items(): + single_loss = np.mean(loss).item() + validation_losses[f'VALIDATION {loss_name}'] = single_loss + print("... finished validation.") + + print(f"t_validation_loss: {t_validation_loss.elapsed:.4f} sec") + print(f"Validation losses {validation_losses}") + + validation_losses_log = validation_losses + validation_losses_l.append((train_steps_current, validation_losses_log)) + + # The validation summary is done only on one batch of the validation data + with TimerCUDA() as t_validation_summary: + do_summary( + summary_fn, + train_steps_current, + ema_model if ema_model is not None else model, + batch_dict_val, + val_loss_info, + val_subset, + prefix='VALIDATION ', + debug=debug, + tensor_args=tensor_args + ) + print(f"t_valididation_summary: {t_validation_summary.elapsed:.4f} sec") + + wandb.log({**train_losses_log, **validation_losses_log}, step=train_steps_current) + + #################################################################################################### + # Early stopping + if early_stopper.early_stop(total_val_loss): + print(f'Early stopped training at {train_steps_current} steps.') + stop_training = True + + #################################################################################################### + # OPTIMIZE TRAIN LOSS BATCH + with TimerCUDA() as t_training_optimization: + for optim in optimizers: + optim.zero_grad() + + scaler.scale(train_loss_batch).backward() + + if clip_grad: + for optim in optimizers: + scaler.unscale_(optim) + torch.nn.utils.clip_grad_norm_( + model.parameters(), + max_norm=clip_grad_max_norm if isinstance(clip_grad, bool) else clip_grad + ) + + for optim in optimizers: + scaler.step(optim) + + scaler.update() + + if ema_model is not None: + if train_steps_current % update_ema_every == 0: + if train_steps_current < step_start_ema: + # reset parameters ema + ema_model.load_state_dict(model.state_dict()) + ema.update_model_average(ema_model, model) + + if train_steps_current % steps_til_summary == 0: + print(f"t_training_optimization: {t_training_optimization.elapsed:.4f} sec") + + #################################################################################################### + # SAVING + #################################################################################################### + pbar.update(1) + train_steps_current += 1 + + if (steps_til_checkpoint is not None) and (train_steps_current % steps_til_checkpoint == 0): + save_models_to_disk([(model, 'model'), (ema_model, 'ema_model')], + epoch, train_steps_current, checkpoints_dir) + save_losses_to_disk(train_losses_l, validation_losses_l, checkpoints_dir) + + if stop_training or (max_steps is not None and train_steps_current == max_steps): + break + + if max_steps is not None and train_steps_current == max_steps: + break + + # Update ema model at the end of training + if ema_model is not None: + # update ema + if train_steps_current < step_start_ema: + # reset parameters ema + ema_model.load_state_dict(model.state_dict()) + ema.update_model_average(ema_model, model) + + # Save model at end of training + save_models_to_disk([(model, 'model'), (ema_model, 'ema_model')], + epoch, train_steps_current, checkpoints_dir) + save_losses_to_disk(train_losses_l, validation_losses_l, checkpoints_dir) + + print(f'\n------- TRAINING FINISHED -------') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0b4e34966f60871a063b9c7ed36a20ac57bf4ca9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/__init__.py @@ -0,0 +1,4 @@ +from .decorators import * +from .one_hot import * +from .path_utils import * +# from .utils_3d import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/decorators.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..812cb98174ee2665e64364327503026984f4ea8d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/decorators.py @@ -0,0 +1,107 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import datetime +import os +import wandb +import yaml +from functools import wraps + + +def save_args(exp_dir, kwargs, filename='experiment_args.yml'): + filtered = {} + for key, value in kwargs.items(): + if type(value) is tuple or type(value) is int or type(value) is float or type(value) is bool or type( + value) is str or value is None: + filtered[key] = value + with open(os.path.join(exp_dir, filename), 'w') as f: + yaml.safe_dump(filtered, f) + + +def save_module_args(exp_dir, args, filename='module_args.yml'): + save_args(exp_dir, args, filename=filename) + + +def load_args(exp_dir, filename='experiment_args.yml'): + with open(os.path.join(exp_dir, filename), 'r') as f: + args = yaml.safe_load(f) + return args + + +def load_module_args(exp_dir, filename='module.yml'): + return load_args(exp_dir, filename=filename) + + +def update_args(exp_dir, partial_args): + args = load_args(exp_dir) + for key, value in partial_args.items(): + args[key] = value + save_args(exp_dir, args) + + +def evaluation(eval_func): + @wraps(eval_func) + def wrapper(**kwargs): + experiment_args = load_args(kwargs["exp_dir"]) + # Run the experiment + eval_func(experiment_args, **kwargs) + + return wrapper + + +def filter_kwargs(kwargs, blacklist=['device', 'exp_dir']): + model_kwargs = {} + for key, value in kwargs.items(): + if key not in blacklist and not key.endswith('_field'): + model_kwargs[key] = value + return model_kwargs + + +def pretrain_helper(model_load_function): + """ + Saves relevant model kwargs to a yml file (default is module.yml). + """ + + @wraps(model_load_function) + def wrapper(**kwargs): + + model_kwargs = filter_kwargs(kwargs) + + # Inject submodels if any + submodule_kwargs = None + if "submodules" in kwargs: + submodule_kwargs = {} + for module_name, submodule in kwargs["submodules"].items(): + kwargs[module_name] = submodule + submodule_kwargs[module_name] = filter_kwargs(submodule._all_kwargs) + model_kwargs['submodules'] = submodule_kwargs + + save_module_args(kwargs['exp_dir'], model_kwargs) + + # Run the experiment + model = model_load_function(**kwargs) + + setattr(model, '_all_kwargs', kwargs) + + return model + + return wrapper + + +def model_loader(model_load_function): + @wraps(model_load_function) + def wrapper(**kwargs): + # Inject submodels if any + if "submodules" in kwargs: + for module_name, submodule in kwargs["submodules"].items(): + kwargs[module_name] = submodule + + # Run the experiment + model = model_load_function(**kwargs) + + # Save submodules in a dictionary (for saving, ...) + model.submodules = kwargs["submodules"] if "submodules" in kwargs else {} + + return model + + return wrapper diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/eval_helpers.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/eval_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..b7d8a580af540eda3ef84cbe8eda34c5687504a4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/eval_helpers.py @@ -0,0 +1,594 @@ +""" +Adapted from https://github.com/jacarvalho/mpd-public +""" +import time + +import numpy as np +import torch +from einops import repeat, rearrange +from sklearn.cluster import KMeans +import math + +import os +import pandas +def plot_env_image(ax=None, env_image=None): + ax.imshow(env_image.permute(1, 2, 0), origin="lower") + + +def plot_trajs(trajs, collisions=None, task_context=None, ax=None, scale=None, color=None, best_index=None, + linewidth=6, linestyle='-', **kwargs): + if task_context is not None: + if scale is not None: + scaled_task_context = ((task_context + 1) / 2) * scale + else: + scaled_task_context = task_context + ax.scatter(scaled_task_context[0], scaled_task_context[1], color='red', zorder=20, marker='o') + ax.scatter(scaled_task_context[2], scaled_task_context[3], color='red', zorder=20, marker='x') + + if scale is not None: + trajs = ((trajs + 1) / 2) * scale # Scale to image size + + for i, traj in enumerate(trajs): + if collisions is not None: + collides = collisions[i] + else: + collides = False + + if best_index is not None: + is_best = i == best_index + kwargs['alpha'] =1 if is_best else 0.5, + else: + is_best = False + + if is_best: + kwargs.update(zorder=10) + ax.plot(traj[:, 0], traj[:, 1], + linestyle='--' if collides else linestyle, color=color, + linewidth=6 if is_best else linewidth, + **kwargs) + #ax.scatter(traj[:, 0], traj[:, 1]) + + +def plot_trajs_3d(trajs, collisions=None, task_context=None, ax=None, scale=None, color=None, best_index=None, + linewidth=6, linestyle='-', **kwargs): + + if task_context is not None: + if scale is not None: + scaled_task_context = ((task_context + 1) / 2) * scale + else: + scaled_task_context = task_context + ax.scatter(scaled_task_context[0], scaled_task_context[1], scaled_task_context[2], color='red', zorder=20, marker='o') + ax.scatter(scaled_task_context[3], scaled_task_context[4], scaled_task_context[5],color='red', zorder=20, marker='x') + + if scale is not None: + trajs = ((trajs + 1) / 2) * scale # Scale to image size + + for i, traj in enumerate(trajs): + if collisions is not None: + collides = collisions[i] + else: + collides = False + + if best_index is not None: + is_best = i == best_index + else: + is_best = False + + if is_best: + kwargs.update(zorder=10) + ax.plot(traj[:, 0], traj[:, 1],traj[:, 2], + linestyle='--' if collides else linestyle, color=color, + linewidth=6 if is_best else linewidth, + alpha=1 if is_best else 0.5, + **kwargs) + + +def sample_trajs(sampler, + model, + env_features=None, + task_context=None, + task_field=None, + device=None, + num_samples=5, + **kwargs + ): + # Trajectory + batch_size = num_samples + env_features_d_batch = {} + if env_features: + env_features_batch = repeat(env_features, '1 d -> b d', b=batch_size) + env_features_d_batch = {model.env_model.output_field: env_features_batch} + task_c_batch = repeat(task_context, 'd -> b d', b=batch_size).to(device) + samples = sampler.sample( + model, + context={**env_features_d_batch, + task_field: task_c_batch, + }, + batch_size=batch_size, + **kwargs + )[model.input_field] + + return samples + + +def get_best_index(torch_trajs=None, collisions=None): + idx_no_collision = torch.where(1 - torch.Tensor(collisions))[0] + if len(idx_no_collision) != 0: + feasible_samples = torch_trajs[idx_no_collision] + n_support_pointsgths = torch.linalg.norm(torch.diff(feasible_samples, dim=(-2)), dim=(-1)).sum(dim=-1) + + idx_best_traj = torch.argmin(n_support_pointsgths).item() + return idx_no_collision[idx_best_traj].item() + else: + idx_random = np.random.choice(np.arange(torch_trajs.shape[0])) + return idx_random + + +def get_best_index_by_sdf(torch_trajs=None, env_features=None, model=None, device=None): + # SDF + sdf_locations = rearrange(torch_trajs, 'b h d -> (b h) d') + env_features_sdf = repeat(env_features, '1 d -> b d', b=sdf_locations.shape[0]) + model_output = model.compute_sdf({ + model.sdf_model.input_field: env_features_sdf.to(device), + model.sdf_model.sdf_location_field: sdf_locations.to(device) + }) + sdfs_flat = model_output['sdf'] + + costs = torch.where(sdfs_flat < 0, sdfs_flat, torch.zeros_like(sdfs_flat)) + costs = rearrange(costs, '(b h) d -> b h d', b=torch_trajs.shape[0], h=torch_trajs.shape[1]) + + idx_feasible_samples = torch.argwhere(costs.sum(dim=(-2, -1)) == 0) + if idx_feasible_samples.nelement() != 0: + feasible_samples = torch_trajs[idx_feasible_samples] + n_support_pointsgths = torch.linalg.norm(torch.diff(feasible_samples, dim=(-2)), dim=(-1)).sum(dim=-1) + + idx_best_traj = torch.argmin(n_support_pointsgths).item() + return idx_feasible_samples[idx_best_traj].item() + else: + idx_random = np.random.choice(np.arange(torch_trajs.shape[0])) + return idx_random + + +def k_means_select_k(X: np.ndarray, k_range: np.ndarray) -> int: + # selects the number of clusters in Kmeans using the Elbow method + # https://www.codecademy.com/learn/machine-learning/modules/dspath-clustering/cheatsheet + # https://stackoverflow.com/questions/72236122/elbow-method-for-k-means-in-python + wss = np.empty(k_range.size) + for i, k in enumerate(k_range): + kmeans = KMeans(n_clusters=k, init='k-means++') + kmeans.fit(X) + wss[i] = kmeans.inertia_ + # wss[i] = ((X - kmeans.cluster_centers_[kmeans.labels_]) ** 2).sum() + + # computes the elbow of a curve + slope = (wss[0] - wss[-1]) / (k_range[0] - k_range[-1]) + intercept = wss[0] - slope * k_range[0] + y = k_range * slope + intercept + + return k_range[(y - wss).argmax()] + + +def evaluation_metrics_rrt_variable_horizons( + trajs_list, + print_info=True, + print_label='RRT_connect', +): + metrics = {} + # by design rrt doesn't have collisions + metrics['percentage_coll_free_trajs'] = 100. + metrics['percentage_in_collision'] = 0. + + traj_distances = [] + acceleration = [] + cos_sims = [] + for traj in trajs_list: + finite_diff = np.diff(traj, axis=-2) + traj_distance = np.sum(np.linalg.norm(finite_diff, axis=-1), axis=-1) + + # Remove duplicates after calculating distance. + # Otherwise mean is thrown off. This is reasonable because we could interpolate infinitely many points the get + # a perfect score + if len(finite_diff) > 1: + finite_diff = purge_duplicates_from_traj(finite_diff) + + # remove straight points + if finite_diff.shape[0] < 2: + accelerations = 0 + cos_sims.append(0) + else: + accelerations = np.linalg.norm(np.diff(finite_diff, axis=-2), axis=-1) + accelerations = accelerations.mean() + + cos_sim = np.zeros((finite_diff.shape[0] - 1)) + for i in range(finite_diff.shape[0] - 1): + x1 = finite_diff[i] + x2 = finite_diff[i + 1] + + sim = 1 - (np.dot(x1, x2) / (np.linalg.norm(x1) * np.linalg.norm(x2))) + cos_sim[i] = sim + + cos_sims.append(cos_sim.mean()) + acceleration.append(accelerations) + #if len(accelerations) > 0: + # if len(accelerations) == 1: + # smoothness_costs.append(accelerations.item()) + # else: + # smoothness_costs += list(accelerations) + traj_distances.append(traj_distance) + + #smoothness_cost = traj_distance / len(traj) + #smoothness_costs.append(smoothness_cost) + average_acceleration = np.mean(acceleration) + average_traj_distance = np.mean(traj_distances) + average_cosine_sim = np.mean(cos_sims) + metrics['average_acceleration'] = average_acceleration + metrics['average_distance'] = average_traj_distance + metrics['average_cosine_sim'] = average_cosine_sim + + return metrics + + +def evaluation_metrics( + trajs_torch, + trajs_coll_free_torch, + print_info=True, + print_label='StochGPMP', + simple_metrics=False +): + trajs_np = to_numpy(trajs_torch) + trajs_coll_free_np = to_numpy(trajs_coll_free_torch) + + metrics = {} + + B, H, D = trajs_torch.shape + B_coll_free, _, _ = trajs_coll_free_torch.shape + + # 3. Number of collision free trajectories + percentage_coll_free_trajs = B_coll_free / B * 100 + metrics['percentage_coll_free_trajs'] = percentage_coll_free_trajs + + # 4. Average smoothness + distance = torch.linalg.norm(torch.diff(trajs_torch, dim=-2), dim=-1) + smoothness_cost = distance.sum(dim=-1) + smoothness_cost *= 1 / H + average_acceleration = smoothness_cost.mean() + average_distance = distance.sum(dim=-1).mean() + + finite_diff = torch.diff(trajs_torch, dim=-2) + accelerations = torch.linalg.norm(torch.diff(torch.diff(trajs_torch, dim=-2), dim=-2), dim=-1) + #smoothness_costs.append(accelerations) + + cos_sim = np.zeros((finite_diff.shape[0], finite_diff.shape[1]-1)) + for b, batch in enumerate(finite_diff): + for i in range(batch.shape[0]-1): + x1 = finite_diff[b, i].unsqueeze(0) + x2 = finite_diff[b, i+1].unsqueeze(0) + sim = 1 - to_numpy(torch.nn.functional.cosine_similarity(x1, x2)) + cos_sim[b][i] = sim + + metrics['average_acceleration'] = to_numpy(accelerations) + metrics['average_cosine_sim'] = cos_sim.mean() + + metrics['average_distance'] = to_numpy(average_distance) + + if not simple_metrics: + # 1. Mode discovery - clustering (flatten the trajectories) + k_opt_trajs = k_means_select_k(trajs_np.reshape(B, H * D), np.arange(1, np.min((21, B)))) + metrics['k_opt_trajs'] = k_opt_trajs + metrics['k_opt_trajs_coll_free'] = 0 + if B_coll_free == 1: + metrics['k_opt_trajs_coll_free'] = 1 + if B_coll_free > 1: + k_opt_trajs_coll_free = k_means_select_k(trajs_coll_free_np.reshape(B_coll_free, H * D), + np.arange(1, np.min((21, B_coll_free)))) + metrics['k_opt_trajs_coll_free'] = k_opt_trajs_coll_free + + # 2. Spatial coverage - variance for each time step + # https://online.stat.psu.edu/stat505/lesson/1/1.5 + for trajs, trajs_label in zip([trajs_torch, trajs_coll_free_torch], ['trajs', 'trajs_coll_free']): + spatial_coverage_trace_average = 0 + spatial_coverage_determinant_average = 0 + + if trajs.shape[0] > 0: + covar_trajs = batch_cov(rearrange(trajs, ' b h d -> h b d')) + spatial_coverage_trace_all_steps = batch_trace(covar_trajs) + spatial_coverage_trace_average = torch.mean(spatial_coverage_trace_all_steps) + spatial_coverage_determinant_all_steps = torch.det(covar_trajs) + spatial_coverage_determinant_average = torch.mean(spatial_coverage_determinant_all_steps) + + metrics[f'spatial_coverage_trace_average_{trajs_label}'] = spatial_coverage_trace_average + metrics[f'spatial_coverage_determinant_average_{trajs_label}'] = spatial_coverage_determinant_average + + if print_info: + print(f'---- Trajectories ALL----') + print(f'{print_label} number of mean_trajs: {B}') + print(f'{print_label} number of coll_free_trajs: {B_coll_free}') + print(f'{print_label} percentage coll_free: {percentage_coll_free_trajs:.2f}') + print(f'{print_label} average smoothness cost: {average_acceleration:.2f}') + if not simple_metrics: + + print() + print(f'{print_label} k_opt_trajs: {metrics["k_opt_trajs"]}') + print( + f'{print_label} spatial_coverage_trace_average_trajs: {metrics["spatial_coverage_trace_average_trajs"]:.5f}') + print( + f'{print_label} spatial_coverage_determinant_average_trajs: {metrics["spatial_coverage_determinant_average_trajs"]:.5f}') + print(f'----Trajectories Collision Free----') + print(f'{print_label} k_opt_trajs_coll_free: {metrics["k_opt_trajs_coll_free"]}') + print( + f'{print_label} spatial_coverage_trace_average_trajs_coll_free: {metrics["spatial_coverage_trace_average_trajs_coll_free"]:.5f}') + print( + f'{print_label} spatial_coverage_determinant_average_trajs: {metrics["spatial_coverage_determinant_average_trajs_coll_free"]:.5f}') + + return metrics + + +def eval_sbm(model, sampler=None, extra_objective_fn_grad=None, extra_objective_fn_grad_cascading=None, + task_context=None, task_field=None, num_samples=None, device=None, obst_map=None, debug=False, + print_label='SBM', env=None, use_env_collision=False, + ): + # Sample trajectories + s = time.time() + sbm_trajs_torch = sample_trajs( + sampler, + model, + task_context=task_context, + task_field=task_field, + num_samples=num_samples, + device=device, + extra_objective_fn_grad_cascading=extra_objective_fn_grad_cascading, + extra_objective_fn_grad=extra_objective_fn_grad, + ) + sbm_sampling_time = time.time() - s + if use_env_collision: + + sbm_coll_free_trajs_torch, percent_in_coll = compute_coll_free_trajs_env(sbm_trajs_torch, env, return_coll_stat=True) + else: + sbm_coll_free_trajs_torch, percent_in_coll = compute_coll_free_trajs_obst_map(sbm_trajs_torch, obst_map, + return_coll_stat=True) + + + sbm_metrics = dict() + sbm_metrics['sampling_time'] = sbm_sampling_time + if debug: + print(f'{print_label} sampling time: {sbm_sampling_time:.3f}') + sbm_metrics['percentage_in_collision'] = percent_in_coll.item() + eval_metrics = evaluation_metrics(sbm_trajs_torch, sbm_coll_free_trajs_torch, print_info=debug, + print_label=print_label, + simple_metrics=True) + sbm_metrics.update(**eval_metrics) + return sbm_metrics, sbm_trajs_torch, sbm_coll_free_trajs_torch + + +def eval_2D_stoch_gpmp(partial_stochgpmp_params, start_state, goal_state, tensor_args=None, initial_particle_means=None, + obst_map=None, print_label='StochGPMP', debug=False, env=None, use_env_collision=False): + stochgpmp_params = { + **partial_stochgpmp_params, + "start_state": torch.cat((start_state, torch.zeros(start_state.shape[0], **tensor_args))), + "multi_goal_states": torch.cat((goal_state, torch.zeros(goal_state.shape[0], **tensor_args))).unsqueeze(0) + } + # Works so don't use new for 2D + stochgpmp_coll_free_trajs_torch, stochgpmp_mean_trajs_torch, _, stochgpmp_metrics = generate_stochgpmp_trajs( + stochgpmp_params, obst_map, initial_particle_means=initial_particle_means, get_statistics=True, env=env, + use_env_collision=use_env_collision, use_legacy=True) + + eval_metrics = evaluation_metrics(stochgpmp_mean_trajs_torch, + stochgpmp_coll_free_trajs_torch, print_info=debug, + print_label=print_label, simple_metrics=True) + stochgpmp_metrics.update(**eval_metrics) + # TODO debug print + return stochgpmp_metrics, stochgpmp_mean_trajs_torch, stochgpmp_coll_free_trajs_torch + + +def eval_3D_stoch_gpmp(partial_stochgpmp_params, start_state, goal_state, tensor_args=None, initial_particle_means=None, + obst_map=None, print_label='StochGPMP', debug=False, env=None, use_env_collision=False): + start_state = torch.cat((start_state, torch.zeros(start_state.shape[0], **tensor_args))) + multi_goal_state = torch.cat((goal_state, torch.zeros(goal_state.shape[0], **tensor_args))).unsqueeze(0) + stochgpmp_params = { + **partial_stochgpmp_params, + "start_state": start_state, + "multi_goal_states": multi_goal_state, + "tensor_args": tensor_args, + } + + n_dof = stochgpmp_params['n_dof'] + n_support_points = stochgpmp_params['n_support_points'] + dt = stochgpmp_params['dt'] + multi_goal_states = stochgpmp_params['multi_goal_states'] + num_particles_per_goal = stochgpmp_params['num_particles_per_goal'] + num_samples = stochgpmp_params['num_samples'] + sigma_start = stochgpmp_params.pop('sigma_start') + sigma_gp = stochgpmp_params.pop('sigma_gp') + sigma_goal = stochgpmp_params.pop('sigma_goal') + sigma_obst = stochgpmp_params.pop('sigma_obst') + + cost_sigmas = dict( + sigma_start=sigma_start, + sigma_gp=sigma_gp, + ) + sigma_coll = sigma_obst + sigma_goal_prior = sigma_goal + + # Construct cost function + cost_prior = CostGP( + n_dof, n_support_points, start_state, dt, + cost_sigmas, tensor_args + ) + cost_goal_prior = CostGoalPrior(n_dof, n_support_points, multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + sigma_goal_prior=sigma_goal_prior, + tensor_args=tensor_args) + cost_obst_2D = CostCollision(n_dof, n_support_points, field=obst_map, sigma_coll=sigma_coll) + cost_func_list = [cost_prior, cost_goal_prior, cost_obst_2D] + cost_composite = CostComposite(n_dof, n_support_points, cost_func_list) + + stochgpmp_params['cost'] = cost_composite + + stochgpmp_coll_free_trajs_torch, stochgpmp_mean_trajs_torch, _, stochgpmp_metrics = generate_stochgpmp_trajs( + stochgpmp_params, obst_map, initial_particle_means=initial_particle_means, get_statistics=True, env=env, + use_env_collision=use_env_collision) + + eval_metrics = evaluation_metrics(stochgpmp_mean_trajs_torch, + stochgpmp_coll_free_trajs_torch, print_info=debug, + print_label=print_label, simple_metrics=True) + stochgpmp_metrics.update(**eval_metrics) + # TODO debug print + return stochgpmp_metrics, stochgpmp_mean_trajs_torch, stochgpmp_coll_free_trajs_torch + + +def eval_panda_stoch_gpmp(partial_stochgpmp_params, start_state, goal_state, tensor_args=None, initial_particle_means=None, + print_label='StochGPMP', debug=False, env=None): + start_state = torch.cat((start_state, torch.zeros(start_state.shape[0], **tensor_args))) + multi_goal_state = torch.cat((goal_state, torch.zeros(goal_state.shape[0], **tensor_args))).unsqueeze(0) + stochgpmp_params = { + **partial_stochgpmp_params, + "start_state": start_state, + "multi_goal_states": multi_goal_state + } + n_dof = stochgpmp_params['n_dof'] + n_support_points = stochgpmp_params['n_support_points'] + dt = stochgpmp_params['dt'] + device=tensor_args['device'] + statistics = dict() + frame = env.pos_frame(env.joint_to_task(multi_goal_state[0])[:, :, -1]) + target_H = frame.get_transform_matrix() + #_, target_H = env.task_to_joint(env.joint_to_task(multi_goal_state[0])) + panda_floor = FloorDistanceField(margin=0.05, device=device) + panda_self_link = LinkSelfDistanceField(margin=0.03, device=device) + panda_collision_link = LinkDistanceField(device=device) + panda_goal = EESE3DistanceField(target_H, device=device) + + # Factored Cost params + prior_sigmas = dict( + sigma_start=0.0001, + sigma_gp=0.0007, + ) + sigma_floor = 0.1 + sigma_self = 0.01 + sigma_coll = 0.0007 + sigma_goal = 0.00007 + sigma_goal_prior = 20. + + # Construct cost function + cost_prior = CostGP( + n_dof, n_support_points, start_state, dt, + prior_sigmas, tensor_args + ) + #cost_floor = CostCollision(n_dof, n_support_points, field=panda_floor, sigma_coll=sigma_floor) + cost_self = CostCollision(n_dof, n_support_points, field=panda_self_link, sigma_coll=sigma_self) + cost_coll = CostCollision(n_dof, n_support_points, field=panda_collision_link, sigma_coll=sigma_coll) + cost_goal = CostGoal(n_dof, n_support_points, field=panda_goal, sigma_goal=sigma_goal) + cost_goal_prior = CostGoalPrior(n_dof, n_support_points, multi_goal_states=stochgpmp_params['multi_goal_states'], + num_particles_per_goal=stochgpmp_params['num_particles_per_goal'], + num_samples=stochgpmp_params['num_samples'], + sigma_goal_prior=sigma_goal_prior, + tensor_args=tensor_args) + cost_func_list = [cost_prior, cost_goal_prior, cost_self, cost_coll, cost_goal] + # cost_func_list = [cost_prior, cost_goal_prior, cost_goal] + cost_composite = CostComposite(n_dof, n_support_points, cost_func_list, FK=env.diff_panda.compute_forward_kinematics_all_links) + + stochgpmp_params['cost'] = cost_composite + obs = { + 'obstacle_spheres': env.sphere_approximation + } + stochgpmp_coll_free_trajs_torch, stochgpmp_mean_trajs_torch, _, stochgpmp_metrics = generate_stochgpmp_trajs( + stochgpmp_params, initial_particle_means=initial_particle_means, get_statistics=True, env=env, + use_env_collision=True, obs=obs) + + eval_metrics = evaluation_metrics(stochgpmp_mean_trajs_torch, + stochgpmp_coll_free_trajs_torch, print_info=debug, + print_label=print_label, simple_metrics=True) + stochgpmp_metrics.update(**eval_metrics) + + return stochgpmp_metrics, stochgpmp_mean_trajs_torch, stochgpmp_coll_free_trajs_torch + + planner = StochGPMP(**stochgpmp_params, initial_particle_means=initial_particle_means) + + s = time.time() + for i in range(partial_stochgpmp_params['opt_iters'] + 1): + print(i) + time_start = time.time() + planner.optimize(obs) + print(f'Time(s) per iter: {time.time() - time_start} sec') + controls, _, trajectories, trajectory_means, weights = planner.get_recent_samples() + statistics['sampling_time'] = time.time() - s + + stochgpmp_coll_free_trajs_torch, stochgpmp_mean_trajs_torch, _, stochgpmp_metrics = generate_stochgpmp_trajs( + stochgpmp_params, initial_particle_means=initial_particle_means, get_statistics=True, env=env, + use_env_collision=True) + + + free_trajs, percent_in_coll = compute_coll_free_trajs_env(trajectory_means, env, return_coll_stat=True) + statistics['percentage_in_collision'] = to_numpy(percent_in_coll) + eval_metrics = evaluation_metrics(trajectory_means, + free_trajs, print_info=debug, + print_label=print_label, simple_metrics=True) + statistics.update(**eval_metrics) + # TODO debug print + return statistics, trajectory_means, free_trajs + + +def eval_rrt(env, start_state, goal_state, num_samples=None, variant='connect',max_step=0.07, max_iterations=1000, debug=False): + start = to_numpy(start_state) + goal = to_numpy(goal_state) + trajs = [] + # Sample trajectories + s = time.time() + for i in range(num_samples): + for t in range(100): # try 100 times + print(t) + if variant == 'connect': + path = rrt_connect(start, goal, distance_fn=env.distance_fn, + sample_fn=env.sample_fn, + extend_fn=env.wrap_extend_fn(max_step=max_step, max_dist=100), + collision_fn=env.collision_fn, max_iterations=max_iterations) + elif variant == 'star': + path = rrt_star(start, goal, distance_fn=env.distance_fn, + sample_fn=env.sample_fn, + extend_fn=env.extend_fn, + collision_fn=env.collision_fn, max_iterations=max_iterations, radius=0.1, debug=debug) + if path is not None: + break + + if path is None: + exit(f'RRT {variant} couldn\'t find a path') + trajs.append(np.array(path)) + # TODO makes times per traj + rrt_sampling_time = time.time() - s + rrt_metrics = dict() + rrt_metrics['sampling_time'] = rrt_sampling_time/num_samples + rrt_metrics['percentage_in_collision'] = 0 + #rrt_trajs_np = np.array(trajs) + eval_metrics = evaluation_metrics_rrt_variable_horizons(trajs, print_info=True, print_label=f'RRT_{variant}') + rrt_metrics.update(**eval_metrics) + return rrt_metrics, trajs, trajs # keep same interface + + +def save_metrics(results_all_contexts, results_dir, round_to=2): + # Average results for all contexts + pd_results_all_contexts = pandas.DataFrame.from_dict(results_all_contexts).transpose() + pd_results_all_contexts_mean = pd_results_all_contexts.applymap(np.mean).round(round_to) + pd_results_all_contexts_mean.to_csv(os.path.join(results_dir, 'metrics_mean.csv'), index=True) + pd_results_all_contexts_mean.to_latex(os.path.join(results_dir, 'metrics_mean.tex'), index=True) + pd_results_all_contexts_std = pd_results_all_contexts.applymap(np.std).round(round_to) + pd_results_all_contexts_std.to_csv(os.path.join(results_dir, 'metrics_std.csv'), index=True) + pd_results_all_contexts_std.to_latex(os.path.join(results_dir, 'metrics_std.tex'), index=True) + mean_numpy = pd_results_all_contexts_mean.to_numpy() + std_numpy = pd_results_all_contexts_std.to_numpy() + # TODO round to + text = '' + for mean_row, std_row in zip(mean_numpy, std_numpy): + for mean, std in zip(mean_row, std_row): + if math.isnan(mean): + text += '& ' + else: + text += f'& {round(mean, round_to)} \\pm {round(std, round_to)} ' + text += '\\\\ \n \\hline \n' + + with open(os.path.join(results_dir, 'metrics_mean_std.tex'), 'w') as f: + f.write(text) + + print(f"\n------ ALL CONTEXTS METRICS") + print(pd_results_all_contexts_mean) + print(pd_results_all_contexts_std) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/jacobian.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/jacobian.py new file mode 100644 index 0000000000000000000000000000000000000000..3bea20b2588da47719778a4391377db145463ab2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/jacobian.py @@ -0,0 +1,22 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch +from torch import autograd + + +def get_jacobian(net, x, output_dims, reshape_flag=True, context=None): + if x.ndimension() == 1: + n = 1 + else: + n = x.size()[:-1] + x_m = x.repeat([1] * len(n) + [output_dims]).view(-1, output_dims) + + x_m.requires_grad_(True) + y_m = net(x_m) + mask = torch.eye(output_dims).repeat(n, 1).to(x.device) + # y.backward(mask) + J = autograd.grad(y_m, x_m, mask, create_graph=True)[0] + if reshape_flag: + J = J.reshape(n, output_dims, output_dims) + return J diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/loading.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/loading.py new file mode 100644 index 0000000000000000000000000000000000000000..38b2f18e9c9042d07e79689dfa5a050251551a19 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/loading.py @@ -0,0 +1,9 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import yaml + + +def load_params_from_yaml(path: str): + with open(path, "r") as stream: + return yaml.load(stream, Loader=yaml.FullLoader) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/one_hot.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/one_hot.py new file mode 100644 index 0000000000000000000000000000000000000000..3cbcc64e1b4f8c6c31ecd11549cd1bfd6e639a5e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/one_hot.py @@ -0,0 +1,25 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import torch + + +def to_one_hot(number, max_val, min_val=0): + assert max_val > min_val, "illegal parameters, max_val is not larger than min_val" + hot = torch.zeros(max_val - min_val) + hot[number] = 1 + return hot + + +def from_one_hot(hot): + index = torch.where(hot) + assert len(index) == 1, "more than one value is 1 in one hot vector" + + return index[0] + + +if __name__ == "__main__": + hot = to_one_hot(4, 0, 5) + print(hot) + val = from_one_hot(hot) + print(val) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/path_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/path_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..44683eac7a4725523510b9d1390304cf16473262 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/mmd/utils/path_utils.py @@ -0,0 +1,44 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import os +import os.path as osp + +data_directory = os.path.abspath(os.path.dirname(__file__ + '/../../../../')) + + +# def makedirs(dirname): +# if not os.path.exists(dirname): +# os.makedirs(dirname) + +def makedirs(dirname): + os.makedirs(dirname, exist_ok=True) + + +def get_pebm_src(): + # directory = os.environ['PEBM_DATA_DIR'] + makedirs(data_directory) + return data_directory + + +def get_pebm_data_dir(): + directory = osp.join(get_pebm_src(), 'data') + makedirs(directory) + return directory + + +def get_pebm_mesh_density_dir(): + directory = osp.join(get_pebm_data_dir(), 'input_objs') + makedirs(directory) + return directory + + +def get_pebm_pointcloud_occupancy_dir(): + directory = osp.join(get_pebm_data_dir(), 'train_data') + makedirs(directory) + return directory + + +if __name__ == '__main__': + print(get_pebm_src()) + print(get_pebm_data_dir()) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_mmd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..f8d1ea691904a4367df1b075603ab10a16c64d29 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_mmd.py @@ -0,0 +1,335 @@ +import numpy as np +import torch +import os +import pandas as pd +import yaml +import argparse +import matplotlib.pyplot as plt +import logging +import re +from tqdm import tqdm +from os.path import join, dirname +from typing import Dict, Any, Tuple + +from torch_robotics.tasks.tasks import PlanningTask + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +from helpers import ( + plot_velocity_stats, plot_velocity_histogram, +) + +def load_data(data_abs_dir: str) -> Tuple[Dict[str, np.ndarray]]: + """Loads raw data from the .npz file.""" + raw_data_pth_ls = [] + processed_data_pth_ls = [] + n_agents_ls = [] + for root, _, files in os.walk(data_abs_dir): + for file in files: + if file.endswith('.npz'): + if 'raw_data' not in file: + continue + + file_path = os.path.join(root, file) + raw_data_pth_ls.append(file_path) + match = re.search(r'(\d+)_agents', file) + try: + n_agents = int(match.group(1)) + except (AttributeError, ValueError): + logger.warning(f"Could not extract number of agents from filename: {file}. Using default value 2.") + n_agents = 2 + n_agents_ls.append(n_agents) + + processed_data_pth = os.path.join(root, file.replace('raw_data', 'processed_data')) + assert os.path.exists(processed_data_pth), \ + f"Processed data file not found: \'{processed_data_pth}\'. Ensure it exists." + processed_data_pth_ls.append(processed_data_pth) + + if not raw_data_pth_ls or not processed_data_pth_ls: + raise FileNotFoundError( + f"No raw or processed data files found in directory: {data_abs_dir}. " + "Ensure the directory contains .npz files with 'raw_data' or 'processed_data' in their names." + ) + + raw_data_ls = [] + processed_data_ls = [] + for raw_data_path, processed_data_path in zip(raw_data_pth_ls, processed_data_pth_ls): + print(f"Loading data from: {dirname(raw_data_path)}") + raw_data = np.load(raw_data_path, allow_pickle=True) + processed_data = np.load(processed_data_path, allow_pickle=True) + raw_data_ls.append(raw_data) + processed_data_ls.append(processed_data) + + return n_agents_ls, raw_data_ls, processed_data_ls + + +def analyze_collisions( + robot_collision_masks: np.ndarray, + config: Dict, + obstacle_collision_masks: np.ndarray = None +) -> Dict[str, Any]: + """Performs collision detection and calculates safety statistics.""" + print("Analyzing collisions...") + + N, I, P, _, H = robot_collision_masks.shape + safe_masks = np.logical_not(robot_collision_masks) # (N, I, P, P, H) + trj_safe_masks:np.ndarray = safe_masks.all(-1) # (N, I, P, P) + + strict_safe_masks = trj_safe_masks.all(axis=(-1, -2)) # (N, I,), whether all agents are collision-free with each other + assert strict_safe_masks.shape == (N, I), \ + f"Expected strict safe masks shape [N, I]({N, I}), got {strict_safe_masks.shape}." + overall_std_strict_safe_rt = strict_safe_masks.astype(float).std() # Overall pooled std + + loose_safe_rates = (trj_safe_masks.astype(float).sum(axis=(-1, -2)) - P) \ + / (P * (P - 1)) # (N, I) + loose_safe_rates_per_init = loose_safe_rates.mean(axis=0) # (I,) + strict_safe_masks = trj_safe_masks.all(-1) # (N, I, P), whether this agent is collision-free with all other agents + strict_safe_rates_per_init = np.mean(strict_safe_masks.all(-1).astype(float), axis=(0,)) # (I,) + overall_loose_safe_rate = loose_safe_rates_per_init.mean() # Overall safe rate across all players + overall_strict_safe_rate = strict_safe_rates_per_init.mean() # Overall safe rate across all players + collision_counts = np.sum(robot_collision_masks.astype(int), axis=(-1, -2, -3)) // 2 # (N, I) + + print(f"Overall strict safe rate: {overall_strict_safe_rate:.2%}") + print(f"Overall loose safe rate: {overall_loose_safe_rate:.2%}") + + if obstacle_collision_masks is None or not isinstance(obstacle_collision_masks, np.ndarray): + raise ValueError( + "Obstacle collision masks must be provided as a numpy array. " + "Ensure 'obstacle_collision_masks' is passed correctly." + ) + + static_obstacle_safe_rates_per_agent = np.logical_not(obstacle_collision_masks.any(-1)) # (N, I, P) + static_obstacle_safe_rates = static_obstacle_safe_rates_per_agent.astype(float).mean(axis=-1) # (N, I) + overall_static_obstacle_safe_rate = static_obstacle_safe_rates.mean() # Overall safe rate across all batches & inits + print(f"Overall static obstacle safe rate: {overall_static_obstacle_safe_rate:.2%}") + + all_safe_masks = np.logical_and( + strict_safe_masks, # (N, I, P) whether this agent is collision-free with all other agents + static_obstacle_safe_rates_per_agent, # (N, I, P) whether this agent is collision-free with all obstacles + ) # (N, I, P) + all_safe_rates_per_agent = all_safe_masks.mean(axis=(0, 1)) # (P,) mean safe rate per player across all inits and batches + overal_safe_rates = all_safe_rates_per_agent.mean() # Overall safe rate across all batches & inits + print(f"Overall safe rate: {overal_safe_rates:.2%}") + + success_masks = all_safe_masks.all(-1) # (N, I) whether all agents are collision-free with all other agents and obstacles + best_success_masks = success_masks.max(axis=0) # (I,) whether at least one batch is collision-free for each init + success_rate = best_success_masks.mean() # Overall success rate across all inits + std_success_rate = best_success_masks.std() # Overall pooled std + print(f"Overall success rate: {success_rate:.2%}") + + return { + 'Overall Mean Collision Count': collision_counts.mean(), + 'Overall Mean Strict Inter-Agent Safe Rate': overall_strict_safe_rate, + 'Overall Std Strict Inter-Agent Safe Rate': overall_std_strict_safe_rt, + 'Overall Mean Loose Inter-Agent Safe Rate': overall_loose_safe_rate, + 'Overall Mean Static Obstacle Safe Rate': overall_static_obstacle_safe_rate, + 'Overall Mean All Safe Rate': overal_safe_rates, + 'Overall Success Rate': success_rate, + 'Overall Std Success Rate': std_success_rate, + } + + +def analyze_rewards(data_adherences:np.ndarray) -> Dict[str, Any]: + """Calculates and returns data adherence statistics.""" + print("Analyzing Data Adherence...") + assert data_adherences.ndim == 3, "Data adherence should be a 3D array (N, I, P)." + N, I, P = data_adherences.shape + mean_rewards_per_player = np.mean(data_adherences, axis=(0, 1)) # (P, ) + std_rewards_per_player = data_adherences.std(axis=(0, 1)) # (P, ) + overall_mean_rewards = np.mean(mean_rewards_per_player) + + print(f"Mean data adherence per player: {np.round(mean_rewards_per_player, 4)}") + print(f"Overall mean data adherence: {overall_mean_rewards:.4f}") + + return { + 'Overall Agent Data Adherence': tuple(mean_rewards_per_player.tolist()), + 'Std Agent Data Adherence': tuple(std_rewards_per_player.tolist()), + 'Overall Mean Data Adherence': overall_mean_rewards, + } + +def analyze_velocity(vel: np.ndarray, config: Dict, expt_params: Dict, save_dir: str): + """Calculates velocity, profiles it, and plots statistics.""" + print("Analyzing velocity...") + constants = config['constants'] + + N, I, P, H = vel.shape + v_eps = constants['VEL_TOL'] + + max_vel = expt_params.get('max_vel') + if max_vel is None: + max_vel = constants['DEFAULT_MAX_VEL'] + logger.warning(f"Max velocity not found in experiment setting, using default max velocity: {max_vel}.") + if abs(max_vel - constants['DEFAULT_MAX_VEL']) > 1e-6: + logger.warning(f"Max velocity ({max_vel}) differs from default ({constants['DEFAULT_MAX_VEL']}).") + + vel_constr_sat = vel <= max_vel + v_eps + vel_constr_sat_seg:np.ndarray = np.all(vel_constr_sat, axis=-1) # (N, I, P) + mean_vel_constr_sat_per_player = np.mean(vel_constr_sat_seg.astype(float), axis=(0, 1)) # (P, ) + overall_mean_vel_constr_sat = np.mean(mean_vel_constr_sat_per_player) + overall_std_vel_constr_sat_per_player = vel_constr_sat_seg.std(axis=(0, 1)) + + print("Mean velocity constraint satisfaction per player: " + '(' + + ", ".join([f"{v:.2%}" for v in mean_vel_constr_sat_per_player]) + + ')') + print(f"Overall mean velocity constraint satisfaction: {overall_mean_vel_constr_sat:.2%}") + + v = vel.reshape(N*I*P, H) # (I*N*P, H) + if config['switches']['plot_velocity_stats']: + hard_deck = max_vel + proj_method = expt_params.get('projection_method') + if proj_method is None: + proj_method = "N/A" + title = f"(Normalized) Velocity Stats (Proj: {proj_method})" + subtitle = f"(Horizon: {H}, Max Vel.: {max_vel:.4f}, Num_Agents: {P}, Samples/Agent: {expt_params.get('n_samples', N)})" + + fig1, _ = plot_velocity_stats(np.arange(v.shape[-1]), v.mean(0), v.std(0), v.max(0), v.min(0), v.mean(), hard_deck, title, subtitle) + fig2, _ = plot_velocity_histogram(v, bins=config['plotting']['vel_hist_bins'], quantiles=constants['VEL_HIST_QUANTILES'], title=f"Velocity Histogram (Proj: {proj_method})", subtitle=subtitle) + + if config['switches']['save_plots']: + logger.info("Saving velocity plots...") + fig1.savefig(os.path.join(save_dir, f"vel_stats_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + fig2.savefig(os.path.join(save_dir, f"vel_hist_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + plt.close(fig1) + plt.close(fig2) + + return { + 'Overall Mean Velocity Constraint Satisfaction': overall_mean_vel_constr_sat, + 'Mean Velocity Constraint Satisfaction per Player': tuple(mean_vel_constr_sat_per_player.tolist()), + 'Std Velocity Constraint Satisfaction per Player': tuple(overall_std_vel_constr_sat_per_player.tolist()), + 'Max Velocity': max_vel, + } + +def aggregate_and_save_stats(num_agents:int, stats: Dict, data_dir: str, config: Dict): + """Aggregates all computed stats into a DataFrame and saves to JSON.""" + df = pd.DataFrame([stats]) + df.index = [os.path.basename(data_dir)] + + if config['switches']['save_stats_json']: + save_path = os.path.join(data_dir, f"summary_stats_ag{num_agents}_vmax{stats['Max Velocity']}.json") + print(f"Saving summary stats to: {save_path}") + df.to_json(save_path, orient='records', indent=4) + + return df + +def run_analysis_for_directory(data_dir: str, config: Dict, base_dir:str) -> pd.DataFrame: + """Main pipeline for processing a single data directory.""" + data_abs_dir = data_dir if os.path.isabs(data_dir) or data_dir.startswith('~') else\ + os.path.join(base_dir, data_dir) + save_dir = os.path.join(data_abs_dir, "figs") + os.makedirs(save_dir, exist_ok=True) + + expt_params = {} # This path skips run-config metadata and uses config/default fallbacks for velocity/projection fields. + + success_cnt = 0 + n_agents_ls, raw_data_ls, processed_data_ls = load_data(data_abs_dir) + for n_agents, raw_data, processed_data in zip(n_agents_ls, raw_data_ls, processed_data_ls): + all_states:np.ndarray = raw_data['all_state_trjs'] # (N, I, P, H, D) + num_agents = all_states.shape[-3] + assert num_agents == n_agents, f"Number of agents in data ({num_agents}) does not match expected ({n_agents})." + collision_masks:np.ndarray = processed_data['collision_masks'] # (N, I, P, P, H) + assert collision_masks.shape[-2] == num_agents, \ + f"Expected number of agents in collision masks ({collision_masks.shape[-2]}) to match number of agents in data ({num_agents})." + all_vel = processed_data['velocity_trjs'] # (N, I, P, H) + assert all_vel.shape[-2] == num_agents, \ + f"Expected number of agents in velocity trajectories ({all_vel.shape[-2]}) to match number of agents in data ({num_agents})." + data_adherence:np.ndarray = processed_data['data_adherence'] # (N, I, P) + assert data_adherence.shape[-1] == num_agents, \ + f"Expected number of agents in data adherence ({data_adherence.shape[-1]}) to match number of agents in data ({num_agents})." + obst_coll_msks:np.ndarray = processed_data['obstacle_collision_masks'] # (N, I, P, H) + assert obst_coll_msks.shape[-2] == num_agents, \ + f"Expected number of agents in obstacle collision masks ({obst_coll_msks.shape[-2]}) to match number of agents in data ({num_agents})." + + if 'missing_ids' in raw_data.files: + missing_ids = raw_data['missing_ids'] + n_missing = len(missing_ids) + n_total_inits = raw_data['all_state_trjs'].shape[1] + n_missing + if n_missing > 0: + logger.warning(f"Found {n_missing} missing data entries out of {n_total_inits} Inits. Filling with default values.") + sorted_missing_ids = sorted(missing_ids) + + # Prepare default value arrays for bulk insertion + n_missing = len(sorted_missing_ids) + bulk_default_coll_msk = np.ones((collision_masks.shape[0], n_missing, num_agents, num_agents, collision_masks.shape[-1]), dtype=collision_masks.dtype) + bulk_default_data_adh = np.zeros((data_adherence.shape[0], n_missing, num_agents), dtype=data_adherence.dtype) + bulk_default_obst_coll_msk = np.ones((obst_coll_msks.shape[0], n_missing, num_agents, obst_coll_msks.shape[-1]), dtype=obst_coll_msks.dtype) + + # Insert all missing entries in a single operation for each array + collision_masks = np.insert(collision_masks, sorted_missing_ids, bulk_default_coll_msk, axis=1) + data_adherence = np.insert(data_adherence, sorted_missing_ids, bulk_default_data_adh, axis=1) + obst_coll_msks = np.insert(obst_coll_msks, sorted_missing_ids, bulk_default_obst_coll_msk, axis=1) + assert collision_masks.shape[1] == data_adherence.shape[1] == obst_coll_msks.shape[1] == n_total_inits, \ + f"After inserting missing data, inconsistent Init dimension sizes: " \ + f"collision_masks {collision_masks.shape[1]}, data_adherence {data_adherence.shape[1]}, " \ + f"obst_coll_msks {obst_coll_msks.shape[1]}, all_state_trjs {n_total_inits}." + + summary_stats = {"Directory": data_dir, **expt_params} + summary_stats.update( + analyze_collisions( + collision_masks, + config, + obstacle_collision_masks=obst_coll_msks + ) + ) + summary_stats.update(analyze_rewards(data_adherence)) + summary_stats.update(analyze_velocity(all_vel, config, expt_params, save_dir)) + + final_df = aggregate_and_save_stats(num_agents, summary_stats, data_abs_dir, config) + success_cnt += 1 + + return success_cnt + + +def main(): + """Main execution function.""" + parser = argparse.ArgumentParser(description="Run trajectory analysis from a config file.") + parser.add_argument( + '--config', + type=str, + default='config.yaml', + help='Path to the YAML configuration file.' + ) + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + base_dir = dirname(dirname(script_dir)) + cfg_dir = join(base_dir, 'configs') + + cfg_pth = os.path.join(cfg_dir, args.config) + assert os.path.exists(cfg_pth), f"Configuration file not found: {cfg_pth}" + + with open(cfg_pth, 'r') as f: + config = yaml.safe_load(f) + + data_dirs = [] + for log_pth in tqdm(config['log_paths'], desc="Processing Directories", unit="dir"): + if not os.path.isabs(log_pth) and not log_pth.startswith('~'): + log_pth = os.path.join(base_dir, log_pth) + with open(log_pth, 'r') as f: + logged_dirs = [line.strip() for line in f if line.strip()] + data_dirs.extend(logged_dirs) + + success_cnt = 0 + for data_dir in tqdm(data_dirs, desc="Processing Directories", unit="dir"): + print("\n" + "="*80) + logger.info(f"Processing Directory: {data_dir}") + print("="*80) + if not os.path.isabs(data_dir) and not data_dir.startswith('~'): + data_dir = os.path.join(base_dir, data_dir) + try: + success_cnt = run_analysis_for_directory(data_dir, config, base_dir=base_dir) + except Exception as e: + logger.error(f"\nERROR processing directory '{data_dir}': \n{e}") + logger.info("Skipping to next directory.\n") + continue + + if success_cnt == 0: + logger.warning("No successful analyses were performed. Check the data directories and configuration.") + else: + logger.info(f"Successfully processed {success_cnt} directories.") + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_pcd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_pcd.py new file mode 100644 index 0000000000000000000000000000000000000000..a05f992151a8dc5b78a380bfc4305e3dbae26fdc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/analyzing_pcd.py @@ -0,0 +1,480 @@ +import numpy as np +import torch +import os +import pandas as pd +import yaml +import argparse +import matplotlib.pyplot as plt +import logging +from tqdm import tqdm +from os.path import join, dirname +from typing import Dict, Any, Tuple, Callable + +from torch_robotics.tasks.tasks import PlanningTask + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +from helpers import ( + extract_params_from_config, + plot_trajectory_tuples, plot_collision_masks, + plot_velocity_stats, plot_velocity_histogram, +) +from mmd.common.argparse import load_config + +TENSOR_ARGS = { + 'dtype': torch.float32, + 'device': 'cpu' + } + +def load_data(data_abs_dir: str) -> Tuple[Dict[str, np.ndarray]]: + """Loads raw data from the .npz file.""" + raw_data_path = os.path.join(data_abs_dir, "raw_data.npz") + processed_data_path = os.path.join(data_abs_dir, "processed_data.npz") + if not os.path.exists(raw_data_path): + raise FileNotFoundError(f"Raw data not found at: \'{raw_data_path}\'") + if not os.path.exists(processed_data_path): + raise FileNotFoundError(f"Processed data not found at: \'{processed_data_path}\'") + + print(f"Loading data from: {raw_data_path}") + raw_data = np.load(raw_data_path, allow_pickle=True) + processed_data = np.load(processed_data_path, allow_pickle=True) + + return raw_data, processed_data + +def get_planning_dataset(expt_params: Dict[str, Any], config: Dict[str, Any]) -> Tuple[Any, Any]: + from mmd.utils.loading import load_params_from_yaml + from mmd.trainer import get_dataset + + trained_models_dir = expt_params.get('data_trained_models_dir') + if not trained_models_dir: + trained_models_dir = config['data']['default_trained_models_dir'] + logger.warning( + "No trained models directory specified in experiment parameters. " + f"Using default: \'{trained_models_dir}\'." + ) + + args = load_params_from_yaml(os.path.join(trained_models_dir, "args.yaml")) + trainset, _, _, _ = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=False, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=TENSOR_ARGS + ) + return trainset.dataset + +def get_planning_task(expt_params: Dict[str, Any], config:Dict[str, Any]) -> PlanningTask: + trainset = get_planning_dataset(expt_params, config) + task:PlanningTask = trainset.dataset.task + return task + + + +def analyze_and_plot_trajectories(all_actions: np.ndarray, config: Dict, save_dir: str, title_args: Dict): + """Plots trajectory tuples based on config settings.""" + if not config['switches']['plot_trajectories']: + return + + print("Plotting trajectory tuples...") + n_trj_tups_plot = config['plotting']['num_trj_tups_plot'] + n_inits_plot = config['plotting']['num_inits_plot'] + figs_axs = plot_trajectory_tuples( + all_actions[:n_trj_tups_plot, :n_inits_plot, ...], + tup_size=2, + h_start=0, h_end=config['plotting']['traj_plot_h_end'], + title="Robot Trajectory Tuples", + title_args=title_args, + cmap_name='plasma', + xlim=tuple(config['plotting']['traj_plot_xlim']), + ylim=tuple(config['plotting']['traj_plot_ylim']), + figsize=(10, 10) + ) + + if config['switches']['save_plots']: + print(f"Saving {len(figs_axs)} trajectory plots to '{save_dir}'...") + for i, (fig, _) in tqdm(enumerate(figs_axs), total=len(figs_axs)): + fig.savefig(os.path.join(save_dir, f"init_st{i:03d}_action_trj_tups.png"), bbox_inches='tight', dpi=300) + plt.close(fig) + +def analyze_costs( + all_pos_trjs:np.ndarray, + expt_params:Dict, +): + print("Analyzing costs...") + from mmd.coupling_costs.cost_function_implementation import cost_registry + from mmd.coupling_costs.coupling_cost_functions import RobotCollisionCost + + cost_fn_name = expt_params["cost_function"] + if cost_fn_name == 'vanilla': + logger.info("Using 'vanilla' cost function, which is a no-op. Returning NaN.") + return { + 'Overall Mean Cost': np.nan, + 'Overall Std Cost': np.nan, + 'Within Init Std Cost': np.nan, + 'Between Init Std Cost': np.nan, + } + else: + assert cost_fn_name in cost_registry, f"Cost function '{cost_fn_name}' not found in registry." + robot_radius = expt_params['robot_radius'] + cost_func = RobotCollisionCost( + robot_radius=robot_radius, + cost_func_key=cost_fn_name, + **expt_params.get('cost_func_param', {}) + ) + + N, I, P, H, D = all_pos_trjs.shape + # Reshape all_pos_trjs so that dim 0 is N*P, preserving I and stacking P + pos_trjs_reshape = all_pos_trjs.transpose(0, 2, 1, 3, 4).reshape(N * P, I, H, D) + costs:torch.Tensor = cost_func.eval( + torch.from_numpy(pos_trjs_reshape).to(**TENSOR_ARGS), + group_size=P, + ).to('cpu').numpy() # (N, I) + assert costs.ndim == 2 + assert costs.shape == (N, I), f"Expected costs shape (N, I), got {costs.shape}." + + # Intra-group variance E[Var(cost | init)] + cost_within_init_std = costs.std(axis=0).mean() # Mean std across inits + cost_between_inits_std = costs.mean(axis=0).std() # Std of means across inits + + return { + 'Overall Mean Cost': costs.mean(), + 'Overall Std Cost': costs.std(), + 'Within Init Std Cost': cost_within_init_std, + 'Between Init Std Cost': cost_between_inits_std, + } + +def analyze_collisions( + all_pos_trjs:np.ndarray, robot_collision_masks: np.ndarray, + config: Dict, + expt_params:Dict, + save_dir: str, + obstacle_collision_masks: np.ndarray = None +) -> Dict[str, Any]: + """Performs collision detection and calculates safety statistics.""" + print("Analyzing collisions...") + constants = config['constants'] + # collision_masks = collision_detect( + # roll_actions, safe_dist=constants['SAFE_DIST'], norm_order=2 + # ).transpose(0, 2, 1, 3) # (I, P, N, H) + + ## ----------------------- Inter-robot Collision Detection -------------------------------- + N, I, P, _, H = robot_collision_masks.shape + safe_masks = np.logical_not(robot_collision_masks) # (N, I, P, P, H) + trj_safe_masks:np.ndarray = safe_masks.all(-1) # (N, I, P, P) + + loose_safe_rates = (trj_safe_masks.astype(float).sum(axis=(-1, -2)) - P) \ + / (P * (P - 1)) # (N, I) + assert loose_safe_rates.shape == (N, I), \ + f"Expected loose safe rates shape [N, I]({N, I}) , got {loose_safe_rates.shape}." + overall_loose_safe_rate = loose_safe_rates.mean() + overall_std_loose_safe_rt = loose_safe_rates.std() # Overall pooled std + within_init_std_loose_safe_rt = loose_safe_rates.std(axis=0).mean() # Mean std across inits + between_inits_std_loose_safe_rt = loose_safe_rates.mean(axis=0).std() # Std of means across inits + + strict_safe_masks = trj_safe_masks.all(axis=(-1, -2)) # (N, I,), whether all agents are collision-free with each other + assert strict_safe_masks.shape == (N, I), \ + f"Expected strict safe masks shape [N, I]({N, I}), got {strict_safe_masks.shape}." + overall_strict_safe_rate = strict_safe_masks.astype(float).mean() # Overall safe rate + overall_std_strict_safe_rt = strict_safe_masks.astype(float).std() # Overall pooled std + within_init_std_strict_safe_rt = strict_safe_masks.astype(float).std(axis=0).mean() # Mean std across inits + between_inits_std_strict_safe_rt = strict_safe_masks.astype(float).mean(axis=0).std() # Std of means across inits + + collision_counts:np.ndarray = np.sum(robot_collision_masks.astype(int), axis=(-1, -2, -3)) // 2 # (N, I) + assert collision_counts.shape == (N, I), \ + f"Expected collision counts shape [N, I]({N, I}), got {collision_counts.shape}." + collision_counts_std = np.std(collision_counts.flatten()) + coll_within_init_std = collision_counts.std(axis=0).mean() # Mean std across inits + coll_between_inits_std = collision_counts.mean(axis=0).std() # Std of means across inits + + print(f"Overall strict safe rate: {overall_strict_safe_rate:.2%}\u00b1{overall_std_strict_safe_rt:.2%}") + print(f"Overall loose safe rate: {overall_loose_safe_rate:.2%}\u00b1{overall_std_loose_safe_rt:.2%}") + + ## ----------------------- Static Obstacle Collision Detection -------------------------------- + if obstacle_collision_masks is None or not isinstance(obstacle_collision_masks, np.ndarray): + N, I, P, H, _ = all_pos_trjs.shape + task:PlanningTask = get_planning_task(expt_params, config) + trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions = \ + task.get_trajs_collision_and_free( + torch.from_numpy(all_pos_trjs).reshape(N*I*P, H, -1), # Reshape to (N*I*P, H, D) + return_indices=True, + num_interpolation=0, + ) + obstacle_collision_masks:np.ndarray = trajs_waypoints_collisions.to('cpu').numpy().reshape(N, I, P, H) # (N, I, P, H) + + static_obstacle_safe_rates_per_agent:np.ndarray = np.logical_not(obstacle_collision_masks.any(-1)) # (N, I, P) + static_obstacle_safe_rates:np.ndarray = static_obstacle_safe_rates_per_agent.astype(float).mean(axis=-1) # (N, I) + overall_static_obstacle_safe_rate = static_obstacle_safe_rates.mean() # Overall safe rate across all batches & inits + overall_std_static_obst_safe_rt = static_obstacle_safe_rates.std() # Overall pooled std + within_init_std_static_obst_safe_rt = static_obstacle_safe_rates.std(axis=0).mean() # Mean std across inits + between_inits_std_static_obst_safe_rt = static_obstacle_safe_rates.mean(axis=0).std() # Std of means across inits + + print(f"Overall static obstacle safe rate: {overall_static_obstacle_safe_rate:.2%}\u00b1{overall_std_static_obst_safe_rt:.2%}") + + ## ----------------------- All Safe rate -------------------------------- + obstacle_safe_masks = np.logical_not(obstacle_collision_masks.any(axis=(-1, -2))) # (N, I,) + assert obstacle_safe_masks.shape == (N, I), \ + f"Expected obstacle safe masks shape [N, I]({N, I}), got {obstacle_safe_masks.shape}." + all_safe_masks = np.logical_and( + strict_safe_masks, # (N, I,) whether this set of trjs is collision-free with all other agents + obstacle_safe_masks, # (N, I) whether this set of trjs is collision-free with all obstacles + ) # (N, I, ) + assert all_safe_masks.shape == (N, I), \ + f"Expected all safe masks shape [N, I]({N, I}), got {all_safe_masks.shape}." + overal_safe_rates = all_safe_masks.astype(float).mean() # Overall safe rate across all batches & inits + overall_std_safe_rt = all_safe_masks.astype(float).std() # Overall pooled std + within_init_std_safe_rt = all_safe_masks.astype(float).std(axis=0).mean() # Mean std across inits + between_inits_std_safe_rt = all_safe_masks.astype(float).mean(axis=0).std() # Std of means across inits + print(f"Overall safe rate: {overal_safe_rates:.2%}") + + best_success_masks = all_safe_masks.astype(float).max(axis=0) # (I,) whether at least one batch is collision-free for each init + success_rate = best_success_masks.mean() # Overall success rate across all inits + std_success_rate = best_success_masks.std() # Overall pooled std + print(f"Overall success rate: {success_rate:.2%}\u00b1{std_success_rate:.2%}") + + ## ----------------------- Plotting Collision Masks -------------------------------- + if config['switches']['plot_trajectories']: + # n_init_plots = config['plotting']['num_inits_plot'] + # # n_trj_tups = config['plotting']['num_trj_tups_plot'] + # fig, _ = plot_collision_masks(collision_masks[:n_trj_tups, :n_init_plots, ...], 2*expt_params['robot_radius'], safe_rates=strict_safe_rates) + # if config['switches']['save_plots']: + # fig.savefig(os.path.join(save_dir, "collision_masks.png"), bbox_inches='tight', dpi=300) + # plt.close(fig) + pass + return { + 'Overall Mean Collision Count': collision_counts.mean(), + 'Overall Std Collision Count': collision_counts_std, + 'Within Init Std Collision Count': coll_within_init_std, + 'Between Init Std Collision Count': coll_between_inits_std, + 'Overall Mean Strict Inter-Agent Safe Rate': overall_strict_safe_rate, + 'Overall Std Strict Inter-Agent Safe Rate': overall_std_strict_safe_rt, + 'Within Init Std Strict Inter-Agent Safe Rate': within_init_std_strict_safe_rt, + 'Between Init Std Strict Inter-Agent Safe Rate': between_inits_std_strict_safe_rt, + 'Overall Mean Loose Inter-Agent Safe Rate': overall_loose_safe_rate, + 'Overall Std Loose Inter-Agent Safe Rate': overall_std_loose_safe_rt, + 'Within Init Std Loose Inter-Agent Safe Rate': within_init_std_loose_safe_rt, + 'Between Init Std Loose Inter-Agent Safe Rate': between_inits_std_loose_safe_rt, + 'Overall Mean Static Obstacle Safe Rate': overall_static_obstacle_safe_rate, + 'Overall Std Static Obstacle Safe Rate': overall_std_static_obst_safe_rt, + 'Within Init Std Static Obstacle Safe Rate': within_init_std_static_obst_safe_rt, + 'Between Init Std Static Obstacle Safe Rate': between_inits_std_static_obst_safe_rt, + 'Overall Mean All Safe Rate': overal_safe_rates, + 'Overall Std All Safe Rate': overall_std_safe_rt, + 'Within Init Std All Safe Rate': within_init_std_safe_rt, + 'Between Init Std All Safe Rate': between_inits_std_safe_rt, + 'Overall Success Rate': success_rate, + 'Overall Std Success Rate': std_success_rate, + } + + +def analyze_rewards(data_adherences:np.ndarray) -> Dict[str, Any]: + """Calculates and returns data adherence statistics.""" + print("Analyzing Data Adherence...") + assert data_adherences.ndim == 3, "Data adherence should be a 3D array (N, I, P)." + N, I, P = data_adherences.shape + mean_rewards_per_player = np.mean(data_adherences, axis=(0, 1)) # (P, ) + std_rewards_per_player = data_adherences.std(axis=(0, 1)) # (P, ) + overall_mean_rewards = np.mean(mean_rewards_per_player) + + print(f"Mean data adherence per player: {np.round(mean_rewards_per_player, 4)}") + + avg_mean_rewards = data_adherences.mean(-1) # (N, I) + assert avg_mean_rewards.shape == (N, I), \ + f"Expected average mean rewards shape (N, I), got {avg_mean_rewards.shape}." + avg_mean_rewards_std = avg_mean_rewards.std() # Overall pooled std + within_init_rewards_std = avg_mean_rewards.std(axis=0).mean() # Mean std across inits + between_inits_rewards_std = avg_mean_rewards.mean(axis=0).std() # Std of means across inits + print(f"Overall mean data adherence: {overall_mean_rewards:.4f}\u00b1{avg_mean_rewards_std:.4f}") + + + return { + 'Overall Agent Data Adherence': tuple(mean_rewards_per_player.tolist()), + 'Std Agent Data Adherence': tuple(std_rewards_per_player.tolist()), + 'Overall Mean Data Adherence': overall_mean_rewards, + 'Overall Std Data Adherence': avg_mean_rewards_std, + 'Within Init Std Data Adherence': within_init_rewards_std, + 'Between Init Std Data Adherence': between_inits_rewards_std, + } + +def analyze_velocity(vel: np.ndarray, config: Dict, expt_params: Dict, save_dir: str): + """Calculates velocity, profiles it, and plots statistics.""" + print("Analyzing velocity...") + constants = config['constants'] + + N, I, P, H = vel.shape + v_eps = constants['VEL_TOL'] + + max_vel = expt_params.get('max_vel') + if max_vel is None: + max_vel = constants['DEFAULT_MAX_VEL'] + logger.warning(f"Max velocity not found in experiment setting, using default max velocity: {max_vel}.") + if abs(max_vel - constants['DEFAULT_MAX_VEL']) > 1e-6: + logger.warning(f"Max velocity ({max_vel}) differs from default ({constants['DEFAULT_MAX_VEL']}).") + + ## Velocity constraint satisfaction + vel_constr_sat = vel <= max_vel + v_eps + vel_constr_sat_seg:np.ndarray = np.all(vel_constr_sat, axis=-1) # (N, I, P) + mean_vel_constr_sat_per_player = np.mean(vel_constr_sat_seg.astype(float), axis=(0, 1)) # (P, ) + overall_mean_vel_constr_sat = np.mean(mean_vel_constr_sat_per_player) + overall_std_vel_constr_sat_per_player = vel_constr_sat_seg.std(axis=(0, 1)) + + trj_vel_constr_sat: np.ndarray = vel_constr_sat_seg.mean(-1) # (N, I) + assert trj_vel_constr_sat.shape == (N, I), \ + f"Expected trajectory velocity constraint satisfaction shape (N, I), got {trj_vel_constr_sat.shape}." + overall_vel_constr_sat_std = trj_vel_constr_sat.std() # Overall pooled std + within_init_vel_constr_sat_std = trj_vel_constr_sat.std(axis=0).mean() # Mean std across inits + between_inits_vel_constr_sat_std = trj_vel_constr_sat.mean(axis=0).std() # Std of means across inits + + print("Mean velocity constraint satisfaction per player: " + '(' + + ", ".join([f"{v:.2%}" for v in mean_vel_constr_sat_per_player]) + + ')') + print(f"Overall mean velocity constraint satisfaction: {overall_mean_vel_constr_sat:.2%}") + + # Plotting + v = vel.reshape(N*I*P, H) # (I*N*P, H) + if config['switches']['plot_velocity_stats']: + hard_deck = max_vel + proj_method = expt_params.get('projection_method') + if proj_method is None: + proj_method = "N/A" + title = f"(Normalized) Velocity Stats (Proj: {proj_method})" + subtitle = f"(Horizon: {H}, Max Vel.: {max_vel:.4f}, Num_Agents: {P}, Samples/Agent: {expt_params.get('n_samples', N)})" + + fig1, _ = plot_velocity_stats(np.arange(v.shape[-1]), v.mean(0), v.std(0), v.max(0), v.min(0), v.mean(), hard_deck, title, subtitle) + fig2, _ = plot_velocity_histogram(v, bins=config['plotting']['vel_hist_bins'], quantiles=constants['VEL_HIST_QUANTILES'], title=f"Velocity Histogram (Proj: {proj_method})", subtitle=subtitle) + + if config['switches']['save_plots']: + logger.info("Saving velocity plots...") + fig1.savefig(os.path.join(save_dir, f"vel_stats_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + fig2.savefig(os.path.join(save_dir, f"vel_hist_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + plt.close(fig1) + plt.close(fig2) + + return { + 'Overall Mean Velocity Constraint Satisfaction': overall_mean_vel_constr_sat, + 'Mean Velocity Constraint Satisfaction per Player': tuple(mean_vel_constr_sat_per_player.tolist()), + 'Std Velocity Constraint Satisfaction per Player': tuple(overall_std_vel_constr_sat_per_player.tolist()), + 'Overall Std Velocity Constraint Satisfaction': overall_vel_constr_sat_std, + 'Within Init Std Velocity Constraint Satisfaction': within_init_vel_constr_sat_std, + 'Between Init Std Velocity Constraint Satisfaction': between_inits_vel_constr_sat_std, + 'Max Velocity': max_vel, + } + +def aggregate_and_save_stats(stats: Dict, data_dir: str, config: Dict): + """Aggregates all computed stats into a DataFrame and saves to JSON.""" + df = pd.DataFrame([stats]) + df.index = [os.path.basename(data_dir)] + + if config['switches']['save_stats_json']: + save_path = os.path.join(data_dir, f"summary_stats_vmax{stats['Max Velocity']}.json") + print(f"Saving summary stats to: {save_path}") + df.to_json(save_path, orient='records', indent=4) + + return df + +def run_analysis_for_directory(data_dir: str, config: Dict, base_dir:str) -> pd.DataFrame: + """Main pipeline for processing a single data directory.""" + data_abs_dir = data_dir if os.path.isabs(data_dir) or data_dir.startswith('~') else\ + os.path.join(base_dir, data_dir) + save_dir = os.path.join(data_abs_dir, "figs") + os.makedirs(save_dir, exist_ok=True) + + expt_params = extract_params_from_config(join(data_abs_dir, "run_config_full.yaml"), base_dir=base_dir) + title_args = {'stp': expt_params.get('stp'), 'scl': expt_params.get('scl')} + + # Load Data + raw_data, processed_data = load_data(data_abs_dir) + all_states:np.ndarray = raw_data['all_state_trjs'] # (N, I, P, H, D) + all_pos = all_states[..., :2] # (N, I, P, H, 2) + collision_masks:np.ndarray = processed_data['collision_masks'] # (N, I, P, P, H) + # collision_masks = collision_masks.swapaxes(0, 1) # (I, N, P, P, H) + all_vel = processed_data['velocity_trjs'] # (N, I, P, H) + data_adherence = processed_data['data_adherence'] # (N, I, P) + obst_coll_msks = processed_data['obstacle_collision_masks'] if \ + 'obstacle_collision_masks' in processed_data else \ + None # (N, I, P, H) + + # --- Run Analysis Modules --- + summary_stats = {"Directory": data_dir, **expt_params} + analyze_and_plot_trajectories(all_pos, config, save_dir, title_args) + summary_stats.update( + analyze_collisions( + all_pos, + collision_masks, + config, expt_params, save_dir, + obstacle_collision_masks=obst_coll_msks + ) + ) + summary_stats.update(analyze_rewards(data_adherence)) + summary_stats.update(analyze_velocity(all_vel, config, expt_params, save_dir)) + summary_stats.update(analyze_costs(all_pos, expt_params)) + + # --- Aggregate and Save --- + final_df = aggregate_and_save_stats(summary_stats, data_abs_dir, config) + return final_df + + +def main(): + """Main execution function.""" + parser = argparse.ArgumentParser(description="Run trajectory analysis from a config file.") + parser.add_argument( + '--config', + type=str, + default='config.yaml', + help='Path to the YAML configuration file.' + ) + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + base_dir = dirname(dirname(script_dir)) + cfg_dir = join(base_dir, 'configs') + + cfg_pth = os.path.join(cfg_dir, args.config) + assert os.path.exists(cfg_pth), f"Configuration file not found: {cfg_pth}" + + with open(cfg_pth, 'r') as f: + config = yaml.safe_load(f) + + data_dirs = [] + for log_pth in tqdm(config['log_paths'], desc="Processing Directories", unit="dir"): + if not os.path.isabs(log_pth) and not log_pth.startswith('~'): + log_pth = os.path.join(base_dir, log_pth) + with open(log_pth, 'r') as f: + logged_dirs = [line.strip() for line in f if line.strip()] + data_dirs.extend(logged_dirs) + + all_stats_df = [] + for data_dir in tqdm(data_dirs, desc="Processing Directories", unit="dir"): + print("\n" + "="*80) + logger.info(f"Processing Directory: {data_dir}") + print("="*80) + if not os.path.isabs(data_dir) and not data_dir.startswith('~'): + data_dir = os.path.join(base_dir, data_dir) + try: + stats_df = run_analysis_for_directory(data_dir, config, base_dir=base_dir) + all_stats_df.append(stats_df) + except Exception as e: + logger.error(f"\nERROR processing directory '{data_dir}': \n{e}") + logger.info("Skipping to next directory.\n") + continue + + if all_stats_df: + final_summary_df = pd.concat(all_stats_df) + print("\n" + "="*80) + print(" FINAL AGGREGATED STATISTICS ") + print("="*80) + pd.set_option('display.max_columns', None) + pd.set_option('display.width', 1000) + print(final_summary_df) + + # Save panda as csv + final_summary_df.to_csv("logs/summary_analysis.csv") + + else: + print("\nNo data was processed successfully.") + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/helpers.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..2b9ea995f3c3778f7a1fdfaa6801d86e67276140 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/helpers.py @@ -0,0 +1,595 @@ +import re +import os +import math +import yaml +import warnings +import matplotlib +import matplotlib.pyplot as plt +import matplotlib.cm as cm +import numpy as np +from ml_collections.config_dict import ConfigDict +from matplotlib.collections import LineCollection +from typing import List, Tuple, Dict, Any +from tqdm import tqdm + +# Helper function for safe extraction +def find_param(pattern, path, key, type_converter, default=None): + match = re.search(pattern, path) + if match: + try: + return type_converter(match.group(key)) + except (ValueError, IndexError): + return default + return default + +def extract_coupling_cost_func(path: str) -> str: + """ + Extracts the coupling method from a log-path string. + + Rules: + - Base method: the part after the last hyphen in the 'max_vel-…-X' segment. + - Coupling suffix: if the 'coupling' segment is written as 'coupling_ps', 'ps' is the suffix. + if it's just 'coupling', there's no suffix. + - Final method is '' if no coupling suffix, otherwise '-'. + + Examples: + >>> s1 = "…/max_vel-max_v6.2-dpp/…/init_st50-max_vel_admm-coupling_ps-…" + >>> extract_coupling_method(s1) + 'dpp-ps' + + >>> s2 = "…/max_vel-max_v10.789-exp_decay_l2_sqr/…/init_st10-max_vel_cvxpy-coupling-…" + >>> extract_coupling_method(s2) + 'exp_decay_l2_sqr' + """ + # 1) capture the method after the last hyphen in the max_vel segment + m1 = re.search(r'max_vel-[^/]+-([^/]+)', path) + if not m1: + warnings.warn(f"No 'max_vel-…-X' pattern found in: {path!r}. Running backup search.") + # raise ValueError(f"No 'max_vel-…-X' pattern found in: {path!r}") + m1 = re.search(r"H\d+O\d+A\d+D\d+/([A-Za-z0-9_]+?)/max_stp*", path) + if not m1: + warnings.warn(f"No alternative pattern found in: {path!r}. Returning 'vanilla'.") + return 'vanilla' + base_method = m1.group(1) + if not base_method: + base_method = 'vanilla' + + # 2) capture optional coupling suffix after 'coupling' + m2 = re.search(r'coupling(?:_([^-/]+))?(?:-|$)', path) + if not m2: + warnings.warn(f"No 'coupling' segment found in: {path!r}") + # raise ValueError(f"No 'coupling' segment found in: {path!r}") + suffix = m2.group(1) # None if no '_…' part + + # 3) combine + return base_method if suffix is None else f"{base_method}-{suffix}" + + +def extract_params_from_path(path: str) -> Dict[str, Any]: + """ + Extracts key parameters from a directory path string. + + Args: + path (str): The input directory path. + + Returns: + Dict[str, Any]: A dictionary of extracted parameters. + """ + params: Dict[str, Any] = {} + + # Diffusion configuration details + m = re.search(r'pusht.*output/([^/]+?)/', path) + if m: + diff_cfg = m.group(1) + params['diffusion_config'] = diff_cfg + params['pred_horizon'] = find_param(r'H(\d+)', path, 1, int) + params['action_horizon'] = find_param(r'A(\d+)', path, 1, int, default=params.get('pred_horizon')) + params['num_obs_steps'] = find_param(r'O(\d+)', path, 1, int, default=1) + params['diffusion_timesteps'] = find_param(r'D(\d+)', path, 1, int, default=100) + + # Other parameters + params['episode_length'] = find_param(r'max_stp(\d+)', path, 1, int) + params['num_init_states'] = find_param(r'init_st(\d+)', path, 1, int) + params['max_vel'] = find_param(r'max_v(\d+(?:\.\d+)?)', path, 1, float) + params['projection_method'] = find_param(r'max_vel_([A-Za-z0-9_]+?)(?=-|/|$)', path, 1, str) + params['num_trials'] = find_param(r'-t(\d+)(?:$|-)', path, 1, int) + params['cost_function'] = extract_coupling_cost_func(path) + + # Stp and Scl parameters + match = re.search(r'-stp(\d+)-scl([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)', path) + if match: + params['stp'] = int(match.group(1)) + params['scl'] = float(match.group(2)) + + return params + + +def extract_params_from_config(path: str, base_dir:str="") -> Dict[str, Any]: + """ + Extracts key parameters from a configuration file path string. + + Args: + path (str): The input configuration file path. + + Returns: + Dict[str, Any]: A dictionary of extracted parameters. + """ + assert os.path.isfile(path), f"Path does not point to a file: \'{path}\'" + with open(path, 'r') as f: + run_cfg_dict = yaml.safe_load(f) + run_cfg = ConfigDict(run_cfg_dict) + + params: Dict[str, Any] = {} + try: + params['data_trained_models_dir'] = os.path.join( + base_dir, + run_cfg.system.trained_models_dir_name, + run_cfg.environment.global_model_ids[0][0] + ) + params['episode_length'] = run_cfg.planner.single_agent.horizon + params['num_init_states'] = run_cfg.experiment.n_inits + params['num_agents'] = run_cfg.experiment.num_agents + params['num_trials'] = run_cfg.planner.single_agent.n_samples + params['horizon'] = run_cfg.planner.single_agent.horizon + params['model_id'] = run_cfg.environment.global_model_ids[0][0].split('-')[0] + # params['model_id'] = run_cfg.environment.global_model_ids[0][0] + params['dt'] = run_cfg.planner.single_agent.trajectory_duration / run_cfg.planner.single_agent.horizon + params['robot_radius'] = run_cfg.robot.planar_disk_radius + ## Projection + # params['projection_method'] = find_param(r'max_vel_([A-Za-z0-9_]+?)(?=-|/|$)', path, 1, str) + if run_cfg.planner.single_agent.planner_alg in ['pcd', 'projected']: + params['projection_method'] = "admm" + else: + params['projection_method'] = 'none' + params['max_vel'] = run_cfg.planner.projection.project_params.vel_max + ## Coupling + if run_cfg.planner.single_agent.planner_alg in ['pcd', 'coupling', 'coupling_ps']: + params['cost_function'] = run_cfg.planner.single_agent.cost_func_key + params['cost_func_param'] = run_cfg.planner.single_agent.cost_func_param + params['stp'] = run_cfg.planner.single_agent.n_guide_steps + params['scl'] = run_cfg.planner.single_agent.weight_grad_cost_constraints + else: + params['cost_function'] = 'vanilla' + ## Guidance + params['scl_obs_avoid_guide'] = run_cfg.planner.single_agent.weight_grad_cost_collision + except RuntimeError as err: + warnings.warn(f"Error extracting parameters from config: {err}") + raise err + + return params + + +def plot_trajectories( + trajectories: np.ndarray, + title: str = "Trajectory Grids", + cmap_name: str = 'viridis', + xlim: tuple = None, + ylim: tuple = None, + figsize: tuple = None, + wspace: float = 0.05, + hspace: float = 0.05 + ): + """ + Plots a batch of (X,Y) trajectories in a grid, where each grid cell + corresponds to the last dimension of the input trajectories. + Colors vary by time (horizon step) along each individual trajectory. + A single colorbar is shown for the entire figure. + + Args: + trajectories (np.ndarray): A NumPy array of shape (N, H, 2, I), where: + - N is the batch size (number of trajectories per grid cell) + - H is the horizon (number of points per trajectory) + - 2 represents (X, Y) coordinates for each point. + - I is the number of grid cells to plot. + title (str): The super-title for the entire figure. + cmap_name (str): The name of the matplotlib colormap. + xlim (tuple, optional): Fixed (min, max) x-limits for each subplot. If None, auto-scaled per subplot. + ylim (tuple, optional): Fixed (min, max) y-limits for each subplot. If None, auto-scaled per subplot. + figsize (tuple, optional): Overall figure size (width, height). If None, automatically determined. + wspace (float): Width space between subplots (fraction of subplot width). + hspace (float): Height space between subplots (fraction of subplot height). + """ + if trajectories.ndim != 4 or trajectories.shape[2] != 2: + print("Error: Input 'trajectories' must be a NumPy array of shape (N, H, 2, I).") + return None, None + + N_traj_per_grid, H, _, I_grids = trajectories.shape + + if I_grids == 0: + print("Error: No grids to plot (I_grids=0).") + return None, None + if H <= 1 and N_traj_per_grid > 0: + print("Warning: Horizon H <= 1, trajectories will not be visible as lines.") + + # Determine grid layout (square-ish) + ncols = int(math.ceil(math.sqrt(I_grids))) + nrows = int(math.ceil(I_grids / ncols)) + + # Determine figure size + _figsize = figsize + if _figsize is None: + subplot_width_default = 4 + subplot_height_default = 4 + max_total_width = 20 + max_total_height = 20 + + fig_width = min(ncols * subplot_width_default, max_total_width) + fig_height = min(nrows * subplot_height_default, max_total_height) + _figsize = (fig_width, fig_height) + + fig, axes = plt.subplots(nrows, ncols, figsize=_figsize, squeeze=False) + + # Tighter spacing right after creating subplots + fig.subplots_adjust( + left=0.05, right=0.95, + bottom=0.05, top=0.90, + wspace=wspace, hspace=hspace + ) + + cmap = cm.get_cmap(cmap_name) + if H > 1: + norm_values = np.linspace(0, 1, H - 1) + colors_for_one_trajectory = cmap(norm_values) + else: + colors_for_one_trajectory = np.array([]) + + for i in range(I_grids): + ax = axes[i // ncols, i % ncols] + current = trajectories[:, :, :, i] + + if N_traj_per_grid == 0 or H <= 1: + if N_traj_per_grid == 0: + ax.text(0.5, 0.5, "No data", ha='center', va='center', transform=ax.transAxes) + if xlim: ax.set_xlim(xlim) + if ylim: ax.set_ylim(ylim) + ax.set_xticks([]); ax.set_yticks([]) + continue + + starts = current[:, :-1, :] + ends = current[:, 1:, :] + segments = np.stack((starts, ends), axis=2).reshape(-1, 2, 2) + colors = np.tile(colors_for_one_trajectory, (N_traj_per_grid, 1)) + + lc = LineCollection(segments, colors=colors, linewidths=2) + ax.add_collection(lc) + ax.grid(True, linestyle='--', alpha=0.7) + ax.set_aspect('equal', adjustable='box') + + # Limits + if xlim is None or ylim is None: + xs = current[:, :, 0]; ys = current[:, :, 1] + mnx, mxx = xs.min(), xs.max() + mny, mxy = ys.min(), ys.max() + bx = (mxx - mnx) * 0.1 or 0.1 + by = (mxy - mny) * 0.1 or 0.1 + ax.set_xlim(xlim if xlim is not None else (mnx - bx, mxx + bx)) + ax.set_ylim(ylim if ylim is not None else (mny - by, mxy + by)) + else: + ax.set_xlim(xlim); ax.set_ylim(ylim) + + ax.set_xticks([]); ax.set_yticks([]) + + # Remove any extra axes + for j in range(I_grids, nrows * ncols): + fig.delaxes(axes[j // ncols, j % ncols]) + + fig.suptitle(title, fontsize=16) + + # Single colorbar + if H > 1: + sm = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(vmin=0, vmax=H - 1)) + sm.set_array([]) + cbar_ax = fig.add_axes([0.96, 0.15, 0.02, 0.7]) + cbar = fig.colorbar(sm, cax=cbar_ax) + cbar.set_label("Time (Horizon Step)") + + return fig, axes + + +def plot_trajectory_tuples( + trajectories: np.ndarray, + tup_size: int, + h_start: int, + h_end: int, + title: str = "Trajectory Tuples", + title_args: dict = {}, + cmap_name: str = 'viridis', + xlim: tuple = None, + ylim: tuple = None, + figsize: tuple = None, + wspace: float = 0.05, + hspace: float = 0.05 +) -> List[Tuple[plt.Figure, np.ndarray]]: + """Plots trajectories organized into tuples, with chunks and batches merged. + + This function visualizes trajectories from a stacked NumPy array. The data + in the first dimension (N) is assumed to be ordered by chunk, then by + tuple member, then by batch instance. The chunk and batch dimensions are + merged to create a single grid of subplots. A separate figure is generated + for each component along the last dimension (I). Each subplot shows + 'tup_size' trajectories. + + Args: + trajectories (np.ndarray): A NumPy array of shape (N, I, P, H, 2), where: + - N is the total batch size, equal to (n_chunks * tup_size * B). + - I is the number of components, each plotted in a separate figure. + - P is the number of agetns. + - H is the full horizon (number of points per trajectory). + - 2 represents (X, Y) coordinates. + tup_size (int): The number of trajectories that form a single tuple, + plotted together in one subplot. + n_chunks (int): The number of chunks the input 'trajectories' array + is conceptually divided into along the N dimension. + h_start (int): The starting time step to slice from the horizon H. + h_end (int): The ending time step to slice from the horizon H. + title (str): The base super-title for the figures. Each figure title + will be appended with its component index. + cmap_name (str): The name of the matplotlib colormap for the lines. + xlim (tuple, optional): Fixed (min, max) x-limits for all subplots. + If None, limits are auto-scaled for each subplot. + ylim (tuple, optional): Fixed (min, max) y-limits for all subplots. + If None, limits are auto-scaled for each subplot. + figsize (tuple, optional): Overall figure size (width, height) for each + figure. If None, it's automatically determined. + wspace (float): The width space between subplots. + hspace (float): The height space between subplots. + + Returns: + List[Tuple[plt.Figure, np.ndarray]]: A list of tuples, where each + tuple contains the matplotlib Figure and Axes objects for one + component (i in I). + """ + # --- Input Validation --- + if trajectories.ndim != 5 or trajectories.shape[3] != 2: + raise ValueError("Input 'trajectories' must be of shape (N, I, P, H, 2).") + + trajectories = trajectories.transpose(2, 0, 3, 4, 1) # Rearrange to (P, N, H, 2, I) + + P, N, H_full, _, I = trajectories.shape # (P, N, H, 2, I) + + if N == 0 or I == 0: + print("Warning: No data to plot (N=0 or I=0).") + return [] + + if tup_size <= 0: + raise ValueError("'tup_size' and 'n_chunks' must be positive integers.") + + total_subplots = N + + if not (0 <= h_start < h_end <= H_full): + raise ValueError("Invalid slicing with h_start and h_end.") + + # --- Data Preparation --- + # Slice the trajectories along the time horizon. + traj_sliced = trajectories[:, :, h_start:h_end, :2, :] + H = traj_sliced.shape[2] + + if H <= 1: + print("Warning: Sliced horizon H <= 1, lines will not be visible.") + + grouped_traj = traj_sliced + + # --- Plotting --- + output_figs = [] + cmap = cm.get_cmap(cmap_name) + + # Prepare the color array for a single trajectory. This is reused. + if H > 1: + norm_values = np.linspace(0, 1, H - 1) + colors_for_one_trajectory = cmap(norm_values) + else: + colors_for_one_trajectory = np.array([]) + + # Generate one figure for each component `i` in `I`. + for i in tqdm(range(I)): + # Data for the current figure, shape (total_subplots, tup_size, H, 2) + data_for_fig_i = grouped_traj[:, :, :, :, i] + + # Determine grid layout (square-ish) for the subplots. + if total_subplots > 0: + ncols = int(math.ceil(math.sqrt(total_subplots))) + nrows = int(math.ceil(total_subplots / ncols)) + else: + nrows, ncols = 1, 1 + + # Determine figure size automatically if not provided. + _figsize = figsize + if _figsize is None: + subplot_w, subplot_h = 4, 4 + max_w, max_h = 24, 24 + fig_width = min(ncols * subplot_w, max_w) + fig_height = min(nrows * subplot_h, max_h) + _figsize = (fig_width, fig_height) + + fig, axes = plt.subplots( + nrows, ncols, figsize=_figsize, squeeze=False + ) + fig.subplots_adjust( + left=0.05, right=0.9, bottom=0.05, top=0.9, + wspace=wspace, hspace=hspace + ) + + # Iterate through all subplots. + for i_subplot in range(total_subplots): + r, c = i_subplot // ncols, i_subplot % ncols + ax = axes[r, c] + + # Get the data for the current subplot, shape (tup_size, H, 2) + current_tuple = data_for_fig_i[:, i_subplot, :, :] + + if H <= 1: + ax.text(0.5, 0.5, "H<=1", ha='center', va='center') + else: + # Prepare segments for LineCollection. + starts = current_tuple[:, :-1, :] + ends = current_tuple[:, 1:, :] + segments = np.stack((starts, ends), axis=2).reshape(-1, 2, 2) + colors = np.tile(colors_for_one_trajectory, (tup_size, 1)) + lc = LineCollection(segments, colors=colors, linewidths=1.5) + ax.add_collection(lc) + + # --- Aesthetics and Limits --- + ax.grid(True, linestyle='--', alpha=0.6) + ax.set_aspect('equal', adjustable='box') + ax.set_xticks([]) + ax.set_yticks([]) + + if xlim is None or ylim is None: + if H > 0: + xs, ys = current_tuple[..., 0], current_tuple[..., 1] + mnx, mxx = xs.min(), xs.max() + mny, mxy = ys.min(), ys.max() + bx = (mxx - mnx) * 0.1 or 0.1 + by = (mxy - mny) * 0.1 or 0.1 + ax.set_xlim(xlim if xlim is not None else (mnx - bx, mxx + bx)) + ax.set_ylim(ylim if ylim is not None else (mny - by, mxy + by)) + else: + ax.set_xlim(xlim) + ax.set_ylim(ylim) + + # Hide unused axes + for j in range(total_subplots, nrows * ncols): + fig.delaxes(axes.flatten()[j]) + + title_str = f"{title}: Intial State {i}" + if len(title_args) > 0: + title_str += '\n' + '(' + ", ".join(f"{k}={v}" for k, v in title_args.items()) + ')' + fig.suptitle(title_str, fontsize=14) + + + # Add a single, shared colorbar to the figure. + if H > 1: + sm = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(vmin=h_start, vmax=h_end-1)) + sm.set_array([]) + cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7]) + cbar = fig.colorbar(sm, cax=cbar_ax) + cbar.set_label(f"Time Step ({h_start} to {h_end-1})") + + output_figs.append((fig, axes)) + + return output_figs + + +def plot_collision_masks(collision_masks:np.ndarray, safe_dist, safe_rates=None): + """ + Plot collision masks as a grid where each row is a batch sample and each column is a horizon step. + Green cells represent safe states, red cells represent collisions. + + Args: + collision_masks: np.ndarray of shape (N, I, P, P, H) - boolean masks for collisions + safe_dist: float - the safety distance threshold used for collision detection + + Returns: + fig, axes - matplotlib figure and axes objects + """ + raise NotImplementedError("This function is not implemented yet.") + N, I, P, _, H = collision_masks.shape + collision_masks = collision_masks.transpose(1, 2, 0, 3) # Rearrange to (I, P, N, H) + # Just use player 0 since both players have the same collision patterns + player_idx = 0 + + # Create a figure with subplots. One for each initial state. + fig_rows = int(np.ceil(I / 2)) + fig_cols = 2 + fig, axes = plt.subplots(fig_rows, fig_cols, figsize=(12, fig_rows * 3), squeeze=False) + fig.suptitle(f"Collision Masks by Initial State\n(Red=Collision, Green=Safe, Safe Dist={safe_dist})", fontsize=16) + + # Define a colormap: green for 0 (safe), red for 1 (collision) + cmap = plt.cm.colors.ListedColormap(['green', 'red']) + + for i in range(I): + row_idx = i // 2 + col_idx = i % 2 + ax = axes[row_idx, col_idx] + + # Convert the boolean mask to integers for plotting + mask_to_plot = collision_masks[i, player_idx].astype(int) + + # Display the data as an image, where each pixel corresponds to a boolean value + im = ax.imshow(mask_to_plot, cmap=cmap, aspect='auto', interpolation='none') + + ax.set_title(f"Initial State {i}") + ax.set_xlabel("Horizon Step") + ax.set_ylabel("Batch Sample") + + # Add text showing collision rate + if safe_rates is None: + collision_rate = np.mean(mask_to_plot) + safe_rate = 1.0 - collision_rate + else: + safe_rate = safe_rates[i, 0] + ax.text(0.98, 0.02, f"Safe: {safe_rate:.2%}", + transform=ax.transAxes, ha='right', va='bottom', + bbox=dict(facecolor='white', alpha=0.5)) + + # Remove any extra axes if odd number of initial states + if I % 2 != 0: + fig.delaxes(axes[fig_rows-1, 1]) + + # Add tight layout to ensure proper spacing and prevent overlap + plt.tight_layout(rect=[0, 0, 1, 0.96]) # Leave room for suptitle + return fig, axes + + +def plot_velocity_stats( + t, v_mean, v_std, v_max, v_min, v_mean_all, hard_deck, + title='(Normalized) Velocity Statistics Over Horizon', + subtitle='', + legend_loc='lower right', +): + fig, ax = plt.subplots(figsize=(10, 7.5)) + ax.plot(t, v_mean, label='Mean Velocity', color='blue') + ax.fill_between( + t, + v_mean - v_std, + v_mean + v_std, + color='blue', alpha=0.2, label='Std Dev' + ) + if hard_deck is not None and np.isfinite(hard_deck): + ax.axhline(hard_deck, color='red', linestyle='-', label=f'Constraint = {hard_deck:.4f}') + ax.plot(t, v_max, label=rf'Max Velocity $\leq$ {v_max.max():.4f}', color='purple', linestyle='--') + ax.plot(t, v_min, label='Min Velocity', color='green', linestyle='--') + ax.axhline(v_mean_all, color='orange', linestyle=':', label=f'Overall Mean Velocity = {v_mean_all:.4f}') + ax.set_title(subtitle, fontsize=14) + ax.set_xlabel('Time Step (Horizon)') + ax.set_ylabel('Velocity (units/s)') + ax.legend(loc=legend_loc, fontsize='14') + ax.grid(alpha=0.5) + fig.suptitle(title, fontsize=16) + fig.tight_layout() + return fig, ax + +def plot_velocity_histogram( + v, bins=40, figsize=(10, 6), quantiles=[], colors=None, + title='Histogram of (Normalized) Velocities', + subtitle='' + ): + """ + Plot a histogram of velocities with optional quantile lines. + + Args: + v (np.ndarray): Velocity array. + bins (int): Number of histogram bins. + figsize (tuple): Figure size. + quantiles (list): List of quantiles to plot (e.g., [0.9, 0.95]). + colors (list or None): List of colors for quantile lines. + """ + fig, ax = plt.subplots(figsize=figsize) + flat_v = v.flatten() + ax.hist(flat_v, bins=bins, color='skyblue', edgecolor='black', alpha=0.8, density=True) + if colors is None: + # Use default matplotlib tab10 colors if not provided + colors = matplotlib.colormaps['tab10'].colors + for i, q in enumerate(quantiles): + quantile_val = np.quantile(flat_v, q) + color = colors[i % len(colors)] + ax.axvline(quantile_val, color=color, linestyle='--', label=f'{int(q*100)}% Quantile: {quantile_val:.4f}') + ax.set_title(subtitle, fontsize=14) + ax.set_xlabel('Velocity (units/s)') + ax.set_ylabel('Density') + ax.grid(True, linestyle='--', alpha=0.5) + if len(quantiles) > 0: + ax.legend(fontsize=14) + fig.suptitle(title, fontsize=16) + plt.tight_layout() + return fig, ax diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/mmd_to_single_tile.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/mmd_to_single_tile.py new file mode 100644 index 0000000000000000000000000000000000000000..15463c023c22858de6bf90f808480a76e61fbcd0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/mmd_to_single_tile.py @@ -0,0 +1,454 @@ +""" +Convert MMD (CBS) results from Env* directories into the single_tile_* format +used by the PCD benchmark, including GIF generation. + +This creates: + single_tile_ENVNAME/agent___N/method___mmd/cost___None/TIMESTAMP-MMD_CBS_|_env=ENVNAME_agents=N/0/ + ├── single_tile_ENVNAME.gif + ├── results.json + ├── config.yaml + ├── raw_data.npz + └── processed_data.npz + +Usage: + python scripts/analyze/mmd_to_single_tile.py + python scripts/analyze/mmd_to_single_tile.py --env empty + python scripts/analyze/mmd_to_single_tile.py --dry-run + python scripts/analyze/mmd_to_single_tile.py --skip-gif +""" +import os +import re +import sys +import glob +import json +import pickle +import argparse +import logging +import numpy as np +import torch +from os.path import join, dirname, basename +from datetime import datetime + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +PROJECT_ROOT = dirname(dirname(dirname(os.path.abspath(__file__)))) +sys.path.insert(0, PROJECT_ROOT) +RESULTS_DIR = join(PROJECT_ROOT, "results") +TRAINED_MODELS_DIR = join(PROJECT_ROOT, "data_trained_models") +TENSOR_ARGS = {'dtype': torch.float32, 'device': 'cuda'} + +ENV_DIRS = { + "empty": "EnvEmpty2DRobotPlanarDiskRandom", + "highway": "EnvHighways2DRobotPlanarDiskRandom", + "conveyor": "EnvConveyor2DRobotPlanarDiskRandom", + "dropregion": "EnvDropRegion2DRobotPlanarDiskRandom", +} + +ENV_TO_SINGLE_TILE = { + "empty": "single_tile_empty", + "highway": "single_tile_highway", + "conveyor": "single_tile_conveyor", + "dropregion": "single_tile_dropregion", +} + +ENV_TO_MODEL_ID = { + "empty": "EnvEmpty2D-RobotPlanarDisk", + "highway": "EnvHighways2D-RobotPlanarDisk", + "conveyor": "EnvConveyor2D-RobotPlanarDisk", + "dropregion": "EnvDropRegion2D-RobotPlanarDisk", +} + + +def discover_mmd_directories(env_filter=None, min_date="2026-04-20"): + """Find all MMD result directories grouped by (env, n_agents).""" + entries = [] + for env_key, env_dir_name in ENV_DIRS.items(): + if env_filter and env_key != env_filter: + continue + env_path = join(RESULTS_DIR, env_dir_name) + if not os.path.isdir(env_path): + continue + for timestamp_dir in sorted(os.listdir(env_path)): + ts_path = join(env_path, timestamp_dir) + if not os.path.isdir(ts_path) or not re.match(r"\d{4}-\d{2}-\d{2}", timestamp_dir): + continue + if min_date and timestamp_dir < min_date: + continue + agent_dirs = glob.glob(join(ts_path, "num_agents___*")) + for agent_dir in agent_dirs: + match = re.search(r"num_agents___(\d+)", basename(agent_dir)) + if match: + n_agents = int(match.group(1)) + entries.append({ + "env_key": env_key, + "timestamp": timestamp_dir, + "abs_path": ts_path, + "n_agents": n_agents, + }) + return entries + + +def load_trial_result(trial_dir): + """Load results.pkl from a trial directory.""" + pkl_path = join(trial_dir, "results.pkl") + if not os.path.exists(pkl_path): + return None + with open(pkl_path, 'rb') as f: + return pickle.load(f) + + +def get_trial_base_dir(entry): + """Get the base trial directory for an entry.""" + return join(entry["abs_path"], + f"num_agents___{entry['n_agents']}", + "planner___CBS", + "single_agent_planner___MPD") + + +def has_any_trials(entry): + """Check if any trial directories with results.pkl exist.""" + base = get_trial_base_dir(entry) + if not os.path.isdir(base): + return False + for name in os.listdir(base): + if name.isdigit() and os.path.exists(join(base, name, "results.pkl")): + return True + return False + + +def pick_best_trial(entry): + """Pick the trial with best success status (first successful, or trial 0).""" + base = get_trial_base_dir(entry) + if not os.path.isdir(base): + return None, None + + trial_dirs = [] + for name in os.listdir(base): + if name.isdigit(): + trial_dirs.append((int(name), join(base, name))) + trial_dirs.sort(key=lambda x: x[0]) + + # Try to find a successful trial for the GIF + best_result = None + best_dir = None + for trial_num, trial_dir in trial_dirs: + result = load_trial_result(trial_dir) + if result is None: + continue + if best_result is None: + best_result = result + best_dir = trial_dir + if result.success_status and result.success_status.name == "SUCCESS": + best_result = result + best_dir = trial_dir + break + return best_result, best_dir + + +def get_task_for_env(env_key): + """Load the PlanningTask for an environment via the dataset.""" + from mmd.utils.loading import load_params_from_yaml + from mmd.trainer import get_dataset + + model_id = ENV_TO_MODEL_ID[env_key] + model_dir = join(TRAINED_MODELS_DIR, model_id) + args = load_params_from_yaml(join(model_dir, "args.yaml")) + + train_subset, _, _, _ = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=TENSOR_ARGS + ) + dataset = train_subset.dataset + return dataset.task, dataset.robot + + +def render_gif(task, paths_l, start_l, goal_l, output_path, n_agents): + """Render a GIF of the trajectories using PlanningVisualizer.""" + from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer + from mmd.common.trajectory_utils import densify_trajs + import matplotlib + matplotlib.use('Agg') + + planner_visualizer = PlanningVisualizer(task=task) + + # Generate colors for agents + import matplotlib.cm as cm + colors = [cm.tab20(i / max(1, n_agents - 1)) if n_agents > 1 else cm.tab20(0) + for i in range(n_agents)] + + # Densify for smoother animation + render_paths = densify_trajs(paths_l, 1) + # Add batch dim + render_paths_batched = [p.unsqueeze(0) for p in render_paths] + + n_frames = max(2, render_paths_batched[0].shape[1]) + planner_visualizer.animate_multi_robot_trajectories( + trajs_l=render_paths_batched, + start_state_l=start_l, + goal_state_l=goal_l, + plot_trajs=True, + video_filepath=output_path, + n_frames=n_frames, + anim_time=5.0, + colors=colors, + ) + + +def build_results_json(result, n_agents): + """Build a results.json dict from a MultiAgentPlanningSingleTrialResult.""" + d = { + "num_ct_expansions": int(result.num_ct_expansions), + "num_collisions_in_solution": int(result.num_collisions_in_solution), + "data_adherence": float(result.data_adherence), + "planning_time": float(result.planning_time), + "path_length_per_agent": float(result.path_length_per_agent), + "mean_path_acceleration_per_agent": float(result.mean_path_acceleration_per_agent), + "success_status": result.success_status.name if result.success_status else "UNKNOWN", + } + # Velocity lists + for attr in ['max_velocity_l', 'mean_velocity_l']: + val = getattr(result, attr, []) + if val: + d[attr] = [float(v) if not hasattr(v, 'item') else v.item() for v in val] + else: + d[attr] = [] + + d["global_model_ids"] = result.global_model_ids if result.global_model_ids else [] + d["agent_skeleton_l"] = result.agent_skeleton_l if result.agent_skeleton_l else [[[0, 0]]] * n_agents + d["start_state_pos_l"] = "raw_data.starts_global" + d["goal_state_pos_l"] = "raw_data.goals_global" + d["agent_path_l"] = "raw_data.all_state_trjs" + return d + + +def build_config_yaml(result, n_agents, env_key): + """Build a config dict matching the PCD format.""" + cfg = result.trial_config + return { + "num_agents": n_agents, + "dt": getattr(cfg, 'dt', 0.1), + "planner_class": "CBS", + "single_agent_planner_class": "MPD", + "instance_name": ENV_DIRS[env_key], + } + + +def _compute_and_save_stats(entry, all_state_trjs, out_dir, task_cache): + """Compute collision masks, velocity, data adherence and save processed_data.npz + summary_stats JSON.""" + from mmd.common.eval_utils import collision_detect, calc_data_adherence, calc_velocity + + env_key = entry["env_key"] + n_agents = entry["n_agents"] + B, I, P, H, D = all_state_trjs.shape + + # Load task/robot for collision checking and normalization + if env_key not in task_cache: + logger.info(f"Loading task for {env_key}...") + task_cache[env_key] = get_task_for_env(env_key) + task, robot = task_cache[env_key] + + # Inter-agent collisions + collision_masks = collision_detect(all_state_trjs[..., :2], safe_dist=robot.radius * 2, norm_order=2) + cnt_coll = np.sum(collision_masks.any(-1).astype(int), axis=(-2, -1)) + cnt_coll = cnt_coll // 2 # pairs counted twice + + # Static obstacle collisions + trajs_tensor = torch.from_numpy(all_state_trjs).reshape(B * I * P, H, -1).to(**TENSOR_ARGS) + _, _, _, _, trajs_wp_coll = task.get_trajs_collision_and_free( + trajs_tensor, return_indices=True, num_interpolation=0) + obstacle_collision_masks = trajs_wp_coll.cpu().numpy().reshape(B, I, P, H) + + # Velocity (on normalized trajectories) + dataset = task_cache[env_key] # (task, robot) tuple - need dataset for normalization + # Use simple finite-difference velocity on position + traj_pos = all_state_trjs[..., :2] + dt = 5.0 / H # default traj_duration / horizon + velocities = calc_velocity(traj_pos, dt=dt) + velocities = np.concatenate([np.zeros(velocities.shape[:-1] + (1,)), velocities], axis=-1) + + # Data adherence placeholder (requires model loading - use result value if available) + data_adherence = np.ones((B, I, P), dtype=np.float32) + + # Save processed_data.npz + np.savez_compressed(join(out_dir, "processed_data.npz"), + collision_masks=collision_masks, + obstacle_collision_masks=obstacle_collision_masks, + velocity_trjs=velocities, + data_adherence=data_adherence) + + # Compute summary stats + # Safety rates + safe_masks = ~collision_masks # [B, I, P, P, H] + trj_safe = safe_masks.all(-1) # [B, I, P, P] + strict_safe = trj_safe.all(axis=(-1, -2)) # [B, I] + overall_strict_safe_rate = float(strict_safe.mean()) + loose_safe = (trj_safe.astype(float).sum(axis=(-1, -2)) - P) / (P * (P - 1)) + overall_loose_safe_rate = float(loose_safe.mean()) + + # Obstacle safety + static_safe = ~obstacle_collision_masks.any(-1) # [B, I, P] + overall_static_safe = float(static_safe.astype(float).mean()) + + # Combined safety + agent_strict_safe = trj_safe.all(-1) # [B, I, P] + all_safe = np.logical_and(agent_strict_safe, static_safe) + overall_all_safe = float(all_safe.astype(float).mean()) + + # Success rate + success_masks = all_safe.all(-1) # [B, I] + best_success = success_masks.max(axis=0) if B > 1 else success_masks.squeeze(0) + success_rate = float(np.mean(best_success.astype(float))) + + # Use a default vmax (0.8) for the summary stats filename + # The notebook will recompute vel_constraint_sat for each actual vmax + default_vmax = 0.8 + vel_sat = velocities <= default_vmax + 5e-5 + vel_sat_per_traj = np.all(vel_sat, axis=-1) + overall_vel_sat = float(vel_sat_per_traj.astype(float).mean()) + + summary = { + "Overall Mean Collision Count": float(cnt_coll.mean()), + "Overall Mean Strict Inter-Agent Safe Rate": overall_strict_safe_rate, + "Overall Mean Loose Inter-Agent Safe Rate": overall_loose_safe_rate, + "Overall Mean Static Obstacle Safe Rate": overall_static_safe, + "Overall Mean All Safe Rate": overall_all_safe, + "Overall Success Rate": success_rate, + "Overall Mean Velocity Constraint Satisfaction": overall_vel_sat, + "Max Velocity": default_vmax, + "num_agents": n_agents, + } + + stats_path = join(out_dir, f"summary_stats_vmax{default_vmax}.json") + with open(stats_path, 'w') as f: + json.dump([summary], f, indent=4) + logger.info(f" Stats saved: {os.path.relpath(stats_path, PROJECT_ROOT)}") + + +def process_entry(entry, task_cache, skip_gif=False): + """Process a single MMD result directory into single_tile format.""" + env_key = entry["env_key"] + n_agents = entry["n_agents"] + timestamp = entry["timestamp"] + + # Pick best trial for GIF + result, trial_dir = pick_best_trial(entry) + if result is None: + logger.warning(f"No results found: {entry['abs_path']}") + return False + + # Build output directory + tile_name = ENV_TO_SINGLE_TILE[env_key] + ts_label = datetime.strptime(timestamp, "%Y-%m-%d-%H-%M-%S").strftime("%m%d%Y_%H%M%S") + run_dir_name = f"{ts_label}-MMD_CBS_|_env={env_key}_agents={n_agents}" + out_dir = join(RESULTS_DIR, tile_name, + f"agent___{n_agents}", + "method___mmd", + "cost___None", + run_dir_name, + "0") + + os.makedirs(out_dir, exist_ok=True) + + # Save results.json + results_json = build_results_json(result, n_agents) + with open(join(out_dir, "results.json"), 'w') as f: + json.dump(results_json, f, indent=4) + + # Save config.yaml + import yaml + config_dict = build_config_yaml(result, n_agents, env_key) + with open(join(out_dir, "config.yaml"), 'w') as f: + yaml.dump(config_dict, f, default_flow_style=False) + + # Save raw_data.npz (trajectories, starts, goals) + paths_np = torch.stack(result.agent_path_l, dim=0).cpu().numpy() # [P, H, D] + starts_np = np.stack([s.cpu().numpy() if torch.is_tensor(s) else np.array(s) + for s in result.start_state_pos_l], axis=0) # [P, 2] + goals_np = np.stack([g.cpu().numpy() if torch.is_tensor(g) else np.array(g) + for g in result.goal_state_pos_l], axis=0) # [P, 2] + all_state_trjs = paths_np[np.newaxis, np.newaxis, ...] # [1, 1, P, H, D] + np.savez_compressed(join(out_dir, "raw_data.npz"), + all_state_trjs=all_state_trjs, + starts_global=starts_np, + goals_global=goals_np) + + # Compute processed data and summary stats + _compute_and_save_stats(entry, all_state_trjs, out_dir, task_cache) + + # Render GIF + if not skip_gif: + if env_key not in task_cache: + logger.info(f"Loading task for {env_key}...") + task_cache[env_key] = get_task_for_env(env_key) + + task, robot = task_cache[env_key] + gif_path = join(out_dir, f"{tile_name}.gif") + + paths_l = result.agent_path_l + # Ensure tensors are on correct device + paths_l = [p.to(**TENSOR_ARGS) if torch.is_tensor(p) else torch.tensor(p, **TENSOR_ARGS) + for p in paths_l] + start_l = [s.to(**TENSOR_ARGS) if torch.is_tensor(s) else torch.tensor(s, **TENSOR_ARGS) + for s in result.start_state_pos_l] + goal_l = [g.to(**TENSOR_ARGS) if torch.is_tensor(g) else torch.tensor(g, **TENSOR_ARGS) + for g in result.goal_state_pos_l] + + try: + render_gif(task, paths_l, start_l, goal_l, gif_path, n_agents) + logger.info(f" GIF saved: {os.path.relpath(gif_path, PROJECT_ROOT)}") + except Exception as e: + logger.error(f" GIF rendering failed: {e}") + + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Convert MMD results to single_tile format with GIFs.") + parser.add_argument("--env", choices=list(ENV_DIRS.keys()), + help="Filter by environment") + parser.add_argument("--dry-run", action="store_true", + help="Show what would be created") + parser.add_argument("--skip-gif", action="store_true", + help="Skip GIF rendering (just create metadata)") + parser.add_argument("--min-date", default="2026-04-20", + help="Ignore timestamps before this date") + args = parser.parse_args() + + entries = discover_mmd_directories(env_filter=args.env, min_date=args.min_date) + logger.info(f"Discovered {len(entries)} MMD result directories.") + + if args.dry_run: + for e in entries: + env_key = e["env_key"] + n_agents = e["n_agents"] + timestamp = e["timestamp"] + tile_name = ENV_TO_SINGLE_TILE[env_key] + ts_label = datetime.strptime(timestamp, "%Y-%m-%d-%H-%M-%S").strftime("%m%d%Y_%H%M%S") + run_dir_name = f"{ts_label}-MMD_CBS_|_env={env_key}_agents={n_agents}" + out_rel = join(tile_name, f"agent___{n_agents}", "method___mmd", "cost___None", run_dir_name, "0") + has_data = has_any_trials(e) + status = "OK" if has_data else "NO DATA" + print(f" [{status}] {out_rel}") + return + + task_cache = {} + success = 0 + for i, entry in enumerate(entries): + logger.info(f"[{i+1}/{len(entries)}] {entry['env_key']} / {entry['n_agents']} agents / {entry['timestamp']}") + try: + if process_entry(entry, task_cache, skip_gif=args.skip_gif): + success += 1 + except Exception as e: + logger.error(f" FAILED: {e}") + continue + + logger.info(f"Done. {success}/{len(entries)} directories processed.") + + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/process_mmd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/process_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..8035a87255351e0671ebcef965c1c477c5f36607 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/process_mmd.py @@ -0,0 +1,242 @@ +import pickle +import torch +import numpy as np +import os +import yaml +from tqdm import tqdm +from datetime import datetime +from os.path import join, dirname + +from torch_robotics.tasks.tasks import PlanningTask +from mmd.utils.loading import load_params_from_yaml +from mmd.trainer import get_dataset +from mmd.datasets.trajectories import TrajectoryDataset +from mmd.config.mmd_params import MMDParams +from mmd.common.eval_utils import collision_detect, calc_data_adherence, calc_velocity +from mmd.common.experiments.experiments import MultiAgentPlanningSingleTrialResult, MultiAgentPlanningSingleTrialConfig + +PROJECT_ROOT = dirname(dirname(dirname(__file__))) +TRAINED_MODELS_DIR = join(PROJECT_ROOT, "data_trained_models") +TENSOR_ARGS = {'dtype': torch.float32, 'device': 'cuda'} + + +def get_dataset_from_cfg(cfg:MultiAgentPlanningSingleTrialConfig): + model_id = cfg.global_model_ids[0][0] + model_dir = os.path.join(TRAINED_MODELS_DIR, model_id) + + args = load_params_from_yaml(os.path.join(model_dir, "args.yaml")) + + #################################### + # Load dataset with env, robot, task. The TrajectoryDataset type is used here. + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.05, + **args, + tensor_args=TENSOR_ARGS + ) + # Extract objects from the dataset. + dataset = train_subset.dataset + return dataset + +def get_args_from_cfg(num_agents:int, cfg:MultiAgentPlanningSingleTrialConfig): + start_time_l = [i * cfg.stagger_start_time_dt for i in range(num_agents)] + global_model_ids = cfg.global_model_ids + tile_width, tile_height = 2.0, 2.0 + global_model_transforms = [[torch.tensor([x * tile_width, -y * tile_height], **TENSOR_ARGS) for x in range(len(global_model_ids[0]))] for y in range(len(global_model_ids))] + agent_skeleton_l = [[[0, 0]]] * num_agents + agent_model_transforms_l, agent_model_ids_l = [], [] + for agent_id in range(num_agents): + agent_transforms = { + sk_step: global_model_transforms[sk_coord[0]][sk_coord[1]] + for sk_step, sk_coord in enumerate(agent_skeleton_l[agent_id]) + } + agent_ids = [ + global_model_ids[sk_coord[0]][sk_coord[1]] + for sk_coord in agent_skeleton_l[agent_id] + ] + agent_model_transforms_l.append(agent_transforms) + agent_model_ids_l.append(agent_ids) + return start_time_l, agent_model_transforms_l, agent_model_ids_l + + +def main(n_agents_ls:list, data_base_dir:str): + print(f"Project root: {PROJECT_ROOT}") + if not os.path.isabs(data_base_dir): + data_base_dir = join(PROJECT_ROOT, data_base_dir) + + for num_agents in n_agents_ls: + print(f"Number of agents: {num_agents}") + path_plate = f"num_agents___{num_agents}/planner___CBS/single_agent_planner___MPD" + data_dir = join(data_base_dir, path_plate) + print(f"Data directory: {data_dir}") + + all_paths = [] + single_trial_configs = [] + traj_duration_ls = [] + all_starts_l = [] + all_goals_l = [] + + # get all the directories in the data_dir + trial_dirs= [] + for root, dirs, files in os.walk(data_dir): + for dir_name in dirs: + if dir_name.isdigit(): + trial_dirs.append(dir_name) + if not trial_dirs: + print(f"Warning: No trial directories found in \'{data_dir}\'. ") + continue + trial_dirs.sort(key=int) + missing_ids = [] + for dir_name in tqdm(trial_dirs): + trial_dir = join(data_dir, dir_name) + pkl_pth = join(trial_dir, "results.pkl") + if os.path.exists(pkl_pth): + with open(pkl_pth, 'rb') as f: + trial_result: MultiAgentPlanningSingleTrialResult = pickle.load(f) + all_paths.append(torch.stack(trial_result.agent_path_l, dim=0).to('cpu').numpy()) # [P, H, D] + all_starts_l.append(np.stack(trial_result.start_state_pos_l, axis=0)) # [P, D] + all_goals_l.append(np.stack(trial_result.goal_state_pos_l, axis=0)) # [P, D] + assert all_starts_l[-1].shape == (num_agents, 2), f"Unexpected start shape: {all_starts_l[-1].shape}" + assert all_goals_l[-1].shape == (num_agents, 2), f"Unexpected goal shape: {all_goals_l[-1].shape}" + single_trial_configs.append(trial_result.trial_config) + else: + print(f"Warning: 'results.pkl' not found in {trial_dir}. Skipping this trial.") + missing_ids.append(int(dir_name)) + continue + mmd_params_pth = join(trial_dir, "mmd_params.yaml") + if os.path.exists(mmd_params_pth): + with open(mmd_params_pth, 'r') as f: + mmd_params = yaml.safe_load(f) + traj_duration = mmd_params.get('traj_duration', 5.0) + traj_duration_ls.append(float(traj_duration)) + else: + print(f"Warning: mmd_params.yaml not found in {trial_dir}") + traj_duration_ls.append(5.0) + + missing_id_array = None + if missing_ids: + print(f"Warning: Missing trial directories for IDs: {missing_ids}") + missing_id_array = np.array(missing_ids) + + all_paths_array = np.stack(all_paths, axis=0)[np.newaxis, ...] # [B, I, P, H, D] + print(f"All paths shape: {all_paths_array.shape}") + print(f"(Shape for refrence: [Batch, Init, Player, Horizon, Dim])") + all_starts_array = np.stack(all_starts_l, axis=0) # [I, P, D] + all_goals_array = np.stack(all_goals_l, axis=0) # [I, P, D] + assert all_starts_array.shape[0] == all_paths_array.shape[1] == all_goals_array.shape[0], \ + f"Mismatch in number of initializations: starts {all_starts_array.shape[0]}, paths {all_paths_array.shape[1]}, goals {all_goals_array.shape[0]}" + + # check whether all traj_durations are the same + traj_duration_set = set(traj_duration_ls) + if len(traj_duration_set) > 1: + print(f"Warning: Different trajectory durations found: {traj_duration_set}. Using the first one.") + traj_duration = traj_duration_ls[0] + + B, I, P, H, D = all_paths_array.shape + assert P == num_agents, f"Expected P (number of players) to be equal to num_agents ({num_agents}), got {P}." + + dataset:TrajectoryDataset = get_dataset_from_cfg(single_trial_configs[0]) + robot = dataset.robot + task:PlanningTask = dataset.task + robot.dt = traj_duration / H + + # Check for inter-agent collisions in the final solution + collision_masks = collision_detect(all_paths_array[..., :2], safe_dist = robot.radius * 2, norm_order=2) # [B, I, N, N, H] + cnt_coll_trjs = np.sum(collision_masks.any(-1).astype(int), axis=(-2, -1)) # Count collisions per batch per init [B, I,] + assert cnt_coll_trjs.shape == (B, I), f"Expected collision count shape {(B,)}, got {cnt_coll_trjs.shape}" + assert np.all(cnt_coll_trjs % 2 == 0), "Collision count should be even (pairs of agents)." + cnt_coll_trjs //= 2 # Each collision was counted twice + print(f"Count of collisions shape: {cnt_coll_trjs.shape}") + + # Check for static obstacle collisions in the final solution + n_interpolated_points = 0 + trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions = \ + task.get_trajs_collision_and_free( + torch.from_numpy(all_paths_array).reshape(B*I*P, H, -1).to(**TENSOR_ARGS), # Reshape to (B*I*P, H, D) + return_indices=True, + num_interpolation=n_interpolated_points, + ) + obstacle_collision_masks:np.ndarray = trajs_waypoints_collisions.to('cpu').numpy().reshape(B, I, P, H) # (B, I, P, H) + print(f"Obstacle collision masks shape: {obstacle_collision_masks.shape}") + + ## Get data adherence + start_time_l, agent_model_transforms_l, agent_model_ids_l = get_args_from_cfg(num_agents, single_trial_configs[0]) + data_adherence_batch = calc_data_adherence( + all_paths_array, + agent_model_ids_l, + agent_model_transforms_l, + start_time_l, + H, + TENSOR_ARGS + ) # [B, I, P] + print(f"Data adherence shape: {data_adherence_batch.shape}") + + ### normalize all paths to get the normalized velocities + normed_trjs = dataset.normalize_trajectories( + torch.from_numpy(all_paths_array).to(**TENSOR_ARGS), + ).to('cpu').numpy() # [B, I, P, H, D] + traj_pos = normed_trjs[..., :2] # [B, I, P, H, 2] + velocities = calc_velocity(traj_pos, dt=robot.dt) # [B, I, P, H-1] + velocities = np.concatenate( + [np.zeros(velocities.shape[:-1]+(1,)), velocities], + axis=-1 + ) + print(f"Velocities shape: {velocities.shape}") # [B, I, P, H] + + raw_data = { + "all_state_trjs": all_paths_array, # [B, I, P, H, D] + "starts_global": all_starts_array, # [I, P, D] + "goals_global": all_goals_array, # [I, P, D] + } + if missing_id_array is not None: + raw_data["missing_ids"] = missing_id_array # [M,] + processed_data = { + "collision_masks": collision_masks, # [B, I, N, N, H] + "cnt_coll_trjs": cnt_coll_trjs, # [B, I] + "obstacle_collision_masks": obstacle_collision_masks, # [B, I, P, H] + "data_adherence": data_adherence_batch, # [B, I, P] + "velocity_trjs": velocities # [B, I, P, H] + } + + raw_data_pth = join(data_base_dir, f"raw_data_{num_agents}_agents.npz") + np.savez_compressed( + raw_data_pth, + **raw_data, + ) + print(f"Raw data saved to \'{raw_data_pth}\'.") + + processed_data_pth = join(data_base_dir, f"processed_data_{num_agents}_agents.npz") + np.savez_compressed( + processed_data_pth, + **processed_data, + ) + print(f"Processed data saved to \'{processed_data_pth}\'.") + #end for [num_agents] + + +if __name__ == "__main__": + list_n_agents_ls = [ + [4], + # [2], + ] + list_data_base_dirs = [ + "results/EnvConveyor2DRobotPlanarDiskRandom/2026-04-14-19-26-50", + # "results/EnvConveyor2DRobotPlanarDiskRandom/some_other_run_dir", + ] + assert len(list_n_agents_ls) == len(list_data_base_dirs), \ + "Number of agent lists must match number of data base directories." + + # Create a dummy log file and write the directories into it for record-keeping + time_str = datetime.now().strftime("%m%d%Y_%H%M%S") + log_file_pth = join(PROJECT_ROOT, dirname(list_data_base_dirs[0]), f"dummy_logs-{time_str}.log") + with open(log_file_pth, "w") as f: + f.write("") + print(f"Dummy log file created at: {log_file_pth}") + + for n_agents_ls, data_base_dir in zip(list_n_agents_ls, list_data_base_dirs): + main(n_agents_ls, data_base_dir) + with open(log_file_pth, "a") as f: + f.write(f"{data_base_dir}\n") + + print(f"Log file at '{log_file_pth}' updated with data directories.") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_all_mmd_postprocessing.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_all_mmd_postprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..3f54fa6434beb09849ce47c0fd21522a22b9cc30 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_all_mmd_postprocessing.py @@ -0,0 +1,189 @@ +""" +Auto-discover and post-process all MMD result directories. + +Step 1: process_mmd.py -> generates raw_data_*.npz + processed_data_*.npz +Step 2: analyzing_mmd.py -> generates summary stats JSON + +Usage: + python scripts/analyze/run_all_mmd_postprocessing.py + python scripts/analyze/run_all_mmd_postprocessing.py --dry-run + python scripts/analyze/run_all_mmd_postprocessing.py --env conveyor + python scripts/analyze/run_all_mmd_postprocessing.py --skip-process + python scripts/analyze/run_all_mmd_postprocessing.py --force +""" +import os +import re +import glob +import argparse +import logging +from os.path import join, dirname, basename + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +PROJECT_ROOT = dirname(dirname(dirname(os.path.abspath(__file__)))) +RESULTS_DIR = join(PROJECT_ROOT, "results") + +ENV_DIRS = { + "empty": "EnvEmpty2DRobotPlanarDiskRandom", + "highways": "EnvHighways2DRobotPlanarDiskRandom", + "conveyor": "EnvConveyor2DRobotPlanarDiskRandom", + "dropregion": "EnvDropRegion2DRobotPlanarDiskRandom", +} + +DEFAULT_MAX_VEL = { + "empty": 0.703, + "highways": 0.878, + "conveyor": 1.76, + "dropregion": 1.34, +} + + +def discover_directories(env_filter=None, min_date="2026-04-20"): + """Auto-discover all result directories and their agent counts.""" + entries = [] + for env_key, env_dir_name in ENV_DIRS.items(): + if env_filter and env_key != env_filter: + continue + env_path = join(RESULTS_DIR, env_dir_name) + if not os.path.isdir(env_path): + logger.warning(f"Environment directory not found: {env_path}") + continue + for timestamp_dir in sorted(os.listdir(env_path)): + ts_path = join(env_path, timestamp_dir) + if not os.path.isdir(ts_path) or not re.match(r"\d{4}-\d{2}-\d{2}", timestamp_dir): + continue + if min_date and timestamp_dir < min_date: + continue + agent_dirs = glob.glob(join(ts_path, "num_agents___*")) + for agent_dir in agent_dirs: + match = re.search(r"num_agents___(\d+)", basename(agent_dir)) + if match: + n_agents = int(match.group(1)) + rel_path = os.path.relpath(ts_path, PROJECT_ROOT) + entries.append({ + "env_key": env_key, + "abs_path": ts_path, + "rel_path": rel_path, + "n_agents": n_agents, + }) + return entries + + +def has_npz_files(abs_path, n_agents): + raw = join(abs_path, f"raw_data_{n_agents}_agents.npz") + processed = join(abs_path, f"processed_data_{n_agents}_agents.npz") + return os.path.exists(raw) and os.path.exists(processed) + + +def run_process_step(entries, force=False, dry_run=False): + """Step 1: Run process_mmd.main() for each directory.""" + from process_mmd import main as process_main + + to_process = [] + for entry in entries: + if not force and has_npz_files(entry["abs_path"], entry["n_agents"]): + logger.info(f"SKIP (already processed): {entry['rel_path']} [{entry['n_agents']} agents]") + continue + to_process.append(entry) + + if dry_run: + logger.info(f"Would process {len(to_process)} directories:") + for e in to_process: + print(f" {e['rel_path']} [{e['n_agents']} agents]") + return + + logger.info(f"Processing {len(to_process)} directories...") + for i, entry in enumerate(to_process): + logger.info(f"[{i+1}/{len(to_process)}] Processing: {entry['rel_path']} [{entry['n_agents']} agents]") + try: + process_main([entry["n_agents"]], entry["rel_path"]) + except Exception as e: + logger.error(f"FAILED: {entry['rel_path']}: {e}") + continue + + +def run_analyze_step(entries, dry_run=False): + """Step 2: Run analyzing_mmd.run_analysis_for_directory() for each directory.""" + from analyzing_mmd import run_analysis_for_directory + + to_analyze = [] + for entry in entries: + if not has_npz_files(entry["abs_path"], entry["n_agents"]): + logger.warning(f"SKIP (no npz files): {entry['rel_path']} [{entry['n_agents']} agents]") + continue + to_analyze.append(entry) + + if dry_run: + logger.info(f"Would analyze {len(to_analyze)} directories:") + for e in to_analyze: + print(f" {e['rel_path']} [{e['n_agents']} agents]") + return + + logger.info(f"Analyzing {len(to_analyze)} directories...") + for i, entry in enumerate(to_analyze): + max_vel = DEFAULT_MAX_VEL[entry["env_key"]] + config = { + "switches": { + "plot_trajectories": False, + "plot_velocity_stats": False, + "save_plots": False, + "save_stats_json": True, + }, + "constants": { + "EPS": 1e-6, + "VEL_TOL": 5e-5, + "DEFAULT_MAX_VEL": max_vel, + "VEL_HIST_QUANTILES": [0.75, 0.8, 0.85, 0.9, 0.95, 0.99], + }, + "plotting": { + "num_inits_plot": 10, + "num_trj_tups_plot": 64, + "traj_plot_xlim": [-1, 1], + "traj_plot_ylim": [-1, 1], + "traj_plot_h_end": 64, + "vel_hist_bins": 100, + }, + "data": { + "default_trained_models_dir": "default_trained_models_dir", + }, + } + logger.info(f"[{i+1}/{len(to_analyze)}] Analyzing: {entry['rel_path']} [{entry['n_agents']} agents, max_vel={max_vel}]") + try: + run_analysis_for_directory(entry["rel_path"], config, base_dir=PROJECT_ROOT) + except Exception as e: + logger.error(f"FAILED: {entry['rel_path']}: {e}") + continue + + +def main(): + parser = argparse.ArgumentParser(description="Auto post-process all MMD result directories.") + parser.add_argument("--env", choices=list(ENV_DIRS.keys()), help="Filter by environment") + parser.add_argument("--dry-run", action="store_true", help="Show what would be processed without running") + parser.add_argument("--skip-process", action="store_true", help="Skip step 1 (process_mmd)") + parser.add_argument("--skip-analyze", action="store_true", help="Skip step 2 (analyzing_mmd)") + parser.add_argument("--force", action="store_true", help="Reprocess directories that already have .npz files") + parser.add_argument("--min-date", default="2026-04-20", help="Ignore timestamp dirs before this date (default: 2026-04-20)") + args = parser.parse_args() + + entries = discover_directories(env_filter=args.env, min_date=args.min_date) + logger.info(f"Discovered {len(entries)} result directories.") + + if args.dry_run: + for e in entries: + status = "DONE" if has_npz_files(e["abs_path"], e["n_agents"]) else "TODO" + print(f" [{status}] {e['rel_path']} [{e['n_agents']} agents]") + print(f"\nTotal: {len(entries)} directories") + return + + if not args.skip_process: + run_process_step(entries, force=args.force, dry_run=False) + + if not args.skip_analyze: + run_analyze_step(entries, dry_run=False) + + logger.info("Done.") + + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_mmd_to_single_tile.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_mmd_to_single_tile.sh new file mode 100644 index 0000000000000000000000000000000000000000..7f924ec9878e20873693576dd32e1a2d4db457f7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/run_mmd_to_single_tile.sh @@ -0,0 +1,18 @@ +#!/bin/bash +#SBATCH --job-name=mmd_to_tile +#SBATCH --partition=YOUR_GPU_PARTITION +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=0-12:00:00 +#SBATCH --output=slurm_logs/%j_%x.out +#SBATCH --error=slurm_logs/%j_%x.err + +mkdir -p slurm_logs + +source "${CONDA_BASE:-$HOME/miniconda3}/etc/profile.d/conda.sh" +conda activate direct-mmd + +cd "$(dirname "$0")/../.." + +python scripts/analyze/mmd_to_single_tile.py diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/score_direct_cell.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/score_direct_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..f2aac5c4b55e9cc3afb72b786ccb9bf0045d2b98 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/analyze/score_direct_cell.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +""" +Post-hoc scorer for one DiRecT cell. + +inference_pcdiff.evaluate_and_save_results computes collisions and data +adherence but NEVER checks goal arrival, so SOLVED cannot come out of the +run itself. Everything needed is in raw_data.npz / processed_data.npz, so +we recompute the full metric set here. + + SR collision-free (inter-agent AND static obstacle), best-of-B per init + SOLVED collision-free AND every agent's final position within tol of goal, + reported at tol = 0.05 and 0.10 + CS fraction of ALL candidate samples that are feasible + Coll mean number of colliding agent-pairs per candidate + DA data adherence + Time per-init wall seconds (mean/std) from the driver's timing json +""" +import sys, os, json, glob +import numpy as np + +def score(d, timing_json=None): + raw = np.load(os.path.join(d, "raw_data.npz")) + pro = np.load(os.path.join(d, "processed_data.npz")) + trj = raw["all_state_trjs"] # [B,I,N,H,D] + goals = raw["goals_global"] # [I,N,2] + coll = pro["collision_masks"] # [B,I,N,N,H] + ocoll = pro["obstacle_collision_masks"] # [B,I,N,H] + da = pro["data_adherence"] # [B,I,N] + B, I, N, H, D = trj.shape + + # ---- per-candidate (b,i) feasibility ---- + pair = coll.any(-1) # [B,I,N,N] + iu = np.triu_indices(N, k=1) + n_pairs_coll = pair[:, :, iu[0], iu[1]].sum(-1) # [B,I] + inter_free = n_pairs_coll == 0 # [B,I] + obs_free = ~ocoll.any(-1).any(-1) # [B,I] + free = inter_free & obs_free # [B,I] + + # ---- goal arrival ---- + final = trj[..., -1, :2] # [B,I,N,2] + dist = np.linalg.norm(final - goals[None], axis=-1) # [B,I,N] + out = {} + for tol in (0.05, 0.10): + reach = (dist <= tol).all(-1) # [B,I] + out[f"SOLVED@{tol:.2f}"] = float((free & reach).any(0).mean()) + out[f"CS_solved@{tol:.2f}"] = float((free & reach).mean()) + + out["SR"] = float(free.any(0).mean()) # best-of-B per init + out["CS"] = float(free.mean()) # all candidates + out["CS_agent"] = float((~ocoll.any(-1)).mean()) # per agent-sample, obstacle only + out["Coll_mean"] = float(n_pairs_coll.mean()) + out["Coll_std"] = float(n_pairs_coll.std()) + out["DA_mean"] = float(da.mean()) + out["DA_std"] = float(da.std()) + out["goal_err_mean"] = float(dist.mean()) + out["goal_err_min_over_B"] = float(dist.max(-1).min(0).mean()) # best candidate's worst agent + out["shape"] = dict(B=int(B), I=int(I), N=int(N), H=int(H)) + + if timing_json and os.path.exists(timing_json): + t = json.load(open(timing_json)) + pi = t.get("per_init") or [] + out["Time_s_mean"] = float(np.mean(pi)) if pi else None + out["Time_s_std"] = float(np.std(pi)) if pi else None + out["Time_s_steady"] = t.get("steady_mean_s") + out["jit_overhead_s"] = t.get("jit_overhead_s") + out["total_wall_s"] = t.get("total_wall_s") + out["robot_radius"] = t.get("robot_radius") + return out + +if __name__ == "__main__": + d = sys.argv[1] + if not os.path.exists(os.path.join(d, "raw_data.npz")): + cands = sorted(glob.glob(os.path.join(d, "**", "raw_data.npz"), recursive=True), + key=os.path.getmtime) + if not cands: sys.exit(f"no raw_data.npz under {d}") + d = os.path.dirname(cands[-1]) + print(f"# {d}") + print(json.dumps(score(d, sys.argv[2] if len(sys.argv) > 2 else None), indent=2)) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/generate_trajectories.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/generate_trajectories.py new file mode 100644 index 0000000000000000000000000000000000000000..a14d6e728d113ccd3f0277f97fbd78d333c0b28f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/generate_trajectories.py @@ -0,0 +1,801 @@ +""" +Adapted from https://github.com/jacarvalho/mpd-public +""" +import os +import pickle +import time +from pathlib import Path + +import torch +import yaml +from matplotlib import pyplot as plt +from typing import List + +# Project includes. +from experiment_launcher import single_experiment_yaml, run_experiment +from experiment_launcher.utils import fix_random_seed +from mp_baselines.planners.gpmp2 import GPMP2 +from mp_baselines.planners.costs.cost_functions import * +from mp_baselines.planners.hybrid_planner import HybridPlanner +from mp_baselines.planners.multi_sample_based_planner import MultiSampleBasedPlanner +from mp_baselines.planners.rrt_connect import RRTConnect +from mp_baselines.planners.rrt_star import RRTStar +from mp_baselines.planners.identity_planner import IdentityPlanner +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from mmd.common.trajectory_utils import densify_trajs + + +def generate_collision_free_trajectories( + env_id, + robot_id, + num_trajectories_per_context, + results_dir, + threshold_start_goal_pos=0.5, + obstacle_cutoff_margin=0.03, + n_tries=1000, + rrt_max_time=300, + gpmp_opt_iters=500, + n_support_points=64, + duration=5.0, + tensor_args=None, + debug=False, + is_start_goal_near_limits=False, +): + # -------------------------------- Load env, robot, task --------------------------------- + # Environment + print("hybrid planner") + env_class = getattr(environments, env_id) + env = env_class(tensor_args=tensor_args) + + # Robot + robot_class = getattr(robots, robot_id) + robot = robot_class(tensor_args=tensor_args) + + # Task + task = PlanningTask( + env=env, + robot=robot, + obstacle_cutoff_margin=obstacle_cutoff_margin, + tensor_args=tensor_args + ) + + # -------------------------------- Start, Goal states --------------------------------- + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2) + start_state_pos = q_free[0] + goal_state_pos = q_free[1] + + # Ask the environment if this start and goal are valid for data generation. + if not env.is_start_goal_valid_for_data_gen(robot, start_state_pos, goal_state_pos): + print(f"Invalid start and goal for data generation: {start_state_pos}, {goal_state_pos}") + continue + + if is_start_goal_near_limits: + dim = torch.randint(0, robot.q_dim, (1,)).item() + random_number = torch.rand(1, **tensor_args) + dim_range = robot.q_max[dim] - robot.q_min[dim] + if random_number < 1/robot.q_dim: + start_state_pos[dim] = robot.q_min[dim] + dim_range * 0.1 + else: + start_state_pos[dim] = robot.q_max[dim] - dim_range * 0.1 + if random_number < 1/robot.q_dim: + goal_state_pos[dim] = robot.q_max[dim] - dim_range * 0.1 + else: + goal_state_pos[dim] = robot.q_min[dim] + dim_range * 0.1 + + if torch.linalg.norm(start_state_pos - goal_state_pos) > threshold_start_goal_pos: + break + + if start_state_pos is None or goal_state_pos is None: + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + n_trajectories = num_trajectories_per_context + + # Get the skill sequence conditioned on the start and goal. + skill_pos_sequence_l = env.get_skill_pos_seq_l(robot, start_pos=start_state_pos, goal_pos=goal_state_pos) + + # -------------------------------- Hybrid Planner --------------------------------- + # Sample-based planner + rrt_connect_default_params_env = env.get_rrt_connect_params(robot=robot) + rrt_connect_default_params_env['max_time'] = rrt_max_time + + # Two options here. Either ask for an RRT-Conect path from random start to goal. Or ask for RRT-Connect path from + # start to beginning of skill sequence, and then from end of skill sequence to goal. + # This is to ensure that the skill is included as-is. + pre_optimization_planners = [] # Their solutions will be concatenated. + if not skill_pos_sequence_l: + rrt_connect_params = dict( + **rrt_connect_default_params_env, + task=task, + start_state_pos=start_state_pos, + goal_state_pos=goal_state_pos, + tensor_args=tensor_args, + ) + sample_based_planner_base = RRTConnect(**rrt_connect_params) + sample_based_planner = MultiSampleBasedPlanner( + sample_based_planner_base, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + pre_optimization_planners = [sample_based_planner] + else: + # Choose random skill index to choose. There may be multiple demonstrations of a skill so choose one. + rand_ix_skill = torch.randint(0, len(skill_pos_sequence_l), (1,)).item() + rrt_start_to_skill = RRTStar( + **rrt_connect_default_params_env, + task=task, + start_state_pos=start_state_pos, + goal_state_pos=skill_pos_sequence_l[rand_ix_skill][0], + tensor_args=tensor_args, + ) + skill = IdentityPlanner(skill_pos_sequence_l[rand_ix_skill], tensor_args=tensor_args) + rrt_skill_to_goal = RRTStar( + **rrt_connect_default_params_env, + task=task, + start_state_pos=skill_pos_sequence_l[rand_ix_skill][-1], + goal_state_pos=goal_state_pos, + tensor_args=tensor_args, + ) + planner_start_to_skill = MultiSampleBasedPlanner( + rrt_start_to_skill, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + planner_skill = MultiSampleBasedPlanner( + skill, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + planner_skill_to_goal = MultiSampleBasedPlanner( + rrt_skill_to_goal, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + pre_optimization_planners = [planner_start_to_skill, planner_skill, planner_skill_to_goal] + + # Optimization-based planner + gpmp_default_params_env = env.get_gpmp2_params(robot=robot) + gpmp_default_params_env['opt_iters'] = gpmp_opt_iters + gpmp_default_params_env['n_support_points'] = n_support_points + gpmp_default_params_env['dt'] = duration / n_support_points + + planner_params = dict( + **gpmp_default_params_env, + robot=robot, + n_dof=robot.q_dim, + num_particles_per_goal=n_trajectories, + start_state=start_state_pos, + multi_goal_states=goal_state_pos.unsqueeze(0), # add batch dim for interface, + collision_fields=task.get_collision_fields(), + tensor_args=tensor_args, + ) + opt_based_planner = GPMP2(**planner_params) + + ############### + # Hybrid planner + planner = HybridPlanner( + pre_optimization_planners, + opt_based_planner, + tensor_args=tensor_args + ) + + # Optimize + trajs_iters = planner.optimize(debug=debug, print_times=True, return_iterations=True) + trajs_last_iter = trajs_iters[-1] + + # -------------------------------- Save trajectories --------------------------------- + print(f'----------------STATISTICS----------------') + print(f'percentage free trajs: {task.compute_fraction_free_trajs(trajs_last_iter)*100:.2f}') + print(f'percentage collision intensity {task.compute_collision_intensity_trajs(trajs_last_iter)*100:.2f}') + print(f'success {task.compute_success_free_trajs(trajs_last_iter)}') + + # save + torch.cuda.empty_cache() + trajs_last_iter_coll, trajs_last_iter_free = task.get_trajs_collision_and_free(trajs_last_iter) + if trajs_last_iter_coll is None: + trajs_last_iter_coll = torch.empty(0) + torch.save(trajs_last_iter_coll, os.path.join(results_dir, f'trajs-collision.pt')) + if trajs_last_iter_free is None: + trajs_last_iter_free = torch.empty(0) + torch.save(trajs_last_iter_free, os.path.join(results_dir, f'trajs-free.pt')) + + # save results data dict + trajs_iters_coll, trajs_iters_free = task.get_trajs_collision_and_free(trajs_iters[-1]) + results_data_dict = { + 'duration': duration, + 'n_support_points': n_support_points, + 'dt': planner_params['dt'], + 'trajs_iters_coll': trajs_iters_coll.unsqueeze(0) if trajs_iters_coll is not None else None, + 'trajs_iters_free': trajs_iters_free.unsqueeze(0) if trajs_iters_free is not None else None, + } + + with open(os.path.join(results_dir, f'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + # -------------------------------- Visualize --------------------------------- + planner_visualizer = PlanningVisualizer(task=task) + + trajs = trajs_last_iter_free + pos_trajs = robot.get_position(trajs) + start_state_pos = pos_trajs[0][0] + goal_state_pos = pos_trajs[0][-1] + + fig, axs = planner_visualizer.plot_joint_space_state_trajectories( + trajs=trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories.png'), dpi=100) + plt.close(fig) + + # Plot 2d trajectories if the environment is 2D. + # if "Empty" in env_id: + print(f'Plotting 2D trajectories') + fig, axs = planner_visualizer.render_robot_trajectories( + trajs=trajs + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories_2d.png'), dpi=100) + plt.close(fig) + + # Plot 2d trajectories if the environment is 2D. + # if "Empty" in env_id: + print(f'Plotting 2D trajectories') + fig, axs = planner_visualizer.render_robot_trajectories( + trajs=trajs + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories_2d.png'), dpi=100) + plt.close(fig) + + # Create animation. Do this with CBS. Yes I know, it's weird. + # Densify the paths for visualization. + trajs_l = [traj for traj in trajs] + trajs_dense_l = densify_trajs(trajs_l, 1) + # Add batch dimension to all paths. + trajs_dense_l = [traj.unsqueeze(0) for traj in trajs_dense_l] + + base_file_name = Path(os.path.basename(__file__)).stem + output_fpath = os.path.join(results_dir, f'{base_file_name}-robot-traj.gif') + # Render the paths. + print('Rendering paths and saving to:', output_fpath) + # Print a link path to terminal that can be clicked to get there. + file_uri = f'file://{os.path.realpath(output_fpath)}' + print(f'Click to open output directory:file://{os.path.realpath(results_dir)}') + + # print(f'Click to open GIF: {file_uri}') + # planner_visualizer.animate_multi_robot_trajectories( + # trajs_l=trajs_dense_l, + # start_state_l=[pos_trajs[i][0] for i in range(len(trajs_dense_l))], + # goal_state_l=[pos_trajs[i][-1] for i in range(len(trajs_dense_l))], + # plot_trajs=True, + # video_filepath=output_fpath, + # n_frames=max((2, trajs_dense_l[0].shape[1])), + # # n_frames=pos_trajs_iters[-1].shape[1], + # anim_time=15.0, + # constraints=None, + # colors=[plt.cm.tab10(i) for i in range(len(trajs_dense_l))], + # ) + + num_trajectories_coll, num_trajectories_free = len(trajs_last_iter_coll), len(trajs_last_iter_free) + return num_trajectories_coll, num_trajectories_free + + +def generate_skill_only_trajectories( + env_id, + robot_id, + num_trajectories_per_context, + results_dir, + threshold_start_goal_pos=1.0, + obstacle_cutoff_margin=0.03, + n_tries=1000, + rrt_max_time=300, + gpmp_opt_iters=500, + n_support_points=64, + duration=5.0, + tensor_args=None, + debug=False, + # skill_pos_sequence_l: List[torch.Tensor] = None, # Each entry is a (n, 2) tensor of positions. + is_start_goal_near_limits=False, +): + # -------------------------------- Load env, robot, task --------------------------------- + # Environment + print("hybrid planner") + env_class = getattr(environments, env_id) + env = env_class(tensor_args=tensor_args) + + # Robot + robot_class = getattr(robots, robot_id) + robot = robot_class(tensor_args=tensor_args) + + # Task + task = PlanningTask( + env=env, + robot=robot, + obstacle_cutoff_margin=obstacle_cutoff_margin, + tensor_args=tensor_args + ) + + # -------------------------------- Start, Goal states --------------------------------- + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2) + start_state_pos = q_free[0] # Shape (robot.q_dim,) + goal_state_pos = q_free[1] # Shape (robot.q_dim,) + + # Ask the environment if this start and goal are valid for data generation. + if not env.is_start_goal_valid_for_data_gen(robot, start_state_pos, goal_state_pos): + print(f"Invalid start and goal for data generation: {start_state_pos}, {goal_state_pos}") + continue + + if is_start_goal_near_limits: + dim = torch.randint(0, robot.q_dim, (1,)).item() + random_number = torch.rand(1, **tensor_args) + dim_range = robot.q_max[dim] - robot.q_min[dim] + if random_number < 1/robot.q_dim: + start_state_pos[dim] = robot.q_min[dim] + dim_range * 0.15 + else: + start_state_pos[dim] = robot.q_max[dim] - dim_range * 0.15 + if random_number < 1/robot.q_dim: + goal_state_pos[dim] = robot.q_max[dim] - dim_range * 0.15 + else: + goal_state_pos[dim] = robot.q_min[dim] + dim_range * 0.15 + + if torch.linalg.norm(start_state_pos - goal_state_pos) > threshold_start_goal_pos: + break + + if start_state_pos is None or goal_state_pos is None: + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + n_trajectories = num_trajectories_per_context + + # Get the skill sequence conditioned on the start and goal. + skill_pos_sequence_l = env.get_skill_pos_seq_l(robot, start_pos=start_state_pos, goal_pos=goal_state_pos) + + # -------------------------------- Hybrid Planner --------------------------------- + # Sample-based planner + rrt_connect_default_params_env = env.get_rrt_connect_params(robot=robot) + rrt_connect_default_params_env['max_time'] = rrt_max_time + + # Two options here. Either ask for an RRT-Conect path from random start to goal. Or ask for RRT-Connect path from + # start to beginning of skill sequence, and then from end of skill sequence to goal. + # This is to ensure that the skill is included as-is. + pre_optimization_planners = [] # Their solutions will be concatenated. + if not skill_pos_sequence_l: + rrt_connect_params = dict( + **rrt_connect_default_params_env, + task=task, + start_state_pos=start_state_pos, + goal_state_pos=goal_state_pos, + tensor_args=tensor_args, + ) + sample_based_planner_base = RRTConnect(**rrt_connect_params) + sample_based_planner = MultiSampleBasedPlanner( + sample_based_planner_base, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + pre_optimization_planners = [sample_based_planner] + else: + # Choose random skill index to choose. There may be multiple demonstrations of a skill so choose one. + rand_ix_skill = torch.randint(0, len(skill_pos_sequence_l), (1,)).item() + rrt_start_to_skill = RRTConnect( + **rrt_connect_default_params_env, + task=task, + start_state_pos=start_state_pos, + goal_state_pos=skill_pos_sequence_l[rand_ix_skill][0], + tensor_args=tensor_args, + ) + skill = IdentityPlanner(skill_pos_sequence_l[rand_ix_skill], tensor_args=tensor_args) + rrt_skill_to_goal = RRTConnect( + **rrt_connect_default_params_env, + task=task, + start_state_pos=skill_pos_sequence_l[rand_ix_skill][-1], + goal_state_pos=goal_state_pos, + tensor_args=tensor_args, + ) + planner_start_to_skill = MultiSampleBasedPlanner( + rrt_start_to_skill, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + planner_skill = MultiSampleBasedPlanner( + skill, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + planner_skill_to_goal = MultiSampleBasedPlanner( + rrt_skill_to_goal, + n_trajectories=n_trajectories, + max_processes=-1, + optimize_sequentially=True + ) + pre_optimization_planners = [planner_start_to_skill, planner_skill, planner_skill_to_goal] + + # Optimization-based planner + gpmp_default_params_env = env.get_gpmp2_params(robot=robot) + gpmp_default_params_env['opt_iters'] = gpmp_opt_iters + gpmp_default_params_env['n_support_points'] = n_support_points + gpmp_default_params_env['dt'] = duration / n_support_points + + planner_params = dict( + **gpmp_default_params_env, + robot=robot, + n_dof=robot.q_dim, + num_particles_per_goal=n_trajectories, + start_state=start_state_pos, + multi_goal_states=goal_state_pos.unsqueeze(0), # add batch dim for interface, + collision_fields=task.get_collision_fields(), + tensor_args=tensor_args, + ) + opt_based_planner = GPMP2(**planner_params) + + ############### + # Hybrid planner + planner = HybridPlanner( + pre_optimization_planners, + opt_based_planner, + tensor_args=tensor_args + ) + + # Optimize + trajs_iters = planner.optimize(debug=debug, print_times=True, return_iterations=True) + trajs_last_iter = trajs_iters[-1] + + # -------------------------------- Save trajectories --------------------------------- + print(f'----------------STATISTICS----------------') + print(f'percentage free trajs: {task.compute_fraction_free_trajs(trajs_last_iter)*100:.2f}') + print(f'percentage collision intensity {task.compute_collision_intensity_trajs(trajs_last_iter)*100:.2f}') + print(f'success {task.compute_success_free_trajs(trajs_last_iter)}') + + # save + torch.cuda.empty_cache() + trajs_last_iter_coll, trajs_last_iter_free = task.get_trajs_collision_and_free(trajs_last_iter) + if trajs_last_iter_coll is None: + trajs_last_iter_coll = torch.empty(0) + torch.save(trajs_last_iter_coll, os.path.join(results_dir, f'trajs-collision.pt')) + if trajs_last_iter_free is None: + trajs_last_iter_free = torch.empty(0) + torch.save(trajs_last_iter_free, os.path.join(results_dir, f'trajs-free.pt')) + + # save results data dict + trajs_iters_coll, trajs_iters_free = task.get_trajs_collision_and_free(trajs_iters[-1]) + results_data_dict = { + 'duration': duration, + 'n_support_points': n_support_points, + 'dt': planner_params['dt'], + 'trajs_iters_coll': trajs_iters_coll.unsqueeze(0) if trajs_iters_coll is not None else None, + 'trajs_iters_free': trajs_iters_free.unsqueeze(0) if trajs_iters_free is not None else None, + } + + with open(os.path.join(results_dir, f'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + # -------------------------------- Visualize --------------------------------- + planner_visualizer = PlanningVisualizer(task=task) + + trajs = trajs_last_iter_free + pos_trajs = robot.get_position(trajs) + start_state_pos = pos_trajs[0][0] + goal_state_pos = pos_trajs[0][-1] + + fig, axs = planner_visualizer.plot_joint_space_state_trajectories( + trajs=trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories.png'), dpi=100) + plt.close(fig) + + # Plot 2d trajectories if the environment is 2D. + # if "Empty" in env_id: + print(f'Plotting 2D trajectories') + fig, axs = planner_visualizer.render_robot_trajectories( + trajs=trajs + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories_2d.png'), dpi=100) + plt.close(fig) + + # Plot 2d trajectories if the environment is 2D. + # if "Empty" in env_id: + print(f'Plotting 2D trajectories') + fig, axs = planner_visualizer.render_robot_trajectories( + trajs=trajs + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories_2d.png'), dpi=100) + plt.close(fig) + + # Create animation. Do this with CBS. Yes I know, it's weird. + # Densify the paths for visualization. + trajs_l = [traj for traj in trajs] + trajs_dense_l = densify_trajs(trajs_l, 1) + # Add batch dimension to all paths. + trajs_dense_l = [traj.unsqueeze(0) for traj in trajs_dense_l] + + base_file_name = Path(os.path.basename(__file__)).stem + output_fpath = os.path.join(results_dir, f'{base_file_name}-robot-traj.gif') + # Render the paths. + print('Rendering paths and saving to:', output_fpath) + # Print a link path to terminal that can be clicked to get there. + file_uri = f'file://{os.path.realpath(output_fpath)}' + print(f'Click to open GIF: {file_uri}') + print(f'Click to open output directory:file://{os.path.realpath(results_dir)}') + + planner_visualizer.animate_multi_robot_trajectories( + trajs_l=trajs_dense_l, + start_state_l=[pos_trajs[i][0] for i in range(len(trajs_dense_l))], + goal_state_l=[pos_trajs[i][-1] for i in range(len(trajs_dense_l))], + plot_trajs=True, + video_filepath=output_fpath, + n_frames=max((2, trajs_dense_l[0].shape[1])), + # n_frames=pos_trajs_iters[-1].shape[1], + anim_time=15.0, + constraints=None, + colors=[plt.cm.tab10(i) for i in range(len(trajs_dense_l))], + ) + + num_trajectories_coll, num_trajectories_free = len(trajs_last_iter_coll), len(trajs_last_iter_free) + return num_trajectories_coll, num_trajectories_free + + +def generate_linear_trajectories( + env_id, + robot_id, + num_trajectories_per_context, + results_dir, + threshold_start_goal_pos=1.0, + obstacle_cutoff_margin=0.03, + n_tries=1000, + rrt_max_time=300, + gpmp_opt_iters=500, + n_support_points=64, + duration=5.0, + tensor_args=None, + is_wait_at_goal=True, + debug=False, +): + # -------------------------------- Load env, robot, task --------------------------------- + # Environment + print("linear planner") + env_class = getattr(environments, env_id) + env = env_class(tensor_args=tensor_args) + + # Robot + robot_class = getattr(robots, robot_id) + robot = robot_class(tensor_args=tensor_args) + + # Task + task = PlanningTask( + env=env, + robot=robot, + obstacle_cutoff_margin=obstacle_cutoff_margin, + tensor_args=tensor_args + ) + + # -------------------------------- Start, Goal states --------------------------------- + start_state_pos, goal_state_pos = None, None + for _ in range(n_tries): + q_free = task.random_coll_free_q(n_samples=2) + start_state_pos = q_free[0] + goal_state_pos = q_free[1] + + if torch.linalg.norm(start_state_pos - goal_state_pos) > threshold_start_goal_pos: + break + + if start_state_pos is None or goal_state_pos is None: + raise ValueError(f"No collision free configuration was found\n" + f"start_state_pos: {start_state_pos}\n" + f"goal_state_pos: {goal_state_pos}\n") + + n_trajectories = num_trajectories_per_context + + # -------------------------------- Linear Planner --------------------------------- + # The output shape of trajectories is (n_trajectories_for_start_goal_pair, n_support_points, state_dim) + if is_wait_at_goal: + # Only allowing velocity v or velocity 0. These are stacked below the positions (vx, vy). + v_mag = 0.05 + else: + # Velocity is distance/steps. + v_mag = torch.linalg.norm(goal_state_pos - start_state_pos) / n_support_points + + if n_trajectories != 1: + raise ValueError(f"n_trajectories must be 1 for linear planner. Got {n_trajectories}") + traj_dist = torch.linalg.norm(goal_state_pos - start_state_pos) + traj_num_points_moving = traj_dist / v_mag + traj_num_points_moving = torch.floor(traj_num_points_moving).int() + traj_interpolation_weights = torch.linspace(0, 1, int(traj_num_points_moving), **tensor_args).unsqueeze(1) + traj = start_state_pos + traj_interpolation_weights * (goal_state_pos - start_state_pos) + # Add points to the end of the trajectory waiting at the goal, if any exist. + traj_num_points_waiting = n_support_points - traj_num_points_moving + if traj_num_points_waiting > 0: + traj = torch.cat((traj, torch.stack([goal_state_pos] * int(traj_num_points_waiting)))) + # Compute the velocity vectors by finite differencing. + traj_vel = torch.cat((torch.diff(traj, dim=0), torch.zeros(1, robot.q_dim, **tensor_args))) + traj = torch.cat((traj.unsqueeze(0), traj_vel.unsqueeze(0)), dim=-1) + trajs_last_iter = traj + + # -------------------------------- Save trajectories --------------------------------- + print(f'----------------STATISTICS----------------') + print(f'percentage free trajs: {task.compute_fraction_free_trajs(trajs_last_iter) * 100:.2f}') + print(f'percentage collision intensity {task.compute_collision_intensity_trajs(trajs_last_iter) * 100:.2f}') + print(f'success {task.compute_success_free_trajs(trajs_last_iter)}') + + # save + torch.cuda.empty_cache() + trajs_last_iter_coll, trajs_last_iter_free = task.get_trajs_collision_and_free(trajs_last_iter) + if trajs_last_iter_coll is None: + trajs_last_iter_coll = torch.empty(0) + torch.save(trajs_last_iter_coll, os.path.join(results_dir, f'trajs-collision.pt')) + if trajs_last_iter_free is None: + trajs_last_iter_free = torch.empty(0) + torch.save(trajs_last_iter_free, os.path.join(results_dir, f'trajs-free.pt')) + + # Save results data dict. + results_data_dict = { + 'duration': duration, + 'n_support_points': n_support_points, + 'dt': duration / n_support_points, + 'trajs_iters_coll': None, + 'trajs_iters_free': None, + } + + with open(os.path.join(results_dir, f'results_data_dict.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + + # -------------------------------- Visualize --------------------------------- + planner_visualizer = PlanningVisualizer(task=task) + + trajs = trajs_last_iter_free + pos_trajs = robot.get_position(trajs) + start_state_pos = pos_trajs[0][0] + goal_state_pos = pos_trajs[0][-1] + + fig, axs = planner_visualizer.plot_joint_space_state_trajectories( + trajs=trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), + ) + # save figure + fig.savefig(os.path.join(results_dir, f'trajectories.png'), dpi=100) + plt.close(fig) + + # Visualize animated. Uncomment below to safe a GIF. + # trajs_dense_l = densify_trajs([trajs_last_iter], 1) + # output_fpath = os.path.join(results_dir, f'robot-traj.gif') + # file_uri = f'file://{os.path.realpath(output_fpath)}' + # print(f'Click to open output directory:file://{os.path.realpath(results_dir)}') + # + # print(f'Click to open GIF: {file_uri}') + # planner_visualizer.animate_multi_robot_trajectories( + # trajs_l=trajs_dense_l, + # start_state_l=[pos_trajs[i][0] for i in range(len(trajs_dense_l))], + # goal_state_l=[pos_trajs[i][-1] for i in range(len(trajs_dense_l))], + # plot_trajs=True, + # video_filepath=output_fpath, + # n_frames=max((2, trajs_dense_l[0].shape[1])), + # # n_frames=pos_trajs_iters[-1].shape[1], + # anim_time=15.0, + # constraints=None, + # colors=[plt.cm.tab10(i) for i in range(len(trajs_dense_l))], + # ) + + num_trajectories_coll, num_trajectories_free = len(trajs_last_iter_coll), len(trajs_last_iter_free) + return num_trajectories_coll, num_trajectories_free + + +@single_experiment_yaml +def experiment( + # env_id: str = 'EnvEmptyNoWait2D', + # env_id: str = 'EnvEmpty2D', + # env_id: str = 'EnvDropRegion2D', + env_id: str = 'EnvHighways2D', + # env_id: str = 'EnvConveyor2D', + + robot_id: str = 'RobotPlanarDisk', + + n_support_points: int = 64, + duration: float = 5.0, # seconds + + threshold_start_goal_pos: float = 0.5, + # threshold_start_goal_pos: float = 1.83, + + is_start_goal_near_limits: bool = False, + + obstacle_cutoff_margin: float = 0.05, + + num_trajectories: int = 5, + + # device: str = 'cpu', + device: str = 'cuda', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = int(time.time()), + # seed: int = 3, + # seed: int = 1679258088, + results_dir: str = f"data", + + ####################################### + **kwargs +): + if debug: + fix_random_seed(seed) + + print(f'\n\n-------------------- Generating data --------------------') + print(f'Seed: {seed}') + print(f'Env: {env_id}') + print(f'Robot: {robot_id}') + print(f'num_trajectories: {num_trajectories}') + + #################################################################################################################### + tensor_args = {'device': device, 'dtype': torch.float32} + + metadata = { + 'env_id': env_id, + 'robot_id': robot_id, + 'num_trajectories': num_trajectories + } + with open(os.path.join(results_dir, 'metadata.yaml'), 'w') as f: + yaml.dump(metadata, f, Dumper=yaml.Dumper) + + num_trajectories_coll, num_trajectories_free = generate_collision_free_trajectories( + env_id, + robot_id, + num_trajectories, + results_dir, + threshold_start_goal_pos=threshold_start_goal_pos, + obstacle_cutoff_margin=obstacle_cutoff_margin, + n_support_points=n_support_points, + duration=duration, + is_start_goal_near_limits=is_start_goal_near_limits, + tensor_args=tensor_args, + debug=debug + ) + + # Start linear. + # num_trajectories_coll, num_trajectories_free = generate_linear_trajectories( + # env_id, + # robot_id, + # num_trajectories, + # results_dir, + # threshold_start_goal_pos=threshold_start_goal_pos, + # obstacle_cutoff_margin=obstacle_cutoff_margin, + # n_support_points=n_support_points, + # duration=duration, + # tensor_args=tensor_args, + # is_wait_at_goal=False, + # debug=debug, + # ) + # End linear. + + metadata.update( + num_trajectories_generated=num_trajectories_coll + num_trajectories_free, + num_trajectories_generated_coll=num_trajectories_coll, + num_trajectories_generated_free=num_trajectories_free, + ) + with open(os.path.join(results_dir, 'metadata.yaml'), 'w') as f: + yaml.dump(metadata, f, Dumper=yaml.Dumper) + + +if __name__ == '__main__': + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/launch_generate_trajectories.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/launch_generate_trajectories.py new file mode 100644 index 0000000000000000000000000000000000000000..d1d27f86fd338a7ac0c1424574f5dd29cc3bd443 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/launch_generate_trajectories.py @@ -0,0 +1,88 @@ +import os +import socket + +import numpy as np + +from experiment_launcher import Launcher +from experiment_launcher.utils import is_local + +######################################################################################################################## +# EXPERIMENT PARAMETERS SETUP +# SELECT ONE +# num_contexts: the number of start/goal pairs. +# num_trajectories_per_context: the number of trajectories per start/goal pair. + +# env_id: str = 'EnvHighways2D' +env_id: str = 'EnvDropRegion2D' +robot_id: str = 'RobotPlanarDisk' +num_contexts = 10 +num_trajectories_per_context = 20 +# threshold_start_goal_pos: float = 0.9 +threshold_start_goal_pos = 1 +is_start_goal_near_limits: bool = False +# obstacle_cutoff_margin: float = 0.05 +obstacle_cutoff_margin: float = 0.02 + +######################################################################################################################## +# LAUNCHER + +hostname = socket.gethostname() + +LOCAL = is_local() +TEST = False +USE_CUDA = True +# USE_CUDA = False + +N_SEEDS = num_contexts + +N_EXPS_IN_PARALLEL = 2 if not USE_CUDA else 1 + +N_CORES = N_EXPS_IN_PARALLEL +# N_CORES = 8 +MEMORY_SINGLE_JOB = 12000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'YOUR_GPU_PARTITION' if USE_CUDA else 'YOUR_CPU_PARTITION' ### Adjust to your cluster +GRES = 'gpu:1' if USE_CUDA else None # e.g. gpu:v100:1, gpu:rtx3090:1, gpu:a5000:1 +CONDA_ENV = 'direct-mmd' + +exp_name = f'generate_trajectories' + +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +launcher = Launcher( + exp_name=exp_name, + exp_file='generate_trajectories', + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=0, + hours=7, + minutes=59, + seconds=0, + partition=PARTITION, + conda_env=CONDA_ENV, + gres=GRES, + use_timestamp=True +) + + +######################################################################################################################## +# RUN + +launcher.add_experiment( + env_id__=env_id, + robot_id__=robot_id, + + num_trajectories=num_trajectories_per_context, + + threshold_start_goal_pos=threshold_start_goal_pos, + obstacle_cutoff_margin=obstacle_cutoff_margin, + is_start_goal_near_limits=is_start_goal_near_limits, + + device='cuda' if USE_CUDA else 'cpu', + + debug=False +) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/visualize_trajectories.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/visualize_trajectories.py new file mode 100644 index 0000000000000000000000000000000000000000..1dc2a2b43b49e1bd9192844caf826fa8c8a68840 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/generate_data/visualize_trajectories.py @@ -0,0 +1,91 @@ +""" +From https://github.com/jacarvalho/mpd-public +""" +import argparse +import pickle + +import matplotlib.pyplot as plt + +import os.path + +import torch +import yaml + +from mmd.utils.loading import load_params_from_yaml +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS, to_torch +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer + +_cli = argparse.ArgumentParser(description="Visualize a generated trajectory dataset.") +_cli.add_argument("--data-dir", required=True, + help="Path to the dataset directory (must contain args.yaml and metadata.yaml).") +DATA_DIR = _cli.parse_args().data_dir + +tensor_args = DEFAULT_TENSOR_ARGS + +args = load_params_from_yaml(os.path.join(DATA_DIR, 'args.yaml')) + +metadata = load_params_from_yaml(os.path.join(DATA_DIR, 'metadata.yaml')) +print(f"\n-------------- METADATA --------------") +print(yaml.dump(metadata)) +print(f"\n--------------------------------------") +print() + +# -------------------------------- Load env, robot, task --------------------------------- +# Environment +env_class = getattr(environments, args['env_id']) +env = env_class(tensor_args=tensor_args) + +# Robot +robot_class = getattr(robots, args['robot_id']) +robot = robot_class( + tensor_args=tensor_args +) + +# Task +task = PlanningTask( + env=env, + robot=robot, + obstacle_cutoff_margin=args['obstacle_cutoff_margin'], + tensor_args=tensor_args +) + +# -------------------------------- Load trajectories ------------------------- +trajs_collision = torch.load(os.path.join(DATA_DIR, 'trajs-collision.pt')).to(**tensor_args) +trajs_free = torch.load(os.path.join(DATA_DIR, 'trajs-free.pt')).to(**tensor_args) + +# trajs = torch.cat((trajs_collision, trajs_free)) +trajs = trajs_free + +# -------------------------------- Visualize --------------------------------- +planner_visualizer = PlanningVisualizer(task=task) + +pos_trajs = robot.get_position(trajs) +start_state_pos = pos_trajs[0][0] +goal_state_pos = pos_trajs[0][-1] + +planner_visualizer.plot_joint_space_state_trajectories( + trajs=trajs, + pos_start_state=start_state_pos, pos_goal_state=goal_state_pos, + vel_start_state=torch.zeros_like(start_state_pos), vel_goal_state=torch.zeros_like(goal_state_pos), +) + +plt.show() + +planner_visualizer.render_robot_trajectories( + trajs=trajs, start_state=start_state_pos, goal_state=goal_state_pos, + render_planner=False, +) + +plt.show() + +planner_visualizer.animate_robot_trajectories( + trajs=trajs, start_state=start_state_pos, goal_state=goal_state_pos, + plot_trajs=True, + video_filepath=os.path.join(DATA_DIR, 'robot-traj.gif'), + # n_frames=max((2, pos_trajs_iters[-1].shape[1]//10)), + n_frames=trajs.shape[1], + anim_time=args['duration'] +) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_mmd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..7a8a04b6aa94a32df3af2e0389393245561e1596 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_mmd.py @@ -0,0 +1,387 @@ +""" +Modified from +- https://github.com/yoraish/mmd + +MIT License + +Copyright (c) 2024 Yorai Shaoul +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import os +import pickle +from datetime import datetime +import time +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List +import concurrent.futures + +# Project imports. +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory, CostConstraint +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.models.diffusion_models.sample_functions import guide_gradient_steps, ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints, \ + compute_average_acceleration, compute_average_acceleration_from_pos_vel, compute_path_length_from_pos +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from torch_robotics.robots.robot_planar_disk import RobotPlanarDisk +from mmd.planners.multi_agent import CBS, PrioritizedPlanning +from mmd.planners.single_agent import MPD, MPDEnsemble +from mmd.common.constraints import MultiPointConstraint, VertexConstraint, EdgeConstraint +from mmd.common.conflicts import VertexConflict, PointConflict, EdgeConflict +from mmd.common.trajectory_utils import smooth_trajs, densify_trajs +from mmd.common import compute_collision_intensity, is_multi_agent_start_goal_states_valid, global_pad_paths, \ + get_start_goal_pos_circle, get_state_pos_column, get_start_goal_pos_boundary, get_start_goal_pos_random_in_env +from mmd.common.pretty_print import * +from mmd.config.mmd_params import MMDParams as params +from mmd.common.experiments import MultiAgentPlanningSingleTrialConfig, MultiAgentPlanningSingleTrialResult, \ + get_result_dir_from_trial_config, TrialSuccessStatus +from torch_robotics.environments import * + +allow_ops_in_compiled_graph() + +TRAINED_MODELS_DIR = 'data_trained_models/' +device = 'cuda' +device = get_torch_device(device) +tensor_args = {'device': device, 'dtype': torch.float32} + + +def run_multi_agent_trial(test_config: MultiAgentPlanningSingleTrialConfig): + # ============================ + # Start time per agent. + # ============================ + start_time_l = [i * test_config.stagger_start_time_dt for i in range(test_config.num_agents)] + + # ============================ + # Arguments for the high/low level planner. + # ============================ + low_level_planner_model_args = { + 'planner_alg': 'mmd', + 'use_guide_on_extra_objects_only': params.use_guide_on_extra_objects_only, + 'n_samples': params.n_samples, + 'n_local_inference_noising_steps': params.n_local_inference_noising_steps, + 'n_local_inference_denoising_steps': params.n_local_inference_denoising_steps, + 'start_guide_steps_fraction': params.start_guide_steps_fraction, + 'n_guide_steps': params.n_guide_steps, + 'n_diffusion_steps_without_noise': params.n_diffusion_steps_without_noise, + 'weight_grad_cost_collision': params.weight_grad_cost_collision, + 'weight_grad_cost_smoothness': params.weight_grad_cost_smoothness, + 'weight_grad_cost_constraints': params.weight_grad_cost_constraints, + 'weight_grad_cost_soft_constraints': params.weight_grad_cost_soft_constraints, + 'factor_num_interpolated_points_for_collision': params.factor_num_interpolated_points_for_collision, + 'trajectory_duration': params.trajectory_duration, + 'device': params.device, + 'debug': params.debug, + 'seed': params.seed, + 'results_dir': params.results_dir, + 'trained_models_dir': TRAINED_MODELS_DIR, + } + high_level_planner_model_args = { + 'is_xcbs': True if test_config.multi_agent_planner_class in ["XECBS", "XCBS"] else False, + 'is_ecbs': True if test_config.multi_agent_planner_class in ["ECBS", "XECBS"] else False, + 'start_time_l': start_time_l, + 'runtime_limit': test_config.runtime_limit, + 'conflict_type_to_constraint_types': {PointConflict: {MultiPointConstraint}}, + } + + # ============================ + # Create a results directory. + # ============================ + results_dir = get_result_dir_from_trial_config(test_config, test_config.time_str, test_config.trial_number) + os.makedirs(results_dir, exist_ok=True) + num_agents = test_config.num_agents + + # ============================ + # Get planning problem. + # ============================ + # If want to get random starts and goals, then must do that after creating the reference task and robot. + start_l = test_config.start_state_pos_l + goal_l = test_config.goal_state_pos_l + global_model_ids = test_config.global_model_ids + agent_skeleton_l = test_config.agent_skeleton_l + + # ============================ + # Transforms and model tiles setup. + # ============================ + # Create a reference planner from which we'll use the task and robot as the reference on in CBS. + # Those are used for collision checking and visualization. This has a skeleton of all tiles. + reference_agent_skeleton = [[r, c] for r in range(len(global_model_ids)) + for c in range(len(global_model_ids[0]))] + + # ============================ + # Transforms from tiles to global frame. + # ============================ + tile_width = 2.0 + tile_height = 2.0 + global_model_transforms = [[torch.tensor([x * tile_width, -y * tile_height], **tensor_args) + for x in range(len(global_model_ids[0]))] for y in range(len(global_model_ids))] + + # ============================ + # Parse the single agent planner class name. + # ============================ + if test_config.single_agent_planner_class == "MPD": + low_level_planner_class = MPD + elif test_config.single_agent_planner_class == "MPDEnsemble": + low_level_planner_class = MPDEnsemble + else: + raise ValueError(f'Unknown single agent planner class: {test_config.single_agent_planner_class}') + + # ============================ + # Create reference agent planner. + # ============================ + # And for the reference skeleton. + reference_task = None + reference_robot = None + reference_agent_transforms = {} + reference_agent_model_ids = {} + for skeleton_step in range(len(reference_agent_skeleton)): + skeleton_model_coord = reference_agent_skeleton[skeleton_step] + reference_agent_transforms[skeleton_step] = global_model_transforms[skeleton_model_coord[0]][ + skeleton_model_coord[1]] + reference_agent_model_ids[skeleton_step] = global_model_ids[skeleton_model_coord[0]][ + skeleton_model_coord[1]] + reference_agent_model_ids = [reference_agent_model_ids[i] for i in range(len(reference_agent_model_ids))] + # Create the reference low level planner. + print("Creating reference agent stuff.") + low_level_planner_model_args['start_state_pos'] = torch.tensor([0.5, 0.9], **tensor_args) # This does not matter. + low_level_planner_model_args['goal_state_pos'] = torch.tensor([-0.5, 0.9], **tensor_args) # This does not matter. + low_level_planner_model_args['model_ids'] = reference_agent_model_ids # This matters. + low_level_planner_model_args['transforms'] = reference_agent_transforms # This matters. + + if test_config.single_agent_planner_class == "MPD": + low_level_planner_model_args['model_id'] = reference_agent_model_ids[0] + + reference_low_level_planner = low_level_planner_class(**low_level_planner_model_args) + reference_task = reference_low_level_planner.task + reference_robot = reference_low_level_planner.robot + + # ============================ + # Run trial. + # ============================ + exp_name = f'mmd_single_trial' + + # Transform starts and goals to the global frame. Right now they are in the local tile frames. + start_l = [start_l[i] + global_model_transforms[agent_skeleton_l[i][0][0]][agent_skeleton_l[i][0][1]] + for i in range(num_agents)] + goal_l = [goal_l[i] + global_model_transforms[agent_skeleton_l[i][-1][0]][agent_skeleton_l[i][-1][1]] + for i in range(num_agents)] + + # ============================ + # Create global transforms for each agent's skeleton. + # ============================ + # Each agent has a dict entry. Each entry is a dict with the skeleton steps (0, 1, 2, ...), mapping to the + # model transform. + agent_model_transforms_l = [] + agent_model_ids_l = [] + for agent_id in range(num_agents): + agent_model_transforms = {} + agent_model_ids = {} + for skeleton_step in range(len(agent_skeleton_l[agent_id])): + skeleton_model_coord = agent_skeleton_l[agent_id][skeleton_step] + agent_model_transforms[skeleton_step] = global_model_transforms[skeleton_model_coord[0]][ + skeleton_model_coord[1]] + agent_model_ids[skeleton_step] = global_model_ids[skeleton_model_coord[0]][skeleton_model_coord[1]] + agent_model_transforms_l.append(agent_model_transforms) + agent_model_ids_l.append(agent_model_ids) + # Change the dict of the model ids to a list sorted by the skeleton steps. + agent_model_ids_l = [[agent_model_ids_l[i][j] for j in range(len(agent_model_ids_l[i]))] for i in + range(num_agents)] + + # ============================ + # Create the low level planners. + # ============================ + planners_creation_start_time = time.time() + low_level_planner_l = [] + for i in range(num_agents): + low_level_planner_model_args_i = low_level_planner_model_args.copy() + low_level_planner_model_args_i['start_state_pos'] = start_l[i] + low_level_planner_model_args_i['goal_state_pos'] = goal_l[i] + low_level_planner_model_args_i['model_ids'] = agent_model_ids_l[i] + low_level_planner_model_args_i['transforms'] = agent_model_transforms_l[i] + if test_config.single_agent_planner_class == "MPD": + # Set the model_id to the first one. + low_level_planner_model_args_i['model_id'] = agent_model_ids_l[i][0] + low_level_planner_l.append(low_level_planner_class(**low_level_planner_model_args_i)) + print('Planners creation time:', time.time() - planners_creation_start_time) + print("\n\n\n\n") + + # ============================ + # Create the multi agent planner. + # ============================ + if (test_config.multi_agent_planner_class in ["XECBS", "ECBS", "XCBS", "CBS"]): + multi_agent_planner_class = CBS + elif test_config.multi_agent_planner_class == "PP": + multi_agent_planner_class = PrioritizedPlanning + else: + raise ValueError(f'Unknown multi agent planner class: {test_config.multi_agent_planner_class}') + planner = multi_agent_planner_class(low_level_planner_l, + start_l, + goal_l, + reference_task=reference_task, + reference_robot=reference_robot, + **high_level_planner_model_args) + # ============================ + # Plan. + # ============================ + startt = time.time() + paths_l, num_ct_expansions, trial_success_status, num_collisions_in_solution = \ + planner.plan(runtime_limit=test_config.runtime_limit) + planning_time = time.time() - startt + # Print planning times. + print(GREEN, 'Planning times:', planning_time, RESET) + + # ============================ + # Gather stats. + # ============================ + single_trial_result = MultiAgentPlanningSingleTrialResult() + # The associated experiment config. + single_trial_result.trial_config = test_config + # The planning problem. + single_trial_result.start_state_pos_l = [start_l[i].cpu().numpy().tolist() for i in range(num_agents)] + single_trial_result.goal_state_pos_l = [goal_l[i].cpu().numpy().tolist() for i in range(num_agents)] + single_trial_result.global_model_ids = global_model_ids + single_trial_result.agent_skeleton_l = agent_skeleton_l + # The agent paths. Each entry is of shape (H, 4). + single_trial_result.agent_path_l = paths_l + # Success. + single_trial_result.success_status = trial_success_status + # Number of collisions in the solution. + single_trial_result.num_collisions_in_solution = num_collisions_in_solution + # Planning time. + single_trial_result.planning_time = planning_time + + # Number of agent pairs in collision. + if len(paths_l) > 0 and trial_success_status: + # This assumes all paths in the solution are of the same length. + for t in range(len(paths_l[0])): + for i in range(num_agents): + for j in range(i + 1, num_agents): + if torch.norm(paths_l[i][t, :2] - paths_l[j][t, :2]) < 2.0 * params.robot_planar_disk_radius: + # The above should be reference_robot.radius. + print(RED, 'Collision in solution:', i, j, t, paths_l[i][t, :2], paths_l[j][t, :2], RESET) + single_trial_result.num_collisions_in_solution += 1 + if single_trial_result.num_collisions_in_solution > 0: + single_trial_result.success_status = TrialSuccessStatus.FAIL_COLLISION_AGENTS + + # If not successful, return here. + if trial_success_status: + # Our metric for determining how well a path is adhering to the data. + # Computed by the environment. If it is a single map, the score is the adherence there. + # If it is a multi-tile map, the score is the average adherence over all tiles. + single_trial_result.data_adherence = 0.0 + for agent_id in range(num_agents): + agent_data_adherence = 0.0 + for skeleton_step, agent_model_id in enumerate(agent_model_ids_l[agent_id]): + agent_model_transform = agent_model_transforms_l[agent_id][skeleton_step] + agent_start_time = start_time_l[agent_id] + single_tile_traj_len = params.horizon + agent_path_in_model_frame = (paths_l[agent_id].clone()[ + agent_start_time + skeleton_step * single_tile_traj_len: + agent_start_time + (skeleton_step + 1) * single_tile_traj_len, :2] - + agent_model_transform) + model_env_name = agent_model_id.split('-')[0] + kwargs = {'tensor_args': tensor_args} + env_object = eval(model_env_name)(**kwargs) + agent_data_adherence += env_object.compute_traj_data_adherence(agent_path_in_model_frame) + agent_data_adherence /= len(agent_model_ids_l[agent_id]) + single_trial_result.data_adherence += agent_data_adherence + single_trial_result.data_adherence /= num_agents + # CT nodes expanded. + single_trial_result.num_ct_expansions = num_ct_expansions + # Path length. + single_trial_result.path_length_per_agent = 0.0 + for agent_id in range(num_agents): + agent_path_pos = paths_l[agent_id][:, :2].unsqueeze(0) + single_trial_result.path_length_per_agent += compute_path_length_from_pos(agent_path_pos).item() + single_trial_result.path_length_per_agent /= num_agents + # Path smoothness. + single_trial_result.mean_path_acceleration_per_agent = 0.0 + for agent_id in range(num_agents): + agent_path_pos = paths_l[agent_id][:, :2].unsqueeze(0) + agent_path_vel = paths_l[agent_id][:, 2:].unsqueeze(0) + if agent_path_vel.shape[-1] == 0: + agent_path_vel = low_level_planner_l[agent_id].robot.get_velocity(paths_l[agent_id]).unsqueeze(0) + + single_trial_result.mean_path_acceleration_per_agent += ( + compute_average_acceleration_from_pos_vel(agent_path_pos, agent_path_vel).item()) + single_trial_result.mean_path_acceleration_per_agent /= num_agents + # ============================ + # Save the results and config. + # ============================ + print(GREEN, single_trial_result, RESET) + results_dir_uri = f'file://{os.path.abspath(results_dir)}' + print('Results dir:', results_dir_uri) + single_trial_result.save(results_dir) + test_config.save(results_dir) + params.save(results_dir) + + # ============================ + # Render. + # ============================ + if trial_success_status and len(paths_l) > 0: + planner.render_paths(paths_l, + output_fpath=os.path.join(results_dir, f'{exp_name}.gif'), + animation_duration=0, + plot_trajs=True, + show_robot_in_image=True) + if test_config.render_animation: + paths_l = densify_trajs(paths_l, 1) # <------ Larger numbers produce nicer animations. But take longer to make too. + planner.render_paths(paths_l, + output_fpath=os.path.join(results_dir, f'{exp_name}.gif'), + plot_trajs=True, + animation_duration=10) + + +def build_default_trial_config() -> MultiAgentPlanningSingleTrialConfig: + test_config = MultiAgentPlanningSingleTrialConfig() + test_config.num_agents = 4 + test_config.instance_name = "EnvConveyor2DRobotPlanarDiskRandom" + test_config.global_model_ids = [['EnvConveyor2D-RobotPlanarDisk']] + test_config.agent_skeleton_l = [[[0, 0]]] * test_config.num_agents + test_config.multi_agent_planner_class = "CBS" + test_config.single_agent_planner_class = "MPD" + test_config.stagger_start_time_dt = 0 + test_config.runtime_limit = 60 + test_config.time_str = datetime.now().strftime("%m%d%Y_%H%M%S") + test_config.render_animation = False + torch.random.manual_seed(10) + test_config.start_state_pos_l, test_config.goal_state_pos_l = \ + get_start_goal_pos_circle(test_config.num_agents, 0.8) + return test_config + + +if __name__ == '__main__': + run_multi_agent_trial(build_default_trial_config()) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_pcdiff.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_pcdiff.py new file mode 100644 index 0000000000000000000000000000000000000000..aaab95b59b824fd6e4b823de206fe490b9007c65 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/inference_pcdiff.py @@ -0,0 +1,645 @@ +""" +Modified from +- https://github.com/yoraish/mmd + +MIT License + +Copyright (c) 2024 Yorai Shaoul +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import argparse +import click +import yaml +from datetime import datetime +import time +import logging +import einops +import torch +import numpy as np +from tqdm import tqdm +from math import ceil +from pathlib import Path +from einops._torch_specific import allow_ops_in_compiled_graph +from typing import List, Dict, Any, Tuple, Union +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_utils import get_torch_device +from torch_robotics.trajectory.metrics import ( + compute_path_length_from_pos, compute_average_velocity_from_pos, compute_max_velocity_from_pos, compute_average_acceleration_from_pos_vel) +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.robots.robot_planar_disk import RobotPlanarDisk +from torch_robotics.environments import * +from torch_robotics.tasks.tasks import PlanningTask +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory, CostConstraint + +from mmd.models import TemporalUnet, UNET_DIM_MULTS +from mmd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from mmd.models.diffusion_models.sample_functions import guide_gradient_steps, ddpm_sample_fn +from mmd.trainer import get_dataset, get_model +from mmd.planners.single_agent import PCD, DiRecT, PDM +from mmd.planners.multi_agent import DummyPlanning +from mmd.common.constraints import MultiPointConstraint +from mmd.common.conflicts import PointConflict +from mmd.common.trajectory_utils import densify_trajs +from mmd.common.argparse import load_config, parse_overrides +from mmd.common.eval_utils import collision_detect, calc_data_adherence, calc_velocity +from mmd.common import (get_start_goal_pos_circle, + get_start_goal_pos_boundary, get_start_goal_pos_batch_random_in_env) +from mmd.common.pretty_print import * +from mmd.common.experiments import ( + MultiAgentPlanningSingleTrialConfig, MultiAgentPlanningSingleTrialResult, get_result_dir_from_trial_config, + TrialSuccessStatus, + get_result_dir_pcdiff, +) + +# Logger setup +logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +allow_ops_in_compiled_graph() + + +def generate_start_goal_positions(config: dict, tensor_args: dict, batch_size=1, method: str = None) -> List[List[torch.Tensor]]: + """Generates start and goal positions based on the configuration.""" + cfg = config['environment']['start_goal_config'] + num_agents = config['experiment']['num_agents'] + if not method: + method = cfg.get('method', 'random_in_env') + + if method == "random_in_env": + env_class = eval(cfg['env_class_for_random']) + return get_start_goal_pos_batch_random_in_env( + num_agents, + batch_size, + env_class, + tensor_args, + margin=cfg.get('margin', 0.2), + obstacle_margin=cfg.get('obstacle_margin', 0.11) + ) + else: + raise ValueError(f"Unknown start/goal generation method: {method}") + + +def transform_start_goal_positions( + num_agents: int, + start_l: List[torch.Tensor], + goal_l: List[torch.Tensor], + global_model_transforms: List[List[torch.Tensor]], + agent_skeleton_l: List[List[List[int]]], +) -> Tuple[List[torch.Tensor], List[torch.Tensor]]: + start_l_global = [start_l[i] + global_model_transforms[agent_skeleton_l[i] + [0][0]][agent_skeleton_l[i][0][1]] for i in range(num_agents)] + goal_l_global = [goal_l[i] + global_model_transforms[agent_skeleton_l[i] + [-1][0]][agent_skeleton_l[i][-1][1]] for i in range(num_agents)] + + return start_l_global, goal_l_global + + +def evaluate_and_save_results( + config: Dict[str, Any], + mock_trial_cfg: MultiAgentPlanningSingleTrialConfig, + results_dir: str, + all_paths: torch.Tensor, + planning_time: float, + trial_success_status: TrialSuccessStatus, + num_ct_expansions: int, + num_collisions_in_solution: int, + timing_profiles_l: List[Dict[str, float]], + start_l_global: List[List[torch.Tensor]], + goal_l_global: List[List[torch.Tensor]], + global_model_ids: List[List[str]], + agent_skeleton_l: List[List[List[int]]], + agent_model_ids_l: List[List[str]], + agent_model_transforms_l: List[Dict[int, torch.Tensor]], + # Or a more specific base planner class if available + low_level_planner_l: List[Any], + reference_robot: RobotBase, # Using a generic base class for the robot + tensor_args: Dict[str, Any] +) -> Tuple[List[torch.Tensor], TrialSuccessStatus]: + """ + Processes the planner's output, calculates detailed metrics, and saves all results and configs. + + Args: + config: The main configuration dictionary from the YAML file. + mock_trial_cfg: A simplified config object used for saving and directory naming. + results_dir: The path to the directory where results will be saved. + all_paths_l: The raw output from the planner (a list of all sampled trajectories). + planning_time: The total time taken for planning. + trial_success_status: The success status returned by the planner. + num_ct_expansions: The number of node expansions in the conflict tree (for CBS-like planners). + num_collisions_in_solution: The initial number of collisions reported by the planner. + timing_profiles_l: List of timing profiles for each initialization. + start_l_global: A list of global start position tensors for each agent. + goal_l_global: A list of global goal position tensors for each agent. + global_model_ids: The grid of environment model IDs. + agent_skeleton_l: The path skeleton for each agent across the model grid. + agent_model_ids_l: The list of model IDs encountered by each agent. + agent_model_transforms_l: The transformations for each model in an agent's skeleton. + low_level_planner_l: The list of instantiated low-level planners. + reference_robot: The instantiated reference robot object for accessing properties like radius. + tensor_args: Dictionary specifying the torch device and dtype. + + Returns: + A tuple containing: + - paths_l (List[torch.Tensor]): The selected final path for each agent. + - trial_success_status (TrialSuccessStatus): The final success status after post-evaluation. + """ + exp_cfg = config['experiment'] + sa_planner_cfg = config['planner']['single_agent'] + robot_cfg = config['robot'] + num_agents = exp_cfg['num_agents'] + + all_paths_array = all_paths.to('cpu').numpy() + all_paths_array: np.ndarray = einops.rearrange( + all_paths_array, '(p b) i h d -> b i p h d', p=num_agents) + + starts_array = torch.stack([torch.stack(inner) + for inner in start_l_global]).to('cpu').numpy() + goals_array = torch.stack([torch.stack(inner) + for inner in goal_l_global]).to('cpu').numpy() + + # Make sure all_paths_l containts paths for all agents' path batches: + # [agent_0_path_batch, agent_1_path_batch, ..., agent_N_path_batch] + # paths_l = all_paths_l + + # ============================ + # Gather Primary Stats + # ============================ + single_trial_result = MultiAgentPlanningSingleTrialResult() + single_trial_result.trial_config = mock_trial_cfg + single_trial_result.start_state_pos_l = "raw_data.starts_global" + single_trial_result.goal_state_pos_l = "raw_data.goals_global" + single_trial_result.global_model_ids = global_model_ids + single_trial_result.agent_skeleton_l = agent_skeleton_l + single_trial_result.agent_path_l = "raw_data.all_state_trjs" + single_trial_result.num_collisions_in_solution = num_collisions_in_solution + single_trial_result.planning_time = planning_time + single_trial_result.num_ct_expansions = num_ct_expansions + + # ============================ + # Detailed Metric Calculation + # ============================ + B, I, N, H, D = all_paths_array.shape + + # Persist the candidate trajectories. Nothing else in this harness writes them, so + # every metric had to be final at run time and a definition found wrong afterwards + # (Conveyor's data-adherence scores a full-lane traversal, which random endpoints + # never perform) could only be fixed by re-running the whole grid. float16 and at + # most 16 candidates keeps a cell around 1 MB. Inert unless SAVE_TRAJ_NPZ is set. + _stn = os.environ.get('SAVE_TRAJ_NPZ') + if _stn: + try: + os.makedirs(os.path.dirname(_stn), exist_ok=True) + np.savez_compressed( + _stn, paths=all_paths_array[:min(B, 16)].astype(np.float16), + shape=np.array([B, I, N, H, D]), + key=np.array(os.environ.get('SHARED_INSTANCES_KEY', ''), dtype=object)) + except Exception as _e: + print(f"[save-traj] could not write {_stn}: {_e}", flush=True) + + # Check for inter-agent collisions in the final solution + collision_masks = collision_detect( + # [B, I, N, N, H] + all_paths_array[..., :2], safe_dist=reference_robot.radius * 2, norm_order=2) + # Count collisions per batch per init [B, I,] + cnt_coll_trjs = np.sum(collision_masks.any(-1).astype(int), axis=(-2, -1)) + assert cnt_coll_trjs.shape == (B, I), f"Expected collision count shape {(B,)}, got {cnt_coll_trjs.shape}" + assert np.all(cnt_coll_trjs % + 2 == 0), "Collision count should be even (pairs of agents)." + cnt_coll_trjs //= 2 # Each collision was counted twice + single_trial_result.num_collisions_in_solution = np.sum( + cnt_coll_trjs) # Total collisions across all batches, all inits + if np.all(cnt_coll_trjs == 0): + single_trial_result.success_status = TrialSuccessStatus.SUCCESS + elif np.any(cnt_coll_trjs == 0): + single_trial_result.success_status = TrialSuccessStatus.PARTIAL_SUCCESS + else: + single_trial_result.success_status = TrialSuccessStatus.FAIL_COLLISION_AGENTS + trial_success_status = single_trial_result.success_status # Update status + + # Check for static obstacle collisions in the final solution + task: PlanningTask = low_level_planner_l[0].dataset.task + n_support_points = low_level_planner_l[0].dataset.n_support_points + n_interpolated_points = ceil( + n_support_points * sa_planner_cfg['factor_num_interpolated_points_for_collision']) + trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions = \ + task.get_trajs_collision_and_free( + torch.from_numpy(all_paths_array).reshape( + B*I*N, H, -1).to(**tensor_args), # Reshape to (B*I*N, H, D) + return_indices=True, + num_interpolation=n_interpolated_points, + ) + obstacle_collision_masks: np.ndarray = trajs_waypoints_collisions.to( + 'cpu').numpy().reshape(B, I, N, H) # (N, I, P, H) + + if True: + # Data Adherence Metric + single_trial_result.data_adherence = 0.0 + ma_planner_cfg = config['planner']['multi_agent'] + start_time_l = [i * ma_planner_cfg['stagger_start_time_dt'] + for i in range(num_agents)] + data_adherence_batch = calc_data_adherence( + all_paths_array, + agent_model_ids_l, + agent_model_transforms_l, + start_time_l, + H, + tensor_args + ) # [B, I, N] + single_trial_result.data_adherence = data_adherence_batch.mean() + # Acceleration and Velocity + accel_sum = 0 + max_vel_l, mean_vel_l = [], [] + # normalize all paths to get the normalized velocities + normed_trjs = low_level_planner_l[0].dataset.normalize_trajectories( + torch.from_numpy(all_paths_array).to(**tensor_args), + ).to('cpu').numpy() # [B, I, N, H, D] + traj_pos = normed_trjs[..., :2] # [B, I, N, H, 2] + velocities = calc_velocity( + traj_pos, dt=low_level_planner_l[0].robot.dt) # [B, I, N, H-1] + # pad zero in front + velocities = np.concatenate( + [np.zeros(velocities.shape[:-1]+(1,)), velocities], + axis=-1 + ) + dims_except_agent = tuple( + range(velocities.ndim - 2)) + (velocities.ndim-1, ) + # Max velocity per agent + max_vel_l = velocities.max(dims_except_agent).tolist() + mean_vel_l = velocities.mean( + dims_except_agent).tolist() # Mean velocity per agent + + # single_trial_result.mean_path_acceleration_per_agent = accel_sum / num_agents + single_trial_result.mean_path_acceleration_per_agent = None + single_trial_result.max_velocity_l = max_vel_l + single_trial_result.mean_velocity_l = mean_vel_l + + if timing_profiles_l[0] is not None: + timing_profiles_merged = { + 'project': np.zeros((I,), dtype=float), + 'coupling': np.zeros((I,), dtype=float), + 'diffuse': np.zeros((I,), dtype=float), + 'others': np.zeros((I,), dtype=float), + } + for i, t_profile in enumerate(timing_profiles_l): + for k in ['project', 'coupling', 'diffuse', 'others']: + timing_profiles_merged[k][i] = t_profile.get(k, 0.0) + + # ============================ + # Save All Data + # ============================ + # Prepare raw data dictionary for .npz saving + raw_data = { + "all_state_trjs": all_paths_array, # [B, I, N, H, D] + "starts_global": starts_array, # [B, I, N, D] + "goals_global": goals_array, # [B, I, N, D] + } + processed_data = { + # [B, I, N, H, D - 2] + "velocity_trjs": velocities, + # [B, I, N, N, H] + "collision_masks": collision_masks, + "data_adherence": data_adherence_batch, # [B, I, N] + "obstacle_collision_masks": obstacle_collision_masks, # [B, I, N, H] + } + + # Update mock config with final dt + mock_trial_cfg.dt = low_level_planner_l[0].robot.dt + + print(GREEN, "--- FINAL RESULTS ---", RESET) + print(single_trial_result) + + # Save results, config, and raw data + single_trial_result.save_json(results_dir) + mock_trial_cfg.save(results_dir) # Saves the simplified trial config + with open(os.path.join(results_dir, "run_config_full.yaml"), 'w') as f: + yaml.dump(config, f, default_flow_style=False) + np.savez_compressed(os.path.join(results_dir, "raw_data.npz"), **raw_data) + np.savez_compressed(os.path.join( + results_dir, "processed_data.npz"), **processed_data) + if timing_profiles_l[0] is not None: + np.savez_compressed(os.path.join( + results_dir, "timing_profiles.npz"), **timing_profiles_merged) + + logger.info(f"All results, configs, raw & processed data are saved to '{results_dir}'.") + + return trial_success_status, processed_data + + +def run_planning_experiment(config: dict, comments: str = '', trial_number=0): + """ + Runs a single multi-agent planning experiment based on the provided configuration. + """ + exp_cfg = config['experiment'] + sys_cfg = config['system'] + planner_cfg = config['planner'] + env_cfg = config['environment'] + + # Setup System and Directories + device = sys_cfg['device'] + tensor_args = {'device': device, 'dtype': torch.float32} + log_pth = sys_cfg.get('log_pth', None) + + base_dir = Path(__file__).resolve( + ).parent.parent.parent if sys_cfg['base_dir'] == 'auto' else Path(sys_cfg['base_dir']) + base_dir_str = base_dir.as_posix() + trained_models_dir = base_dir.joinpath(sys_cfg['trained_models_dir_name']) + + time_str = datetime.now().strftime( + "%m%d%Y_%H%M%S") if exp_cfg['time_str'] == 'auto' else exp_cfg['time_str'] + # Append comments to the results timestamp when provided. + if comments: + time_str += f"-{comments.replace(' ', '_')}" + + # Create results directory path correctly + mock_trial_cfg = MultiAgentPlanningSingleTrialConfig() + mock_trial_cfg.instance_name = exp_cfg['name'] + mock_trial_cfg.num_agents = exp_cfg['num_agents'] + mock_trial_cfg.multi_agent_planner_class = planner_cfg['multi_agent']['class'] + mock_trial_cfg.single_agent_planner_class = planner_cfg['single_agent']['class'] + results_dir = get_result_dir_pcdiff( + mock_trial_cfg, planner_cfg, base_dir_str, time_str, trial_number) + os.makedirs(results_dir, exist_ok=True) + logger.info(f"Results will be saved to: {results_dir}") + + # Setup Planning Problem + num_agents = exp_cfg['num_agents'] + global_model_ids = env_cfg['global_model_ids'] + agent_skeleton_l = [ + [[0, 0]]] * num_agents if env_cfg['agent_skeleton_l'] == 'auto' else env_cfg['agent_skeleton_l'] + dummy_start_l, dummy_goal_l = generate_start_goal_positions( + config, tensor_args) + dummy_start_l = dummy_start_l[0] + dummy_goal_l = dummy_goal_l[0] + dummy_start = torch.stack(dummy_start_l) + dummy_goal = torch.stack(dummy_goal_l) + + # ============================ + # Setup Planners with all keys + # ============================ + sa_planner_cfg = planner_cfg['single_agent'] + ma_planner_cfg = planner_cfg['multi_agent'] + projection_args = planner_cfg.get('projection', None) + cost_func_param = sa_planner_cfg.get('cost_func_param') + if cost_func_param is None: + cost_func_param = {} + + low_level_planner_model_args = { + 'planner_alg': sa_planner_cfg['planner_alg'], + 'use_guide_on_extra_objects_only': sa_planner_cfg['use_guide_on_extra_objects_only'], + # Total samples across all agents + 'n_samples': sa_planner_cfg['n_samples'] * num_agents, + 'n_local_inference_noising_steps': sa_planner_cfg['n_local_inference_noising_steps'], + 'n_local_inference_denoising_steps': sa_planner_cfg['n_local_inference_denoising_steps'], + 'start_guide_steps_fraction': sa_planner_cfg['start_guide_steps_fraction'], + 'cost_func_key': sa_planner_cfg['cost_func_key'], + 'n_guide_steps': sa_planner_cfg['n_guide_steps'], + 'n_diffusion_steps_without_noise': sa_planner_cfg['n_diffusion_steps_without_noise'], + 'weight_grad_cost_collision': sa_planner_cfg['weight_grad_cost_collision'], + 'weight_grad_cost_smoothness': sa_planner_cfg['weight_grad_cost_smoothness'], + 'weight_grad_cost_constraints': sa_planner_cfg['weight_grad_cost_constraints'], + 'weight_grad_cost_soft_constraints': sa_planner_cfg['weight_grad_cost_soft_constraints'], + 'factor_num_interpolated_points_for_collision': sa_planner_cfg['factor_num_interpolated_points_for_collision'], + 'trajectory_duration': sa_planner_cfg['trajectory_duration'], + 'device': device, + 'debug': sa_planner_cfg['debug'], + 'seed': exp_cfg['seed'], + 'results_dir': results_dir, + 'trained_models_dir': trained_models_dir, + 'projection_args': projection_args, + 'cost_func_param': cost_func_param, + 'timeit': sa_planner_cfg.get('timeit', False), + 'n_agents': num_agents, + } + + # DiRecT-specific args (PDM shares the same flags) + if sa_planner_cfg['class'] in {'DiRecT', 'PDM'}: + low_level_planner_model_args['projector_type'] = sa_planner_cfg.get('projector_type', 'JaxScpADMM') + low_level_planner_model_args['start_projection_guidance'] = sa_planner_cfg.get('start_projection_guidance', 0.0) + + high_level_planner_model_args = { + 'is_xcbs': ma_planner_cfg.get('is_xcbs', False), + 'is_ecbs': ma_planner_cfg.get('is_ecbs', False), + 'start_time_l': [i * ma_planner_cfg['stagger_start_time_dt'] for i in range(num_agents)], + 'runtime_limit': exp_cfg['runtime_limit'], + 'conflict_type_to_constraint_types': { + eval(k): {eval(v_i) for v_i in v} for k, v in ma_planner_cfg['conflict_type_to_constraint_types'].items() + } + } + + # ============================ + # Create Reference Agent and Planner + # ============================ + tile_width, tile_height = 2.0, 2.0 + global_model_transforms = [[torch.tensor([x * tile_width, -y * tile_height], **tensor_args) + for x in range(len(global_model_ids[0]))] for y in range(len(global_model_ids))] + + low_level_planner_class = eval(sa_planner_cfg['class']) + + reference_agent_skeleton = [[r, c] for r in range( + len(global_model_ids)) for c in range(len(global_model_ids[0]))] + reference_agent_transforms = { + sk_step: global_model_transforms[sk_coord[0]][sk_coord[1]] + for sk_step, sk_coord in enumerate(reference_agent_skeleton) + } + reference_agent_model_ids = [ + global_model_ids[sk_coord[0]][sk_coord[1]] + for sk_coord in reference_agent_skeleton + ] + + logger.info("Creating reference agent and planner.") + ref_args = low_level_planner_model_args.copy() + ref_args.update({ + 'start_state_pos': dummy_start, 'goal_state_pos': dummy_goal, + 'model_ids': reference_agent_model_ids, + 'transforms': reference_agent_transforms, + }) + if sa_planner_cfg['class'] in {"PCD", "DiRecT", "PDM"}: + ref_args['model_id'] = reference_agent_model_ids[0] + + reference_low_level_planner = low_level_planner_class(**ref_args) + reference_task = reference_low_level_planner.task + reference_robot = reference_low_level_planner.robot + + # ============================ + # Create Agent-Specific Models and Final Planners + # ============================ + + agent_model_transforms_l, agent_model_ids_l = [], [] + for agent_id in range(num_agents): + agent_transforms = { + sk_step: global_model_transforms[sk_coord[0]][sk_coord[1]] + for sk_step, sk_coord in enumerate(agent_skeleton_l[agent_id]) + } + agent_ids = [ + global_model_ids[sk_coord[0]][sk_coord[1]] + for sk_coord in agent_skeleton_l[agent_id] + ] + agent_model_transforms_l.append(agent_transforms) + agent_model_ids_l.append(agent_ids) + + planners_creation_start_time = time.time() + low_level_planner_args = low_level_planner_model_args.copy() + low_level_planner_args.update({ + 'start_state_pos': dummy_start, # this does not matter + 'goal_state_pos': dummy_goal, # this does not matter + 'model_ids': agent_model_ids_l[0], + 'transforms': agent_model_transforms_l[0] + }) + if sa_planner_cfg['class'] in {"PCD", "DiRecT", "PDM"}: + low_level_planner_args['model_id'] = agent_model_ids_l[0][0] + + low_level_planner_l = [low_level_planner_class(**low_level_planner_args)] + logger.info(f"Planner creation time: {time.time() - planners_creation_start_time:.4f}s\n" + '-' * 80) + + # ============================ + # Create the Multi-Agent Planner + # ============================ + multi_agent_planner_class = eval(ma_planner_cfg['class']) + planner: DummyPlanning + planner = multi_agent_planner_class( + low_level_planner_l, + dummy_start_l, dummy_goal_l, + reference_task=reference_task, + reference_robot=reference_robot, + **high_level_planner_model_args + ) + + # ============================ + # Start Planning + # ============================ + + n_inits = exp_cfg['n_inits'] + all_paths_l = [] + logger.info(f"Start planning for {n_inits} trials with {num_agents} agents.") + + # Reset seed for reproducibility (initialization of planner used random state generator) + fix_random_seed(exp_cfg['seed']) + # Precompute starts and goals for all inits + starts = [] + goals = [] + start_ls, goal_ls = generate_start_goal_positions( + config, tensor_args, n_inits) + for s, g in zip(start_ls, goal_ls): + start_l_global, goal_l_global = transform_start_goal_positions( + num_agents, + s, g, + global_model_transforms, agent_skeleton_l + ) + starts.append(start_l_global) + goals.append(goal_l_global) + + pbar = tqdm(zip(starts, goals), desc="Inits", total=n_inits, ncols=80) + timing_profiles_l = [] + for s_glb, g_glb in pbar: + planner.update_start_goal_states(s_glb, g_glb) + start_time = time.time() + paths_this_init, t_profile, num_ct_expansions_this_init, trial_success_status, num_collisions_in_solution = \ + planner.plan(runtime_limit=exp_cfg['runtime_limit'], ) + planning_time = time.time() - start_time + # List[ [N*B, H, D] ], move to CPU for saving + all_paths_l.append(torch.stack(paths_this_init, dim=0).to('cpu')) + timing_profiles_l.append(t_profile) + pbar.set_postfix({ + 'time': f"{planning_time:.3f}s" + }) + # end for [init_idx] + all_paths_cpu = torch.stack(all_paths_l, dim=1).to('cpu') # [N*B, I, H, D] + + # ============================ + # Process, Evaluate, and Save Results + # ============================ + trial_success_status, processed_data = evaluate_and_save_results( + config, mock_trial_cfg, results_dir, + all_paths_cpu, planning_time, trial_success_status, num_ct_expansions_this_init, num_collisions_in_solution, + timing_profiles_l, + starts, goals, + global_model_ids, agent_skeleton_l, + agent_model_ids_l, agent_model_transforms_l, + low_level_planner_l, reference_robot, tensor_args + ) + + # Write the output directory to a .log file + if log_pth: + outer_log_file = log_pth + with open(outer_log_file, 'a' if os.path.exists(outer_log_file) else 'w') as f: + f.write(f'{results_dir}\n') + + # ============================ + # Render Animation + # ============================ + # Find a feasible path to render + collisions: np.ndarray = processed_data['collision_masks'].any( + axis=(-1, -2, -3)) # [B, I, N, N, H] -> [B, I] + coll_free_path_mask = ~collisions # [B, I] + # Find all (b, i) indices where the path is collision-free + # shape [num_free, 2], each row is [b, i] + coll_free_indices = np.argwhere(coll_free_path_mask) + # If there are any collision-free paths, pick the first one; otherwise, default to (0, 0) + if coll_free_indices.shape[0] > 0: + b_idx, i_idx = coll_free_indices[0] + else: + b_idx, i_idx = 0, 0 + + grp_sz = sa_planner_cfg['n_samples'] + paths_l = [all_paths_cpu[n * grp_sz + b_idx, i_idx].to(**tensor_args) + for n in range(num_agents)] + if len(paths_l) > 0 and exp_cfg['render_animation']: + logger.info("Rendering final animation...") + # Restore the corresponding start and goal positions for rendering + s_glb, g_glb = starts[i_idx], goals[i_idx] + planner.update_start_goal_states(s_glb, g_glb) + # Denser trajectory for smoother GIF + render_paths = densify_trajs(paths_l, 1) + planner.render_paths( + render_paths, + output_fpath=os.path.join(results_dir, f"{exp_cfg['name']}.gif"), + plot_trajs=True, + animation_duration=5.0 + ) + logger.info("Rendering complete.") + + +@ click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True}) +@ click.option("--config", type=click.Path(exists=True), required=True, + help="Path to the configuration YAML file.") +@ click.option("--log_pth", type=click.Path(), default=None, + help="Optional path to save logs. If not provided, logs will be saved in the results directory.") +@ click.option("--trial_number", type=int, default=0, + help="Trial number for saving results. Default is 0.") +@ click.option("--comments", "-m", default="", help="Optional comments for the results directory.") +@ click.pass_context +def main(ctx, config, log_pth, trial_number, comments): + # ctx.args contains all the unknown args (our overrides) + overrides = parse_overrides(ctx.args) + + # load & override + cfg = load_config(config, overrides) + cfg['log_pth'] = log_pth + + run_planning_experiment(cfg, comments, trial_number) + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_mmd.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..8de5c736eb30dacfd2c790762887d8fff1f00f44 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_mmd.py @@ -0,0 +1,81 @@ +""" +Modified from +- https://github.com/yoraish/mmd + +MIT License + +Copyright (c) 2024 Yorai Shaoul +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. + +# Project includes. +from torch import fix +from mmd.common.experiments.experiment_utils import * +from inference_mmd import run_multi_agent_trial + + +def run_multi_agent_experiment(experiment_config: MultiAgentPlanningExperimentConfig): + # Run the multi-agent planning experiment. + startt = time.time() + # Create the experiment config. + experiment_config.time_str = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + # Get single trial configs from the experiment config. + single_trial_configs = experiment_config.get_single_trial_configs_from_experiment_config() + # So let's run sequentially. + for single_trial_config in single_trial_configs: + print(single_trial_config) + try: + run_multi_agent_trial(single_trial_config) + + # Aggregate and save results after each trial. + combine_and_save_results_for_experiment(experiment_config) + except Exception as e: + print("Error in run_multi_agent_experiment: ", e) + # Save the error details for this trial. + with open(f"error_{experiment_config.time_str}.txt", "a") as f: + f.write(str(e)) + f.write("This is for single_trial_config: ") + f.write(str(single_trial_config)) + f.write("\n") + continue + + # Print the runtime. + print("Runtime: ", time.time() - startt) + print("Run: OK.") + + +if __name__ == "__main__": + experiment_config = MultiAgentPlanningExperimentConfig() + experiment_config.num_agents_l = [4] + experiment_config.instance_name = "EnvConveyor2DRobotPlanarDiskRandom" + # experiment_config.instance_name = "EnvEmpty2DRobotPlanarDiskRandom" + # experiment_config.instance_name = "EnvHighways2DRobotPlanarDiskRandom" + # experiment_config.instance_name = "EnvDropRegion2DRobotPlanarDiskRandom" + + experiment_config.stagger_start_time_dt = 0 + experiment_config.multi_agent_planner_class_l = ["CBS"] + experiment_config.single_agent_planner_class = "MPD" + experiment_config.runtime_limit = 60 + experiment_config.num_trials_per_combination = 100 + # experiment_config.num_trials_per_combination = 5 + experiment_config.render_animation = False + run_multi_agent_experiment(experiment_config) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_pcdiff.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_pcdiff.py new file mode 100644 index 0000000000000000000000000000000000000000..039ad4874443d623f4153ba81c5382b951331de0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/launch_pcdiff.py @@ -0,0 +1,308 @@ +import os +import sys +import logging +import datetime +import click +import pdb +import numpy as np +import importlib.util + +from tqdm import tqdm +from itertools import product +from os.path import join, dirname + +# Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +BASE_DIR = dirname(os.path.abspath(__file__)) +PROJECT_ROOT= dirname(dirname(BASE_DIR)) +CONFIG_DIR = join(PROJECT_ROOT, "configs") +LOG_DIR = os.path.join(PROJECT_ROOT, "results/000event_logs") +GUIDERS = ['vanilla', 'coupling', 'coupling_ps', 'base', 'final'] +PROJECTORS = ['none', 'max_vel_admm', 'ipopt', 'scp_admm'] + +PROJECTOR_PARAMS = { + 'max_vel_admm': { + '_target_': 'ADMMProjectionOperatorCUDAGraph', + 'rho': 10.0, + 'max_iter': 1000, + 'tol': 3.0e-6, + 'decomp': 'lu', + 'convergence_check': True, + 'check_period': 100, + 'verbose': False, + } +} + + +# default sweep params +# n_init_states = 20 # Mini +n_init_states = 100 +# num_agents = 2 +num_agents = 4 +batch_size = 128 +# batch_size = 4 +default_cost_fn_key = "hinge_sqr_l2" +steps = [1] +#----------------------- Highway ------------------------------- +# vmaxs = [0.647, 0.781, 0.878] # 80%, 90%, 95% of max action +vmaxs = [0.647] + + +#----------------------- Empty ----------------------------------------------- +# vmaxs = [0.675, 0.692, 0.703] # # 75%, 90%, 95% of max action +# vmaxs = [0.675, ] + +#----------------------- Conveyor -------------------------------------------- +# vmaxs = [1.21, 1.46, 1.76] # 85%, 90%, 95% of max action + + + +#----------------------- DropRegion -------------------------------------------- +# vmaxs = [0.928, 1.13, 1.34] # 85%, 90%, 95% of max action + + +scales = [1e-1, 2e-1,] +skips = [] + +# Helpers +def import_module_from_path(module_name: str, file_path: str): + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot import {module_name} from {file_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) # type: ignore + logger.info(f"Imported module '{module_name}' from '{file_path}'") + return module + +# CLI +@click.command(context_settings={"ignore_unknown_options": True, + "allow_extra_args": True}) +@click.option("--config", type=str, required=True, + help="Path to the configuration YAML file.") +@click.option("--work-dir", type=str, default=BASE_DIR) +@click.option("--gpu-id", type=int, default=0) +@click.option("--guider", "-g", type=click.Choice(GUIDERS), required=True) +@click.option("--projector","-p", type=click.Choice(PROJECTORS), required=True) +@click.option("--cost-func-key","-c", type=str, default=default_cost_fn_key) +@click.option("--product-params","-r", is_flag=True) +@click.option("--comments", "-m", type=str, default="") +@click.option("--n-inits", "-n", type=int, default=None, + help="Number of initial states (overrides the default in the script).") +@click.option("--vmax", "-v", type=float, multiple=True, + help="Velocity cap(s) for projection. Repeatable, e.g. -v 0.675 -v 0.703.") +@click.option("--agents", "-a", type=int, default=None, + help="Number of agents (overrides the default in the script).") +@click.pass_context +def main(ctx, config, work_dir, gpu_id, guider, projector, + cost_func_key, product_params, comments, n_inits, vmax, agents): + global n_init_states, vmaxs, num_agents + if n_inits is not None: + n_init_states = n_inits + if vmax: + vmaxs = list(vmax) + if agents is not None: + num_agents = agents + + # grab any --foo=bar or foo=bar tokens + raw_overrides = list(ctx.args) + + # sanity + assert os.path.isdir(LOG_DIR), f"LOG_DIR not found: {LOG_DIR}" + os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id) + + + ### Now import GPU-specific modules + from mmd.common.argparse import load_config, parse_overrides + + timestamp = datetime.datetime.now().strftime("%y%m%d_%H%M%S") + log_nm = f"eval_runs-{timestamp}" + (f"-{comments}" if comments else "") + ".log" + log_pth = os.path.join(LOG_DIR, log_nm) + logger.info(f"Logging all runs to {log_pth}") + if raw_overrides: + logger.info(f"Ad-hoc overrides: {raw_overrides}") + + # import sub-scripts + guide_py = os.path.join(work_dir, "inference_pcdiff.py") + base_py = os.path.join(work_dir, "inference_pcdiff.py") + eval_guide = import_module_from_path("eval_guide", guide_py) + eval_base = import_module_from_path("eval_guide", base_py) + + cfg_dir = CONFIG_DIR + + # --- 4a) Baseline --- + if guider=="vanilla" and projector=="none": + logger.info(f"Running baseline (vanilla/none) for {n_init_states} trials…") + + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.single_agent.planner_alg='vanilla'", + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.n_guide_steps={0}", + f"--planner.single_agent.weight_grad_cost_constraints={0}", + f"--planner.single_agent.n_samples={batch_size}", + ] + raw_overrides + + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_base.run_planning_experiment(cfg, comments=comments) + logger.info("Baseline done.") + + # --- 4b) Projection-only --- + elif guider=="vanilla" and projector!="none": + for v_max in tqdm(vmaxs, desc="Projected runs", ncols=80): + logger.info(f"Running projector={projector} v_max={v_max} for {n_init_states} trials…") + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.projection.project_params.vel_max={v_max}", + f"--planner.single_agent.planner_alg='projected'", + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.n_guide_steps={0}", + f"--planner.single_agent.weight_grad_cost_constraints={0}", + f"--planner.single_agent.n_samples={batch_size}", + ] + # add projector defaults + for k, v in PROJECTOR_PARAMS[projector].items(): + args_list.append(f"--planner.projection.projector.{k}={v}") + + args_list += raw_overrides + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_base.run_planning_experiment(cfg, comments=comments) + logger.info(f"Finished v_max={v_max}.") + + # --- 4c) Coupling-only --- + elif guider in ("coupling","coupling_ps") and projector=="none": + grid = (product(steps, scales) if product_params + else zip(steps, scales)) + total = (len(steps)*len(scales)) if product_params else len(steps) + + for stp, scl in tqdm(grid, total=total, desc="Coupling runs", ncols=80): + if any(np.linalg.norm(np.array([stp,scl]) - np.array(skip))<1e-6 + for skip in skips): + logger.info(f"Skip stp={stp}, scl={scl}") + continue + + logger.info(f"Running coupling {cost_func_key} stp={stp}, scl={scl} for {n_init_states} trials…") + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.single_agent.planner_alg='coupling'", + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.n_guide_steps={stp}", + f"--planner.single_agent.weight_grad_cost_constraints={scl}", + f"--planner.single_agent.n_samples={batch_size}", + ] + raw_overrides + + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_guide.run_planning_experiment(cfg, comments=comments) + logger.info(f"Done stp={stp}, scl={scl}.") + + # --- 4d) Coupling + Projection --- + elif guider in ("coupling","coupling_ps") and projector!="none": + grid = (product(steps, scales, vmaxs) if product_params + else zip(steps, scales, vmaxs)) + total = (len(steps)*len(scales)*len(vmaxs) + if product_params else len(steps)) + + for trial, (stp, scl, v_max) in enumerate(tqdm(grid, total=total, desc="PCDiff runs", ncols=80)): + if any(np.linalg.norm(np.array([stp,scl]) - np.array(skip))<1e-6 + for skip in skips): + logger.info(f"Skip stp={stp}, scl={scl}") + continue + + logger.info(f"Running coupling+proj {cost_func_key} stp={stp}, scl={scl}, v_max={v_max}…") + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.projection.project_params.vel_max={v_max}", + f"--planner.single_agent.planner_alg='pcd'", + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.n_guide_steps={stp}", + f"--planner.single_agent.weight_grad_cost_constraints={scl}", + f"--planner.single_agent.n_samples={batch_size}", + ] + for k, v in PROJECTOR_PARAMS[projector].items(): + args_list.append(f"--planner.projection.projector.{k}={v}") + args_list += raw_overrides + + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_guide.run_planning_experiment(cfg, comments=comments) + logger.info(f"Done stp={stp}, scl={scl}, v_max={v_max}.") + + # --- 4e) DiRecT base + projection (ipopt or scp_admm) --- + elif guider=="base" and projector in ("ipopt", "scp_admm"): + projector_type_map = {"ipopt": "Ipopt", "scp_admm": "JaxScpADMM"} + proj_type = projector_type_map[projector] + + for v_max in tqdm(vmaxs, desc=f"DiRecT+{projector} runs", ncols=80): + logger.info(f"Running DiRecT base + {projector} v_max={v_max} for {n_init_states} trials…") + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.projection.project_params.vel_max={v_max}", + f"--planner.single_agent.planner_alg='base'", + f"--planner.single_agent.start_guide_steps_fraction={0.0}", + f"--planner.single_agent.n_guide_steps={0}", + f"--planner.single_agent.weight_grad_cost_constraints={0}", + f"--planner.single_agent.n_samples={batch_size}", + f"--planner.single_agent.projector_type={proj_type!r}", + ] + raw_overrides + + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_base.run_planning_experiment(cfg, comments=comments) + logger.info(f"Done v_max={v_max}.") + + # --- 4g) DiRecT final (unguided diffusion + project at last step only) --- + elif guider == "final" and projector != "none": + projector_type_map = { + "ipopt": "Ipopt", + "scp_admm": "JaxScpADMM", + "max_vel_admm": "ADMMVelOnly", + } + proj_type = projector_type_map[projector] + + for v_max in tqdm(vmaxs, desc=f"DiRecT final+{projector} runs", ncols=80): + logger.info(f"Running DiRecT final + {projector} v_max={v_max} for {n_init_states} trials…") + args_list = [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={num_agents}", + f"--experiment.n_inits={n_init_states}", + f"--planner.projection.project_params.vel_max={v_max}", + f"--planner.single_agent.planner_alg='final'", + f"--planner.single_agent.start_guide_steps_fraction={0.0}", + f"--planner.single_agent.n_guide_steps={0}", + f"--planner.single_agent.weight_grad_cost_constraints={0}", + f"--planner.single_agent.n_samples={batch_size}", + f"--planner.single_agent.projector_type={proj_type!r}", + ] + if projector in PROJECTOR_PARAMS: + for k, v in PROJECTOR_PARAMS[projector].items(): + args_list.append(f"--planner.projection.projector.{k}={v}") + args_list += raw_overrides + + overrides = parse_overrides(args_list) + cfg = load_config(join(cfg_dir, config), overrides) + eval_base.run_planning_experiment(cfg, comments=comments) + logger.info(f"Done v_max={v_max}.") + + else: + raise click.UsageError(f"Unknown combination: guider={guider}, projector={projector}") + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.21.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.21.sh new file mode 100644 index 0000000000000000000000000000000000000000..73365df08c73c33e7b16c87c135decdb801cacb1 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.21.sh @@ -0,0 +1,22 @@ +#!/bin/bash +#SBATCH --job-name=bench_conv_v1.21 +#SBATCH --partition=YOUR_GPU_PARTITION +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_logs/%j_%x.out +#SBATCH --error=slurm_logs/%j_%x.err + +mkdir -p slurm_logs + +source "${CONDA_BASE:-$HOME/miniconda3}/etc/profile.d/conda.sh" +conda activate direct-mmd + +cd "$(dirname "$0")/../.." + +python scripts/inference/run_benchmark.py \ + --gpu-id 0 \ + --envs conveyor \ + --vmaxs 1.21 \ + -m "bench_j1of6_conveyor_v1.21" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.46.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.46.sh new file mode 100644 index 0000000000000000000000000000000000000000..0edce89b75d2dfcc048cf01a4010b79b44ff06e4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.46.sh @@ -0,0 +1,22 @@ +#!/bin/bash +#SBATCH --job-name=bench_conv_v1.46 +#SBATCH --partition=YOUR_GPU_PARTITION +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_logs/%j_%x.out +#SBATCH --error=slurm_logs/%j_%x.err + +mkdir -p slurm_logs + +source "${CONDA_BASE:-$HOME/miniconda3}/etc/profile.d/conda.sh" +conda activate direct-mmd + +cd "$(dirname "$0")/../.." + +python scripts/inference/run_benchmark.py \ + --gpu-id 0 \ + --envs conveyor \ + --vmaxs 1.46 \ + -m "bench_j2of6_conveyor_v1.46" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.76.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.76.sh new file mode 100644 index 0000000000000000000000000000000000000000..5f4a37019d8c3ddd2004ce41e54f19dec0b8bc7d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/rerun_conveyor_v1.76.sh @@ -0,0 +1,22 @@ +#!/bin/bash +#SBATCH --job-name=bench_conv_v1.76 +#SBATCH --partition=YOUR_GPU_PARTITION +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_logs/%j_%x.out +#SBATCH --error=slurm_logs/%j_%x.err + +mkdir -p slurm_logs + +source "${CONDA_BASE:-$HOME/miniconda3}/etc/profile.d/conda.sh" +conda activate direct-mmd + +cd "$(dirname "$0")/../.." + +python scripts/inference/run_benchmark.py \ + --gpu-id 0 \ + --envs conveyor \ + --vmaxs 1.76 \ + -m "bench_j3of6_conveyor_v1.76" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_all_methods.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_all_methods.sh new file mode 100644 index 0000000000000000000000000000000000000000..47bef6261f6fb3600eda948883419964bad7f79c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_all_methods.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run all benchmark methods for a given environment and agent count. +# +# Usage: +# bash run_all_methods.sh GPU_ID ENV N_AGENTS [--dry-run] +# +# Examples: +# bash run_all_methods.sh 0 empty 4 +# bash run_all_methods.sh 0 highway 4 --dry-run + +set -e + +GPU=${1:?Usage: run_all_methods.sh GPU_ID ENV N_AGENTS [--dry-run]} +ENV=${2:?Usage: run_all_methods.sh GPU_ID ENV N_AGENTS [--dry-run]} +N_AGENTS=${3:?Usage: run_all_methods.sh GPU_ID ENV N_AGENTS [--dry-run]} +shift 3 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +python run_benchmark.py --gpu-id "$GPU" --envs "$ENV" --agents "$N_AGENTS" "$@" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_benchmark.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..f3e5ca8d21f2e10b118087b7d2bf08e229e9648f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_benchmark.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python +""" +Benchmark comparison script for multi-robot diffusion planning. + +Runs all method variants across environments, loss functions, velocity +constraints, and agent counts. Every method call explicitly sets ALL key +parameters so you know exactly what is running. + +Usage: + # Full benchmark + python scripts/inference/run_benchmark.py --gpu-id 0 + + # Quick test: one env, one agent count + python scripts/inference/run_benchmark.py --gpu-id 0 \ + --envs empty --agents 4 --vmaxs 0.675 --costs hinge_sqr_l2 + + # Only specific methods + python scripts/inference/run_benchmark.py --gpu-id 0 --only pcd direct + + # Dry run (print what would run, don't execute) + python scripts/inference/run_benchmark.py --gpu-id 0 --dry-run +""" + +import os +import sys +import logging +import datetime +import argparse +import importlib.util +from os.path import join, dirname + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +BASE_DIR = dirname(os.path.abspath(__file__)) +PROJECT_ROOT = dirname(dirname(BASE_DIR)) +CONFIG_DIR = join(PROJECT_ROOT, "configs") +LOG_DIR = os.path.join(PROJECT_ROOT, "results/000event_logs") + +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ GLOBAL PARAMETERS ║ +# ║ Edit these to control the full benchmark sweep. ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + +# ─── ENVIRONMENTS ─── +ENVS = { + 'empty': { + 'pcd_config': 'pcdiff_empty.yaml', + 'direct_config': 'direct_empty.yaml', + 'vel_maxs': [0.675, 0.692, 0.703], # 75%, 90%, 95% of max action + 'guide_weight_coll': 0.1, # weight_grad_cost_collision + 'admm_rho': 10.0, # ADMM penalty (from original config) + 'direct_smoothness_weight': 0.0, # projection smoothness for DiRecT + }, + 'highway': { + 'pcd_config': 'pcdiff_highway.yaml', + 'direct_config': 'direct_highway.yaml', + 'vel_maxs': [0.647, 0.781, 0.878], # 80%, 90%, 95% of max action + 'guide_weight_coll': 0.1, + 'admm_rho': 6.0, + 'direct_smoothness_weight': 0.0, + }, + 'conveyor': { + 'pcd_config': 'pcdiff_conveyor.yaml', + 'direct_config': 'direct_conveyor.yaml', + 'vel_maxs': [1.21, 1.46, 1.76], # 85%, 90%, 95% of max action + 'guide_weight_coll': 0.05, + 'admm_rho': 6.0, + 'direct_smoothness_weight': 0.0, + }, + 'dropregion': { + 'pcd_config': 'pcdiff_dropregion.yaml', + 'direct_config': 'direct_dropregion.yaml', + 'vel_maxs': [0.928, 1.13, 1.34], # 85%, 90%, 95% of max action + 'guide_weight_coll': 0.05, + 'admm_rho': 6.0, + 'direct_smoothness_weight': 0.0, + }, +} + +# ─── SWEEP AXES ─── +N_AGENTS_LIST = [4, 8, 12, 16, 20] +COST_FUNC_KEYS = ['hinge_sqr_l2', 'sum_log_l2'] + +# ─── EXPERIMENT SETTINGS ─── +N_INITS = 100 # number of random initializations +BATCH_SIZE = 128 # n_samples per agent +SEED = 18 +TIMEIT = False + +# ─── GUIDANCE DEFAULTS (for PCD / CD) ─── +N_GUIDE_STEPS = 1 +GUIDE_WEIGHT_SMOOTH = 0.0 # weight_grad_cost_smoothness +GUIDE_FRACTION = 1.0 # start_guide_steps_fraction + +# ─── PER-COST γ (coupling strength) ─── +COST_FUNC_DEFAULTS = { + 'hinge_sqr_l2': {'guide_weight_constr': 0.2}, # SHD + 'sum_log_l2': {'guide_weight_constr': 0.02}, # LB +} + +# ─── PROJECTION START FRACTION (per method) ─── +# PCD projects at every step (no t_start_projection param in PCD class) +PROJ_FRACTION_DIRECT = 0.5 # DiRecT: JaxScpADMM on Tweedie +PROJ_FRACTION_FINAL = 0.5 # final: ignored (overridden to t=0 only in __init__) +PROJ_FRACTION_PDM = 0.5 # PDM: JaxScpADMM on the noisy latent (same gating as DiRecT) + +# ─── COST FUNCTION PARAMETERS ─── +COST_MARGIN_COEFF = 1.9 +COST_RADIUS_COEFF = 6.0 + +# ─── ADMM PROJECTOR DEFAULTS ─── +ADMM_RHO = 6.0 +ADMM_MAX_ITER = 500 +ADMM_TOL = 1e-5 +ADMM_DECOMP = 'lu' +ADMM_CHECK_PERIOD = 200 + +# ─── MMD-CBS SETTINGS ─── +MMD_RUNTIME_LIMIT = 60 # CBS runtime limit in seconds +MMD_MULTI_AGENT_PLANNER = "CBS" +MMD_SINGLE_AGENT_PLANNER = "MPD" +MMD_OBSTACLE_MARGIN = 0.2 # margin for random start/goal sampling +MMD_ENV_INSTANCE_MAP = { + 'empty': 'EnvEmpty2DRobotPlanarDiskRandom', + 'highway': 'EnvHighways2DRobotPlanarDiskRandom', + 'conveyor': 'EnvConveyor2DRobotPlanarDiskRandom', + 'dropregion': 'EnvDropRegion2DRobotPlanarDiskRandom', +} + +# ─── WHICH METHODS TO RUN ─── +ALL_METHODS = [ + 'diffusion_policy', + 'cd', + 'pcd', + 'direct', + 'pdm', + 'final_projection', + 'mmd_cbs', +] + +# ═════════════════════════════════════════════════════════════════════════════ + + +def import_inference_module(): + """Import inference_pcdiff.py as a module.""" + path = os.path.join(BASE_DIR, "inference_pcdiff.py") + spec = importlib.util.spec_from_file_location("inference_pcdiff", path) + mod = importlib.util.module_from_spec(spec) + sys.modules["inference_pcdiff"] = mod + spec.loader.exec_module(mod) + return mod + + +def import_mmd_modules(): + """Import inference_mmd.py and launch_mmd.py as modules.""" + path = os.path.join(BASE_DIR, "inference_mmd.py") + spec = importlib.util.spec_from_file_location("inference_mmd", path) + mod = importlib.util.module_from_spec(spec) + sys.modules["inference_mmd"] = mod + spec.loader.exec_module(mod) + + path2 = os.path.join(BASE_DIR, "launch_mmd.py") + spec2 = importlib.util.spec_from_file_location("launch_mmd", path2) + mod2 = importlib.util.module_from_spec(spec2) + sys.modules["launch_mmd"] = mod2 + spec2.loader.exec_module(mod2) + return mod2 + + +def _run_experiment(config_path, args_list, log_pth, label, dry_run=False): + """Load config with overrides and run a single experiment.""" + from mmd.common.argparse import load_config, parse_overrides + + logger.info(f"\n{'─'*70}") + logger.info(f" {label}") + logger.info(f" config: {config_path}") + logger.info(f" overrides ({len(args_list)}):") + for a in args_list: + logger.info(f" {a}") + logger.info(f"{'─'*70}") + + if dry_run: + logger.info(" [DRY RUN] skipping execution") + return + + overrides = parse_overrides(args_list) + cfg = load_config(config_path, overrides) + + inference_mod = import_inference_module() + inference_mod.run_planning_experiment(cfg, comments=label) + + +def _admm_projector_args(rho=None): + """ADMM projector override args.""" + return [ + f"--planner.projection.projector._target_=ADMMProjectionOperatorCUDAGraph", + f"--planner.projection.projector.rho={rho if rho is not None else ADMM_RHO}", + f"--planner.projection.projector.max_iter={ADMM_MAX_ITER}", + f"--planner.projection.projector.tol={ADMM_TOL}", + f"--planner.projection.projector.decomp={ADMM_DECOMP}", + f"--planner.projection.projector.convergence_check=True", + f"--planner.projection.projector.check_period={ADMM_CHECK_PERIOD}", + f"--planner.projection.projector.verbose=False", + ] + + +def _common_args(n_agents, log_pth): + """Args shared by every method.""" + return [ + f"--system.log_pth={log_pth!r}", + f"--experiment.num_agents={n_agents}", + f"--experiment.n_inits={N_INITS}", + f"--experiment.seed={SEED}", + f"--planner.single_agent.n_samples={BATCH_SIZE}", + f"--planner.single_agent.timeit={TIMEIT}", + ] + + +# ═══════════════════════════════════════════════════════════════════════════ +# METHOD RUNNERS — each one explicitly sets ALL relevant parameters +# ═══════════════════════════════════════════════════════════════════════════ + +def run_diffusion_policy(env_key, n_agents, log_pth, dry_run=False): + """Diffusion Policy baseline: vanilla diffusion, no guidance, no projection.""" + config_path = join(CONFIG_DIR, ENVS[env_key]['pcd_config']) + env = ENVS[env_key] + label = f"DIFFUSION_POLICY | env={env_key} agents={n_agents}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=PCD", + f"--planner.single_agent.planner_alg=vanilla", + # ── No guidance ── + f"--planner.single_agent.start_guide_steps_fraction=0.0", + f"--planner.single_agent.n_guide_steps=0", + f"--planner.single_agent.weight_grad_cost_constraints=0", + f"--planner.single_agent.weight_grad_cost_collision=0", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Cost function (unused but set for consistency) ── + f"--planner.single_agent.cost_func_key=hinge_sqr_l2", + ] + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_cd(env_key, n_agents, cost_func_key, guide_weight_constr, log_pth, dry_run=False): + """Coupled Diffusion: coupling guidance only, no velocity projection.""" + env = ENVS[env_key] + config_path = join(CONFIG_DIR, env['pcd_config']) + label = f"CD | env={env_key} agents={n_agents} cost={cost_func_key} scl={guide_weight_constr}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=PCD", + f"--planner.single_agent.planner_alg=coupling", + # ── Guidance ── + f"--planner.single_agent.start_guide_steps_fraction={GUIDE_FRACTION}", + f"--planner.single_agent.n_guide_steps={N_GUIDE_STEPS}", + f"--planner.single_agent.weight_grad_cost_constraints={guide_weight_constr}", + f"--planner.single_agent.weight_grad_cost_collision={env['guide_weight_coll']}", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Cost function ── + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.cost_func_param.margin_coeff={COST_MARGIN_COEFF}", + f"--planner.single_agent.cost_func_param.radius_coeff={COST_RADIUS_COEFF}", + ] + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_pcd(env_key, n_agents, vel_max, cost_func_key, guide_weight_constr, log_pth, dry_run=False): + """PCD (full): coupling guidance + ADMM velocity projection.""" + env = ENVS[env_key] + config_path = join(CONFIG_DIR, env['pcd_config']) + label = f"PCD | env={env_key} agents={n_agents} vmax={vel_max} cost={cost_func_key} scl={guide_weight_constr}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=PCD", + f"--planner.single_agent.planner_alg=pcd", + # ── Guidance ── + f"--planner.single_agent.start_guide_steps_fraction={GUIDE_FRACTION}", + f"--planner.single_agent.n_guide_steps={N_GUIDE_STEPS}", + f"--planner.single_agent.weight_grad_cost_constraints={guide_weight_constr}", + f"--planner.single_agent.weight_grad_cost_collision={env['guide_weight_coll']}", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Cost function ── + f"--planner.single_agent.cost_func_key={cost_func_key}", + f"--planner.single_agent.cost_func_param.margin_coeff={COST_MARGIN_COEFF}", + f"--planner.single_agent.cost_func_param.radius_coeff={COST_RADIUS_COEFF}", + # ── Velocity projection ── + f"--planner.projection.project_params.vel_max={vel_max}", + ] + _admm_projector_args(rho=env['admm_rho']) + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_direct(env_key, n_agents, vel_max, log_pth, dry_run=False): + """DiRecT: no guidance, JaxScpADMM Tweedie projection at every step.""" + env = ENVS[env_key] + config_path = join(CONFIG_DIR, env['direct_config']) + label = f"DiRecT | env={env_key} agents={n_agents} vmax={vel_max}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=DiRecT", + f"--planner.single_agent.planner_alg=base", + f"--planner.single_agent.projector_type=JaxScpADMM", + # ── No guidance ── + f"--planner.single_agent.start_guide_steps_fraction=0.0", + f"--planner.single_agent.n_guide_steps=0", + f"--planner.single_agent.weight_grad_cost_constraints=0", + f"--planner.single_agent.weight_grad_cost_collision=0", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Projection ── + f"--planner.single_agent.start_projection_guidance={PROJ_FRACTION_DIRECT}", + f"--planner.projection.project_params.vel_max={vel_max}", + f"--planner.projection.project_params.smoothness_weight={env['direct_smoothness_weight']}", + ] + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_pdm(env_key, n_agents, vel_max, log_pth, dry_run=False): + """PDM: no guidance, JaxScpADMM per-step projection of the noisy latent. + + Identical flags to run_direct except the planner class — the projection + is applied to x_t directly instead of the Tweedie clean estimate. + """ + env = ENVS[env_key] + config_path = join(CONFIG_DIR, env['direct_config']) + label = f"PDM | env={env_key} agents={n_agents} vmax={vel_max}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=PDM", + f"--planner.single_agent.planner_alg=base", + f"--planner.single_agent.projector_type=JaxScpADMM", + # ── No guidance ── + f"--planner.single_agent.start_guide_steps_fraction=0.0", + f"--planner.single_agent.n_guide_steps=0", + f"--planner.single_agent.weight_grad_cost_constraints=0", + f"--planner.single_agent.weight_grad_cost_collision=0", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Projection ── + f"--planner.single_agent.start_projection_guidance={PROJ_FRACTION_PDM}", + f"--planner.projection.project_params.vel_max={vel_max}", + f"--planner.projection.project_params.smoothness_weight={env['direct_smoothness_weight']}", + ] + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_final_projection(env_key, n_agents, vel_max, log_pth, dry_run=False): + """Final Projection: unguided diffusion + JaxScpADMM projection at last step only.""" + env = ENVS[env_key] + config_path = join(CONFIG_DIR, env['direct_config']) + label = f"FINAL_PROJ | env={env_key} agents={n_agents} vmax={vel_max}" + + args_list = _common_args(n_agents, log_pth) + [ + # ── Planner identity ── + f"--planner.single_agent.class=DiRecT", + f"--planner.single_agent.planner_alg=final", + f"--planner.single_agent.projector_type=JaxScpADMM", + # ── No guidance ── + f"--planner.single_agent.start_guide_steps_fraction=0.0", + f"--planner.single_agent.n_guide_steps=0", + f"--planner.single_agent.weight_grad_cost_constraints=0", + f"--planner.single_agent.weight_grad_cost_collision=0", + f"--planner.single_agent.weight_grad_cost_smoothness={GUIDE_WEIGHT_SMOOTH}", + # ── Projection (t=0 only, enforced by planner_alg=final) ── + f"--planner.single_agent.start_projection_guidance={PROJ_FRACTION_FINAL}", + f"--planner.projection.project_params.vel_max={vel_max}", + f"--planner.projection.project_params.smoothness_weight={env['direct_smoothness_weight']}", + ] + + _run_experiment(config_path, args_list, log_pth, label, dry_run) + + +def run_mmd_cbs(env_key, n_agents, log_pth, dry_run=False): + """MMD-CBS baseline: CBS multi-agent planner with MPD low-level, no velocity constraints.""" + from mmd.common.experiments.experiments import MultiAgentPlanningExperimentConfig + + instance_name = MMD_ENV_INSTANCE_MAP.get(env_key) + if instance_name is None: + logger.warning(f"MMD: no instance mapping for env '{env_key}', skipping.") + return + + label = f"MMD-CBS | env={env_key} agents={n_agents}" + logger.info(f"\n{'─'*70}") + logger.info(f" {label}") + logger.info(f" instance: {instance_name}") + logger.info(f"{'─'*70}") + + if dry_run: + logger.info(" [DRY RUN] skipping execution") + return + + launch_mmd_mod = import_mmd_modules() + + experiment_config = MultiAgentPlanningExperimentConfig() + experiment_config.seed = SEED + experiment_config.num_agents_l = [n_agents] + experiment_config.instance_name = instance_name + experiment_config.stagger_start_time_dt = 0 + experiment_config.multi_agent_planner_class_l = [MMD_MULTI_AGENT_PLANNER] + experiment_config.single_agent_planner_class = MMD_SINGLE_AGENT_PLANNER + experiment_config.runtime_limit = MMD_RUNTIME_LIMIT + experiment_config.num_trials_per_combination = N_INITS + experiment_config.render_animation = False + + launch_mmd_mod.run_multi_agent_experiment(experiment_config) + + +# ═══════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════ + +def main(): + global GUIDE_WEIGHT_SMOOTH + + parser = argparse.ArgumentParser( + description="Benchmark comparison across methods, envs, agents, vmaxs, costs.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__) + parser.add_argument("--gpu-id", type=int, default=0) + parser.add_argument("--envs", nargs="+", default=list(ENVS.keys()), + choices=list(ENVS.keys()), + help="Environments to run (default: all)") + parser.add_argument("--agents", nargs="+", type=int, default=N_AGENTS_LIST, + help=f"Agent counts (default: {N_AGENTS_LIST})") + parser.add_argument("--vmaxs", nargs="+", type=float, default=None, + help="Velocity limits (default: per-environment from ENVS)") + parser.add_argument("--guide-weight-smooth", type=float, default=GUIDE_WEIGHT_SMOOTH, + help=f"weight_grad_cost_smoothness (default: {GUIDE_WEIGHT_SMOOTH})") + parser.add_argument("--costs", nargs="+", default=COST_FUNC_KEYS, + choices=COST_FUNC_KEYS, + help=f"Cost functions (default: {COST_FUNC_KEYS})") + parser.add_argument("--only", nargs="+", default=ALL_METHODS, + choices=ALL_METHODS, + help=f"Methods to run (default: all)") + parser.add_argument("--dry-run", action="store_true", + help="Print what would run without executing") + parser.add_argument("-m", "--comments", type=str, default="benchmark", + help="Comment tag for log file") + args = parser.parse_args() + + os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu_id) + os.makedirs(LOG_DIR, exist_ok=True) + + timestamp = datetime.datetime.now().strftime("%y%m%d_%H%M%S") + log_pth = os.path.join(LOG_DIR, + f"benchmark-{timestamp}-{args.comments}.log") + logger.info(f"Logging to {log_pth}") + + # Update module-level defaults from CLI flags + GUIDE_WEIGHT_SMOOTH = args.guide_weight_smooth + + methods = set(args.only) + envs = args.envs + agents_list = args.agents + costs = args.costs + + # Methods that don't sweep over vel_max / cost + NO_SWEEP_METHODS = {'diffusion_policy', 'mmd_cbs'} + # Methods that sweep over cost functions (LB / SHD) + COST_SWEEP_METHODS = {'pcd', 'cd'} + + # Count total runs for progress tracking + n_runs = 0 + for env in envs: + vmaxs = args.vmaxs if args.vmaxs is not None else ENVS[env]['vel_maxs'] + for n_ag in agents_list: + for m in ALL_METHODS: + if m not in methods: + continue + if m in NO_SWEEP_METHODS: + n_runs += 1 + elif m in COST_SWEEP_METHODS: + if m == 'cd': + n_runs += len(costs) # CD doesn't sweep vel_max + else: + n_runs += len(vmaxs) * len(costs) + else: + n_runs += len(vmaxs) + + logger.info(f"Total experiment runs: {n_runs}") + logger.info(f" Environments: {envs}") + logger.info(f" Agent counts: {agents_list}") + logger.info(f" Vel maxs: {{'per-env' if args.vmaxs is None else args.vmaxs}}") + logger.info(f" Cost funcs: {costs}") + logger.info(f" Cost γ: {COST_FUNC_DEFAULTS}") + logger.info(f" Methods: {[m for m in ALL_METHODS if m in methods]}") + + run_i = 0 + for env in envs: + vmaxs = args.vmaxs if args.vmaxs is not None else ENVS[env]['vel_maxs'] + logger.info(f"\n env={env} vel_maxs={vmaxs}") + + for n_agents in agents_list: + for method in ALL_METHODS: + if method not in methods: + continue + + if method == 'diffusion_policy': + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_diffusion_policy(env, n_agents, log_pth, dry_run=args.dry_run) + + elif method == 'cd': + for cost_func_key in costs: + gwc = COST_FUNC_DEFAULTS[cost_func_key]['guide_weight_constr'] + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_cd(env, n_agents, cost_func_key, + gwc, log_pth, dry_run=args.dry_run) + + elif method == 'pcd': + for vel_max in vmaxs: + for cost_func_key in costs: + gwc = COST_FUNC_DEFAULTS[cost_func_key]['guide_weight_constr'] + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_pcd(env, n_agents, vel_max, cost_func_key, + gwc, log_pth, dry_run=args.dry_run) + + elif method == 'direct': + for vel_max in vmaxs: + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_direct(env, n_agents, vel_max, log_pth, + dry_run=args.dry_run) + + elif method == 'pdm': + for vel_max in vmaxs: + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_pdm(env, n_agents, vel_max, log_pth, + dry_run=args.dry_run) + + elif method == 'final_projection': + for vel_max in vmaxs: + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_final_projection(env, n_agents, vel_max, log_pth, + dry_run=args.dry_run) + + elif method == 'mmd_cbs': + run_i += 1 + logger.info(f"\n[{run_i}/{n_runs}]") + run_mmd_cbs(env, n_agents, log_pth, dry_run=args.dry_run) + + logger.info(f"\n{'═'*70}") + logger.info(f"Benchmark complete. {run_i} runs. Log: {log_pth}") + logger.info(f"{'═'*70}") + + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_cd_pdm_cell.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_cd_pdm_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..480df7cca0a5564e70f02b8fc53658383486032d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_cd_pdm_cell.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python +""" +Single-cell runner for the two small baselines in run_benchmark.py: + + cd = Coupled Diffusion (PCD class, planner_alg='coupling', guidance only, + NO velocity projection -> vmax is a no-op, so the + three velocity regimes collapse into ONE cell) + pdm = Projected Diffusion Model (PDM class, projection of the NOISY latent + x_t at every step, JaxScpADMM by default) + +It wraps run_benchmark.run_cd / run_benchmark.run_pdm so the argument +construction is byte-identical to the official sweep, and only adds: + * n_inits / n_samples as CLI flags (module constants upstream), + * OUR retrained single-agent models + OUR inflated geometry, + * per-init wall-clock timing, + * an offline scorer (SR / SOLVED@0.05 / SOLVED@0.10 / CS / Coll / DA / Time). + +Timing split: + setup - dataset + model load + planner construction + init[0] - first planning call (INCLUDES JAX/CUDA-graph compile) + init[1:] - steady-state per-instance cost (the number you scale by) +""" +import os, sys, time, json, glob, argparse +from os.path import join, dirname + +MAPS = { # map -> (env class, OBS_SCALE, ROBOT_R, run_benchmark config family) + 'empty': ('EnvEmptyNoWait2D', 1.00, 0.10, 'empty'), + 'basic': ('EnvEmptyNoWait2D', 2.20, 0.05, 'empty'), + 'dense': ('EnvEmptyNoWait2D', 1.00, 0.05, 'empty'), + 'corridor': ('EnvConveyor2D', 1.50, 0.05, 'conveyor'), + 'room': ('EnvHighways2D', 1.45, 0.05, 'highway'), + 'shelf': ('EnvDropRegion2D', 1.60, 0.05, 'dropregion'), + 'corridorbay': ('EnvCorridorSMD2D', 1.15, 0.05, 'conveyor'), + 'crossroom': ('EnvCrossRoom2D', 1.10, 0.05, 'dropregion'), +} + +# --- Robotarium port ----------------------------------------------------------------- +# Hardware fixes the GRITSBot radius (0.055 m) and the 3.2 x 2.0 m arena, so obstacle +# scale was the only free knob; these are the measured-feasible values (100% of sampled +# ring rotations admitted at every N <= 18, except corridorbay at 52-82%). 'room' is +# absent deliberately -- its walls cross the 0.80 m start ring at every scale. +# This REWRITES the table rather than setting ALLOW_SCALE_OVERRIDE, so the guards below +# stay live and still catch genuine training/scoring geometry drift. +if os.environ.get('RBT_PORT') == '1': + _RBT_SCALE = {'empty': 1.00, 'corridor': 1.10, 'shelf': 0.40, + 'corridorbay': 0.20, 'crossroom': 0.85} + for _m, _s in _RBT_SCALE.items(): + if _m in MAPS: + _t = list(MAPS[_m]); _t[1] = _s; _t[2] = 0.13; MAPS[_m] = tuple(_t) # study radius r=0.13 + MAPS = {_m: _t for _m, _t in MAPS.items() if _m in _RBT_SCALE} +# -------------------------------------------------------------------------------------- + +ap = argparse.ArgumentParser() +ap.add_argument("--method", required=True, choices=["cd", "pdm"]) +ap.add_argument("--map", required=True, choices=list(MAPS)) +ap.add_argument("--agents", type=int, required=True) +ap.add_argument("--vmax", type=float, required=True) # pdm only; recorded for cd +ap.add_argument("--n-inits", type=int, required=True) +ap.add_argument("--n-samples", type=int, required=True) # PER AGENT +ap.add_argument("--obs-scale", required=True) # explicit, cross-checked +ap.add_argument("--robot-r", required=True) # explicit, cross-checked +ap.add_argument("--cost", default="sum_log_l2", + choices=["sum_log_l2", "hinge_sqr_l2"]) # cd guider axis +ap.add_argument("--projector", default="JaxScpADMM", + choices=["JaxScpADMM", "ADMMVelOnly", "Ipopt"]) # pdm projector axis +ap.add_argument("--gpu-id", type=int, default=5) +ap.add_argument("--seed", type=int, default=18) +ap.add_argument("--tag", default=None) +ap.add_argument("--timing-json", default=None) +ap.add_argument("--sg-margin", type=float, default=None) +args = ap.parse_args() + +ENV_CLS, WANT_SCALE, WANT_R, ENV_KEY = MAPS[args.map] +if abs(float(args.obs_scale) - WANT_SCALE) > 1e-9: + sys.exit(f"FATAL: --obs-scale={args.obs_scale} but map '{args.map}' needs {WANT_SCALE}") +if abs(float(args.robot_r) - WANT_R) > 1e-9: + sys.exit(f"FATAL: --robot-r={args.robot_r} but map '{args.map}' needs {WANT_R}") + +# ---- env vars MUST be set before torch/jax/torch_robotics import ---- +# Only when the caller has NOT already chosen a device. This line used to be +# unconditional, which silently undid the dispatcher's GPU assignment: grid.py exports +# CUDA_VISIBLE_DEVICES= and passes --gpu-id 0 meaning "the first visible +# device", and re-setting the variable to "0" re-interpreted that as PHYSICAL GPU 0. All +# twelve workers, plus another user's job, ended up on one card -- 13 cells died of OOM +# while six GPUs sat at 3 MiB. +if "CUDA_VISIBLE_DEVICES" not in os.environ: + os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu_id) +os.environ["OBS_SCALE"] = str(args.obs_scale) +os.environ["ROBOT_R"] = str(args.robot_r) +os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") +os.environ.setdefault("XLA_PYTHON_CLIENT_MEM_FRACTION", ".15") + +BASE = dirname(os.path.abspath(__file__)) +PROJ = dirname(dirname(BASE)) +# DiRecT ships its own torch_robotics; the installed one resolves to mdoc's fork, +# which lacks DEFAULT_TENSOR_ARGS and several env classes. Force DiRecT's copy. +sys.path.insert(0, join(PROJ, "deps", "torch_robotics")) +sys.path.insert(0, PROJ) +import importlib.util +spec = importlib.util.spec_from_file_location("run_benchmark", join(BASE, "run_benchmark.py")) +rb = importlib.util.module_from_spec(spec); sys.modules["run_benchmark"] = rb +spec.loader.exec_module(rb) + +# ---- shrink the two module constants that have no CLI flag upstream ---- +rb.N_INITS = args.n_inits +rb.BATCH_SIZE = args.n_samples +rb.SEED = args.seed + +MODEL_ID = f"{ENV_CLS}-RobotPlanarDisk" +TAG = args.tag or f"{args.method}-{args.map}-a{args.agents}-v{args.vmax}" +TIMING = {"per_init": [], "argv": vars(args), "map": args.map, + "env_class": ENV_CLS, "model_id": MODEL_ID} + +_orig_run_experiment = rb._run_experiment +def _patched(config_path, args_list, log_pth, label, dry_run=False): + sg_margin = args.sg_margin if args.sg_margin is not None else max(0.2, 2.2 * float(args.robot_r)) + extra = [ + f"--environment.global_model_ids=[[{MODEL_ID}]]", + f"--environment.start_goal_config.env_class_for_random={ENV_CLS}", + f"--environment.start_goal_config.margin={sg_margin}", + f"--experiment.render_animation=False", + f"--experiment.name={TAG}", + ] + if args.method == "pdm": + extra.append(f"--planner.single_agent.projector_type={args.projector}") + print(f"[VERIFY] start/goal margin={sg_margin} " + f"(validator needs > 2.1*{args.robot_r}={2.1*float(args.robot_r):.4f})") + args_list = list(args_list) + extra + from mmd.common.argparse import load_config, parse_overrides + cfg = load_config(config_path, parse_overrides(args_list)) + + # ---- VERIFY the geometry knobs actually took effect ---- + import torch + from torch_robotics.environments import __dict__ as envd + from mmd.config import MMDParams as _P + ecls = envd[ENV_CLS] + e = ecls(precompute_sdf_obj_fixed=False, + tensor_args={'device': 'cpu', 'dtype': torch.float32}) + sizes = [] + for of in (e.obj_fixed_list or []): + for p in of.fields: + if hasattr(p, 'sizes'): sizes.append(('box', p.sizes.flatten()[:4].tolist())) + if hasattr(p, 'radii'): sizes.append(('sphere', p.radii.flatten()[:4].tolist())) + print(f"[VERIFY] OBS_SCALE env={os.environ['OBS_SCALE']} -> first obstacle extents {sizes[:3]}") + print(f"[VERIFY] ROBOT_R env={os.environ['ROBOT_R']} -> " + f"MMDParams.robot_planar_disk_radius={_P.robot_planar_disk_radius}") + print(f"[VERIFY] planner class={cfg['planner']['single_agent']['class']} " + f"alg={cfg['planner']['single_agent']['planner_alg']} " + f"cost={cfg['planner']['single_agent'].get('cost_func_key')} " + f"projector={cfg['planner']['single_agent'].get('projector_type')} " + f"vel_max={cfg['planner'].get('projection', {}).get('project_params', {}).get('vel_max')}") + print(f"[VERIFY] n_inits={cfg['experiment']['n_inits']} " + f"n_samples/agent={cfg['planner']['single_agent']['n_samples']} " + f"num_agents={cfg['experiment']['num_agents']}") + TIMING["obs_extents"] = sizes[:3] + TIMING["robot_radius"] = _P.robot_planar_disk_radius + TIMING["planner_alg"] = cfg['planner']['single_agent']['planner_alg'] + TIMING["planner_class"] = cfg['planner']['single_agent']['class'] + TIMING["cost_func_key"] = cfg['planner']['single_agent'].get('cost_func_key') + + # ---- per-init timing ---- + from mmd.planners.multi_agent import DummyPlanning + _plan = DummyPlanning.plan + def timed_plan(self, *a, **k): + t0 = time.time(); out = _plan(self, *a, **k) + TIMING["per_init"].append(time.time() - t0); return out + DummyPlanning.plan = timed_plan + + mod = rb.import_inference_module() + TIMING["_t_start"] = time.time() + mod.run_planning_experiment(cfg, comments=label) + +rb._run_experiment = _patched + +t0 = time.time() +if args.method == "cd": + gwc = rb.COST_FUNC_DEFAULTS[args.cost]['guide_weight_constr'] + rb.run_cd(ENV_KEY, args.agents, args.cost, gwc, + join(rb.LOG_DIR, f"cd-{TAG}.log"), dry_run=False) +else: + rb.run_pdm(ENV_KEY, args.agents, args.vmax, + join(rb.LOG_DIR, f"pdm-{TAG}.log"), dry_run=False) +TIMING["total_wall_s"] = time.time() - t0 + +pi = TIMING["per_init"] +TIMING["setup_s"] = TIMING["_t_start"] - t0 if "_t_start" in TIMING else None +TIMING["init0_s"] = pi[0] if pi else None +TIMING["steady_mean_s"] = (sum(pi[1:]) / len(pi[1:])) if len(pi) > 1 else None +TIMING["jit_overhead_s"] = (pi[0] - TIMING["steady_mean_s"]) if len(pi) > 1 else None +TIMING.pop("_t_start", None) + +# ============================ SCORING ============================ +import numpy as np +alg = TIMING.get("planner_alg") +cost = TIMING.get("cost_func_key") if alg in ('coupling', 'coupling_ps', 'pcd') else None +pat = join(PROJ, "results", TAG, f"agent___{args.agents}", + f"method___{alg}", f"cost___{cost}", "*", "*") +cands = [d for d in glob.glob(pat) if os.path.exists(join(d, "raw_data.npz"))] +TIMING["results_glob"] = pat +if not cands: + TIMING["score_error"] = f"no results dir matched {pat}" +else: + rd = max(cands, key=os.path.getmtime) + TIMING["results_dir"] = rd + raw = np.load(join(rd, "raw_data.npz")) + pro = np.load(join(rd, "processed_data.npz")) + trj = raw["all_state_trjs"] # [B, I, N, H, D] + goal = raw["goals_global"] # [I, N, D] + cm = pro["collision_masks"] # [B, I, N, N, H] + om = pro["obstacle_collision_masks"]# [B, I, N, H] + da = pro["data_adherence"] # [B, I, N] + B, I, N, H, D = trj.shape + pair = cm.any(-1) # [B,I,N,N] + n_pairs = pair.sum(axis=(-2, -1)) // 2 # [B,I] colliding agent pairs + obs_hit = om.astype(bool).any(-1).sum(-1) # [B,I] agents hitting obstacles + feasible = (n_pairs == 0) & (obs_hit == 0) # [B,I] + goal_b = np.broadcast_to(goal.reshape(1, I, N, -1)[..., :2], (B, I, N, 2)) + gerr = np.linalg.norm(trj[:, :, :, -1, :2] - goal_b, axis=-1).max(-1) # [B,I] worst agent + res = { + "n_inits": int(I), "n_samples_per_agent": int(B), "n_agents": int(N), "horizon": int(H), + "CS": float(feasible.mean()), + "SR": float(feasible.any(axis=0).mean()), + "SOLVED@0.05": float((feasible & (gerr <= 0.05)).any(axis=0).mean()), + "SOLVED@0.10": float((feasible & (gerr <= 0.10)).any(axis=0).mean()), + "Coll_mean": float(n_pairs.mean()), + "Coll_std": float(n_pairs.std()), + "ObsHit_mean": float(obs_hit.mean()), + "DA_mean": float(da.mean()), + "DA_std": float(da.std()), + "goal_err_mean": float(gerr.mean()), + "goal_err_min": float(gerr.min(axis=0).mean()), + "Time_mean_s": float(np.mean(pi)) if pi else None, + "Time_std_s": float(np.std(pi)) if pi else None, + } + TIMING["metrics"] = res + +print("\n" + "=" * 70) +print(json.dumps({k: v for k, v in TIMING.items() if k != "argv"}, indent=2, default=str)) +print("=" * 70) +if args.timing_json: + with open(args.timing_json, "w") as f: json.dump(TIMING, f, indent=2, default=str) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_direct_cell.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_direct_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..1f02a144628cdd73bd3bb05efd77b24c1066bf12 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_direct_cell.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +""" +Minimal single-cell DiRecT runner for the ICLR preliminary table. + +Wraps scripts/inference/run_benchmark.py's own run_direct() so the argument +construction is byte-identical to the official sweep, but exposes the knobs +that are module constants there (n_inits, n_samples) as CLI flags and adds +the overrides needed to point at OUR retrained single-agent models and OUR +inflated geometry. + +Timing is split into: + setup - dataset load + model load + planner construction + init[0] - first planning call (INCLUDES the JAX SCP-ADMM JIT compile) + init[1:] - steady-state per-instance cost (what you scale by) +""" +import os, sys, time, json, argparse +from os.path import join, dirname + +ap = argparse.ArgumentParser() +ap.add_argument("--env", required=True, + choices=["empty", "highway", "conveyor", "dropregion", + "corridorsmd", "crossroom"]) +ap.add_argument("--model-id", required=True) # e.g. EnvEmptyNoWait2D-RobotPlanarDisk +ap.add_argument("--env-class", required=True) # e.g. EnvEmptyNoWait2D +ap.add_argument("--agents", type=int, required=True) +ap.add_argument("--vmax", type=float, required=True) +ap.add_argument("--n-inits", type=int, required=True) +ap.add_argument("--n-samples", type=int, required=True) # PER AGENT +ap.add_argument("--obs-scale", required=True) # explicit, no default +ap.add_argument("--robot-r", required=True) # explicit, no default +ap.add_argument("--gpu-id", type=int, default=7) +ap.add_argument("--seed", type=int, default=18) +ap.add_argument("--tag", default="cell") +ap.add_argument("--timing-json", default=None) +# start/goal rejection-sampling separation. The validator +# (robot_planar_disk.check_rr_collisions) rejects pairs closer than 2.1*radius, +# but the sampler only guarantees > margin. With the yaml default margin=0.2 and +# ROBOT_R=0.1 the validator needs 0.21 -> "Start states are in collision" at larger +# team sizes. Default here to 2.2*robot_r so the sampler always clears the validator. +ap.add_argument("--sg-margin", type=float, default=None) +# results dir is named from the CONFIG key, so corridor/corridorbay both land in +# results/single_tile_conveyor and shelf/crossroom both in results/single_tile_dropregion. +# --exp-name overrides experiment.name so each map gets its own tree. +ap.add_argument("--exp-name", default=None) +# DiRecT is unguided, so the "guider" axis does not exist for it; its config axes are the +# projector and the diffusion fraction at which projection switches on. run_benchmark.run_direct +# hardcodes JaxScpADMM / 0.5 -- these expose them for the sweep. Defaults reproduce it exactly. +ap.add_argument("--projector-type", default=None, choices=[None,"JaxScpADMM","Ipopt","ADMMVelOnly"]) +ap.add_argument("--proj-start", type=float, default=None) +args = ap.parse_args() + +# ---- env vars MUST be set before torch/jax/torch_robotics import ---- +# Only when the caller has NOT already chosen a device. This line used to be +# unconditional, which silently undid the dispatcher's GPU assignment: grid.py exports +# CUDA_VISIBLE_DEVICES= and passes --gpu-id 0 meaning "the first visible +# device", and re-setting the variable to "0" re-interpreted that as PHYSICAL GPU 0. All +# twelve workers, plus another user's job, ended up on one card -- 13 cells died of OOM +# while six GPUs sat at 3 MiB. +if "CUDA_VISIBLE_DEVICES" not in os.environ: + os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu_id) +os.environ["OBS_SCALE"] = str(args.obs_scale) +os.environ["ROBOT_R"] = str(args.robot_r) +os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") +os.environ.setdefault("XLA_PYTHON_CLIENT_MEM_FRACTION", ".15") + +BASE = dirname(os.path.abspath(__file__)) +PROJ = dirname(dirname(BASE)) +# DiRecT ships its own torch_robotics; the installed one resolves to mdoc's fork, +# which lacks DEFAULT_TENSOR_ARGS and several env classes. Force DiRecT's copy. +sys.path.insert(0, join(PROJ, "deps", "torch_robotics")) +sys.path.insert(0, PROJ) +import importlib.util +spec = importlib.util.spec_from_file_location("run_benchmark", join(BASE, "run_benchmark.py")) +rb = importlib.util.module_from_spec(spec); sys.modules["run_benchmark"] = rb +spec.loader.exec_module(rb) + +# ---- shrink the two module constants that have no CLI flag upstream ---- +rb.N_INITS = args.n_inits +rb.BATCH_SIZE = args.n_samples +rb.SEED = args.seed + +ENV_KEY = {"corridorsmd": "conveyor", "crossroom": "dropregion"}.get(args.env, args.env) + +TIMING = {"per_init": [], "argv": vars(args)} + +_orig_run_experiment = rb._run_experiment +def _patched(config_path, args_list, log_pth, label, dry_run=False): + sg_margin = args.sg_margin if args.sg_margin is not None else max(0.2, 2.2 * float(args.robot_r)) + extra = [ + f"--environment.global_model_ids=[[{args.model_id}]]", + f"--environment.start_goal_config.env_class_for_random={args.env_class}", + f"--environment.start_goal_config.margin={sg_margin}", + f"--experiment.render_animation=False", + ] + ([f"--experiment.name={args.exp_name}"] if args.exp_name else []) + if args.projector_type: + args_list = [x for x in args_list if not x.startswith("--planner.single_agent.projector_type")] + extra = extra + [f"--planner.single_agent.projector_type={args.projector_type}"] + if args.proj_start is not None: + args_list = [x for x in args_list if not x.startswith("--planner.single_agent.start_projection_guidance")] + extra = extra + [f"--planner.single_agent.start_projection_guidance={args.proj_start}"] + print(f"[VERIFY] start/goal margin={sg_margin} (validator needs > 2.1*{args.robot_r}={2.1*float(args.robot_r):.4f})") + args_list = list(args_list) + extra + from mmd.common.argparse import load_config, parse_overrides + cfg = load_config(config_path, parse_overrides(args_list)) + + # ---- VERIFY the geometry knobs actually took effect ---- + import torch + from torch_robotics.environments import __dict__ as envd + from mmd.config import MMDParams as _P + ecls = envd[args.env_class] + e = ecls(precompute_sdf_obj_fixed=False, + tensor_args={'device': 'cpu', 'dtype': torch.float32}) + sizes = [] + for of in (e.obj_fixed_list or []): + for p in of.fields: + if hasattr(p, 'sizes'): sizes.append(('box', p.sizes.flatten()[:4].tolist())) + if hasattr(p, 'radii'): sizes.append(('sphere', p.radii.flatten()[:4].tolist())) + print(f"[VERIFY] OBS_SCALE env={os.environ['OBS_SCALE']} -> first obstacle extents {sizes[:3]}") + print(f"[VERIFY] ROBOT_R env={os.environ['ROBOT_R']} -> MMDParams.robot_planar_disk_radius={_P.robot_planar_disk_radius}") + TIMING["obs_extents"] = sizes[:3] + TIMING["robot_radius"] = _P.robot_planar_disk_radius + + # ---- per-init timing ---- + from mmd.planners.multi_agent import DummyPlanning + _plan = DummyPlanning.plan + def timed_plan(self, *a, **k): + t0 = time.time(); out = _plan(self, *a, **k) + TIMING["per_init"].append(time.time() - t0); return out + DummyPlanning.plan = timed_plan + + mod = rb.import_inference_module() + t_setup = time.time() + TIMING["_t_start"] = t_setup + mod.run_planning_experiment(cfg, comments=label) + +rb._run_experiment = _patched + +t0 = time.time() +rb.run_direct(ENV_KEY, args.agents, args.vmax, + join(rb.LOG_DIR, f"direct-{args.tag}.log"), dry_run=False) +TIMING["total_wall_s"] = time.time() - t0 + +pi = TIMING["per_init"] +TIMING["setup_s"] = TIMING["_t_start"] - t0 if "_t_start" in TIMING else None +TIMING["init0_s"] = pi[0] if pi else None +TIMING["steady_mean_s"] = (sum(pi[1:]) / len(pi[1:])) if len(pi) > 1 else None +TIMING["jit_overhead_s"] = (pi[0] - TIMING["steady_mean_s"]) if len(pi) > 1 else None +TIMING.pop("_t_start", None) + +print("\n" + "=" * 70) +print(json.dumps({k: v for k, v in TIMING.items() if k != "argv"}, indent=2, default=str)) +print("=" * 70) +if args.timing_json: + with open(args.timing_json, "w") as f: json.dump(TIMING, f, indent=2, default=str) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_finalproj_cell.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_finalproj_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..7157b1729a5cd152ee4f0016304e2b51ee105ed9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_finalproj_cell.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +""" +Minimal single-cell FINAL-PROJECTION runner for the ICLR preliminary table. + +Final Projection = unguided (vanilla) diffusion, then ONE projection onto the +feasible set at the last denoising step (planner_alg='final' forces +t_start_projection=1, i.e. apply only at t_single==0). It shares the +projector stack with DiRecT. + +Wraps run_benchmark.run_final_projection() so the argument construction is +byte-identical to the official sweep, but exposes n_inits / n_samples / +projector_type as CLI flags and adds the overrides for OUR retrained +single-agent models and OUR inflated geometry. + +Also computes the FULL metric set the table needs straight off the saved +npz: SR, SOLVED@0.05, SOLVED@0.10, CS, Coll, DA, Time (mean+std). +""" +import os, sys, time, json, glob, argparse +from os.path import join, dirname + +MAPS = { # map -> (env class, OBS_SCALE, ROBOT_R, benchmark ENVS key) + 'empty': ('EnvEmptyNoWait2D', 1.00, 0.10, 'empty'), + 'basic': ('EnvEmptyNoWait2D', 2.20, 0.05, 'empty'), + 'dense': ('EnvEmptyNoWait2D', 1.00, 0.05, 'empty'), + 'corridor': ('EnvConveyor2D', 1.50, 0.05, 'conveyor'), + 'room': ('EnvHighways2D', 1.45, 0.05, 'highway'), + 'shelf': ('EnvDropRegion2D', 1.60, 0.05, 'dropregion'), + 'corridorbay': ('EnvCorridorSMD2D', 1.15, 0.05, 'conveyor'), + 'crossroom': ('EnvCrossRoom2D', 1.10, 0.05, 'dropregion'), +} + +# --- Robotarium port ----------------------------------------------------------------- +# Hardware fixes the GRITSBot radius (0.055 m) and the 3.2 x 2.0 m arena, so obstacle +# scale was the only free knob; these are the measured-feasible values (100% of sampled +# ring rotations admitted at every N <= 18, except corridorbay at 52-82%). 'room' is +# absent deliberately -- its walls cross the 0.80 m start ring at every scale. +# This REWRITES the table rather than setting ALLOW_SCALE_OVERRIDE, so the guards below +# stay live and still catch genuine training/scoring geometry drift. +if os.environ.get('RBT_PORT') == '1': + _RBT_SCALE = {'empty': 1.00, 'corridor': 1.10, 'shelf': 0.40, + 'corridorbay': 0.20, 'crossroom': 0.85} + for _m, _s in _RBT_SCALE.items(): + if _m in MAPS: + _t = list(MAPS[_m]); _t[1] = _s; _t[2] = 0.13; MAPS[_m] = tuple(_t) # study radius r=0.13 + MAPS = {_m: _t for _m, _t in MAPS.items() if _m in _RBT_SCALE} +# -------------------------------------------------------------------------------------- + +ap = argparse.ArgumentParser() +ap.add_argument("--map", required=True, choices=list(MAPS)) +ap.add_argument("--agents", type=int, required=True) +ap.add_argument("--vmax", type=float, required=True) +ap.add_argument("--n-inits", type=int, required=True) # instances +ap.add_argument("--n-samples", type=int, required=True) # total; B = n_samples // agents +ap.add_argument("--projector-type", default="JaxScpADMM", + choices=["JaxScpADMM", "Ipopt", "ADMMVelOnly"]) +ap.add_argument("--models-root", default=None, + help="absolute dir of -RobotPlanarDisk/{args.yaml,checkpoints}; " + "default uses multi_robot/data_trained_models (symlinks -> shared_trained//0)") +ap.add_argument("--gpu-id", type=int, default=2) +ap.add_argument("--seed", type=int, default=18) +ap.add_argument("--tag", default="fp") +ap.add_argument("--timing-json", default=None) +ap.add_argument("--obs-scale", default=None, help="override; must match map unless ALLOW_SCALE_OVERRIDE=1") +ap.add_argument("--sg-margin", type=float, default=None) +args = ap.parse_args() + +ENV_CLASS, WANT_SCALE, WANT_R, ENV_KEY = MAPS[args.map] +if args.obs_scale is not None and abs(float(args.obs_scale) - WANT_SCALE) > 1e-9: + if os.environ.get("ALLOW_SCALE_OVERRIDE") == "1": + print(f"WARNING: DIAGNOSTIC -- OBS_SCALE {args.obs_scale} != map default {WANT_SCALE}") + WANT_SCALE = float(args.obs_scale) + else: + sys.exit(f"FATAL: --obs-scale {args.obs_scale} != map '{args.map}' scale {WANT_SCALE}") +MODEL_ID = f"{ENV_CLASS}-RobotPlanarDisk" + +# ---- env vars MUST be set before torch/jax/torch_robotics import ---- +# Only when the caller has NOT already chosen a device. This line used to be +# unconditional, which silently undid the dispatcher's GPU assignment: grid.py exports +# CUDA_VISIBLE_DEVICES= and passes --gpu-id 0 meaning "the first visible +# device", and re-setting the variable to "0" re-interpreted that as PHYSICAL GPU 0. All +# twelve workers, plus another user's job, ended up on one card -- 13 cells died of OOM +# while six GPUs sat at 3 MiB. +if "CUDA_VISIBLE_DEVICES" not in os.environ: + os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu_id) +os.environ["OBS_SCALE"] = repr(WANT_SCALE) +os.environ["ROBOT_R"] = repr(WANT_R) +os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") +os.environ.setdefault("XLA_PYTHON_CLIENT_MEM_FRACTION", ".15") + +BASE = dirname(os.path.abspath(__file__)) +PROJ = dirname(dirname(BASE)) +sys.path.insert(0, join(PROJ, "deps", "torch_robotics")) +sys.path.insert(0, PROJ) +import numpy as np +import importlib.util +spec = importlib.util.spec_from_file_location("run_benchmark", join(BASE, "run_benchmark.py")) +rb = importlib.util.module_from_spec(spec); sys.modules["run_benchmark"] = rb +spec.loader.exec_module(rb) + +rb.N_INITS = args.n_inits +rb.BATCH_SIZE = args.n_samples +rb.SEED = args.seed + +TIMING = {"per_init": [], "argv": vars(args), + "map": args.map, "env_class": ENV_CLASS, "obs_scale": WANT_SCALE, + "robot_r": WANT_R, "projector": args.projector_type} +RESULT_DIRS = [] + +_orig_run_experiment = rb._run_experiment +def _patched(config_path, args_list, log_pth, label, dry_run=False): + sg_margin = args.sg_margin if args.sg_margin is not None else max(0.2, 2.2 * WANT_R) + extra = [ + f"--environment.global_model_ids=[[{MODEL_ID}]]", + f"--environment.start_goal_config.env_class_for_random={ENV_CLASS}", + f"--environment.start_goal_config.margin={sg_margin}", + f"--experiment.render_animation=False", + ] + ([f"--system.trained_models_dir_name={args.models_root!r}"] if args.models_root else []) + [ + f"--planner.single_agent.projector_type={args.projector_type}", + ] + print(f"[VERIFY] start/goal margin={sg_margin} (validator needs > 2.1*{WANT_R}={2.1*WANT_R:.4f})") + args_list = list(args_list) + extra + from mmd.common.argparse import load_config, parse_overrides + cfg = load_config(config_path, parse_overrides(args_list)) + + # ---- VERIFY geometry + method identity actually took effect ---- + import torch + from torch_robotics.environments import __dict__ as envd + from mmd.config import MMDParams as _P + ecls = envd[ENV_CLASS] + e = ecls(precompute_sdf_obj_fixed=False, + tensor_args={'device': 'cpu', 'dtype': torch.float32}) + sizes = [] + for of in (e.obj_fixed_list or []): + for p in of.fields: + if hasattr(p, 'sizes'): sizes.append(('box', p.sizes.flatten()[:4].tolist())) + if hasattr(p, 'radii'): sizes.append(('sphere', p.radii.flatten()[:4].tolist())) + sa = cfg['planner']['single_agent'] + print(f"[VERIFY] OBS_SCALE env={os.environ['OBS_SCALE']} -> first obstacle extents {sizes[:3]}") + print(f"[VERIFY] ROBOT_R env={os.environ['ROBOT_R']} -> MMDParams.robot_planar_disk_radius={_P.robot_planar_disk_radius}") + print(f"[VERIFY] class={sa['class']} planner_alg={sa['planner_alg']} " + f"projector_type={sa['projector_type']} start_projection_guidance={sa['start_projection_guidance']} " + f"n_guide_steps={sa['n_guide_steps']} vel_max={cfg['planner']['projection']['project_params']['vel_max']}") + assert sa['planner_alg'] == 'final', "planner_alg is not 'final' -- this is NOT final_projection" + TIMING["obs_extents"] = sizes[:3] + TIMING["robot_radius"] = _P.robot_planar_disk_radius + TIMING["vel_max"] = cfg['planner']['projection']['project_params']['vel_max'] + TIMING["dt"] = None + + from mmd.planners.multi_agent import DummyPlanning + _plan = DummyPlanning.plan + def timed_plan(self, *a, **k): + t0 = time.time(); out = _plan(self, *a, **k) + TIMING["per_init"].append(time.time() - t0); return out + DummyPlanning.plan = timed_plan + + mod = rb.import_inference_module() + # capture the timestamped results dir + import mmd.common.experiments.experiments as _exp + _grd = _exp.get_result_dir_pcdiff + def _grd_cap(*a, **k): + d = _grd(*a, **k); RESULT_DIRS.append(d); return d + mod.get_result_dir_pcdiff = _grd_cap + + TIMING["_t_start"] = time.time() + mod.run_planning_experiment(cfg, comments=label) + +rb._run_experiment = _patched + +t0 = time.time() +rb.run_final_projection(ENV_KEY, args.agents, args.vmax, + join(rb.LOG_DIR, f"finalproj-{args.tag}.log"), dry_run=False) +TIMING["total_wall_s"] = time.time() - t0 + +pi = TIMING["per_init"] +TIMING["setup_s"] = TIMING.get("_t_start", t0) - t0 +TIMING["init0_s"] = pi[0] if pi else None +TIMING["steady_mean_s"] = (sum(pi[1:]) / len(pi[1:])) if len(pi) > 1 else None +TIMING["jit_overhead_s"] = (pi[0] - TIMING["steady_mean_s"]) if len(pi) > 1 else None +TIMING.pop("_t_start", None) + +# ══════════ METRICS off the saved npz ══════════ +def compute_metrics(rdir): + raw = np.load(join(rdir, "raw_data.npz")) + proc = np.load(join(rdir, "processed_data.npz")) + trj = raw["all_state_trjs"] # [B, I, N, H, D] + goals = raw["goals_global"] # [B, I, N, D] + cm = proc["collision_masks"] # [B, I, N, N, H] + ocm = proc["obstacle_collision_masks"] # [B, I, N, H] + da = proc["data_adherence"] # [B, I, N] + B, I, N = trj.shape[:3] + inter = cm.any(-1).any(-1) # [B,I,N] agent hits someone + obst = ocm.astype(bool).any(-1) # [B,I,N] agent hits an obstacle + coll_free_sample = ~(inter | obst).any(-1) # [B,I] whole team collision-free + # goal error: last waypoint position vs goal position + ge = np.linalg.norm(trj[..., -1, :2] - goals[..., :2], axis=-1) # [B,I,N] + ge_team = ge.max(-1) # [B,I] worst agent + # per-instance: best-of-B candidate selection (the planner returns a batch) + def frac(mask_bi): # instance solved if ANY candidate works + return float(mask_bi.any(0).mean()) + out = { + "B_candidates": int(B), "I_instances": int(I), "N_agents": int(N), + "SR": frac(coll_free_sample), + "SOLVED@0.05": frac(coll_free_sample & (ge_team <= 0.05)), + "SOLVED@0.10": frac(coll_free_sample & (ge_team <= 0.10)), + "CS": float(coll_free_sample.mean()), # all samples feasible + "Coll_mean": float((inter | obst).sum(-1).mean()), # mean #agents in collision + "Coll_std": float((inter | obst).sum(-1).std()), + "DA_mean": float(np.mean(da)), "DA_std": float(np.std(da)), + "goal_err_mean": float(ge.mean()), "goal_err_max_agent_mean": float(ge_team.mean()), + } + return out + +if RESULT_DIRS: + rdir = RESULT_DIRS[-1] + TIMING["results_dir"] = rdir + try: + TIMING["metrics"] = compute_metrics(rdir) + except Exception as ex: + TIMING["metrics_error"] = f"{type(ex).__name__}: {ex}" + +if pi: + TIMING["Time_s_mean"] = float(np.mean(pi)) + TIMING["Time_s_std"] = float(np.std(pi)) + +print("\n" + "=" * 70) +print(json.dumps({k: v for k, v in TIMING.items() if k != "argv"}, indent=2, default=str)) +print("=" * 70) +if args.timing_json: + with open(args.timing_json, "w") as f: json.dump(TIMING, f, indent=2, default=str) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_full_sweep.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_full_sweep.sh new file mode 100644 index 0000000000000000000000000000000000000000..f7e9d846cba0a3b4766170b9281ad948cfc788af --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_full_sweep.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Full parameter sweep: all methods x all environments x all agent counts. +# +# Usage: +# bash run_full_sweep.sh GPU_ID [--dry-run] +# +# Examples: +# bash run_full_sweep.sh 0 +# bash run_full_sweep.sh 0 --dry-run + +set -e + +GPU=${1:?Usage: run_full_sweep.sh GPU_ID [--dry-run]} +shift + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +python run_benchmark.py \ + --gpu-id "$GPU" \ + --envs empty highway conveyor dropregion \ + --agents 4 8 12 16 20 \ + --costs hinge_sqr_l2 sum_log_l2 \ + "$@" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_mmd_cbs_cell.py b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_mmd_cbs_cell.py new file mode 100644 index 0000000000000000000000000000000000000000..7fc31cb2dedeed35be297df58a0b5dd20528c2cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_mmd_cbs_cell.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python +""" +MMD-CBS single-cell runner on the ICLR hard geometry. + +One "cell" = one map x one team size. MMD-CBS applies no velocity projection, +so the 3 velocity regimes collapse into a single cell for this method. + +Everything is env-var driven so the command is self-documenting. +OBS_SCALE is REQUIRED and cross-checked against the map (no silent 1.0). +""" +import os, sys, json, time, argparse + +if 'OBS_SCALE' not in os.environ: + sys.exit("FATAL: OBS_SCALE not set (it silently defaults to 1.0 = shipped geometry).") + +MAPS = { # map -> (env class, OBS_SCALE, ROBOT_R) + 'empty': ('EnvEmptyNoWait2D', 1.00, 0.13), + 'basic': ('EnvEmptyNoWait2D', 2.20, 0.05), + 'dense': ('EnvEmptyNoWait2D', 1.00, 0.05), + 'corridor': ('EnvConveyor2D', 1.10, 0.13), + 'room': ('EnvHighways2D', 1.45, 0.05), + 'shelf': ('EnvDropRegion2D', 0.40, 0.13), + 'corridorbay': ('EnvCorridorSMD2D', 0.20, 0.13), + 'crossroom': ('EnvCrossRoom2D', 0.85, 0.13), +} + +# --- Robotarium port ----------------------------------------------------------------- +# Hardware fixes the GRITSBot radius (0.055 m) and the 3.2 x 2.0 m arena, so obstacle +# scale was the only free knob; these are the measured-feasible values (100% of sampled +# ring rotations admitted at every N <= 18, except corridorbay at 52-82%). 'room' is +# absent deliberately -- its walls cross the 0.80 m start ring at every scale. +# This REWRITES the table rather than setting ALLOW_SCALE_OVERRIDE, so the guards below +# stay live and still catch genuine training/scoring geometry drift. +if os.environ.get('RBT_PORT') == '1': + _RBT_SCALE = {'empty': 1.00, 'corridor': 1.10, 'shelf': 0.40, + 'corridorbay': 0.20, 'crossroom': 0.85} + for _m, _s in _RBT_SCALE.items(): + if _m in MAPS: + _t = list(MAPS[_m]); _t[1] = _s; _t[2] = 0.090; MAPS[_m] = tuple(_t) # 0.055 phys + nonholonomic conversion margin + MAPS = {_m: _t for _m, _t in MAPS.items() if _m in _RBT_SCALE} +# -------------------------------------------------------------------------------------- + +ap = argparse.ArgumentParser() +ap.add_argument('--map', default=os.environ.get('MAP', 'corridor'), choices=list(MAPS)) +ap.add_argument('--n-agents', type=str, default=os.environ.get('N_AGENTS', '3')) +ap.add_argument('--n-instances', type=int, default=int(os.environ.get('N_INSTANCES', 1))) +ap.add_argument('--n-samples', type=int, default=int(os.environ.get('N_SAMPLES', 4))) +ap.add_argument('--runtime-limit', type=float, default=float(os.environ.get('RUNTIME_LIMIT', 60))) +ap.add_argument('--models-root', default=os.environ.get('MODELS_ROOT')) +ap.add_argument('--data-root', default=os.environ.get('DATA_ROOT', + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'data_trajectories'))) +ap.add_argument('--seed', type=int, default=int(os.environ.get('SEED', 18))) +ap.add_argument('--out', default=os.environ.get('OUT', '/tmp/mmd_cbs_cell.json')) +ap.add_argument('--no-cache-dataset', action='store_true') +args = ap.parse_args() + +ENV_CLS, WANT_SCALE, WANT_R = MAPS[args.map] +if abs(float(os.environ['OBS_SCALE']) - WANT_SCALE) > 1e-9: + if os.environ.get('ALLOW_SCALE_OVERRIDE') == '1': + print(f"WARNING: DIAGNOSTIC RUN -- OBS_SCALE={os.environ['OBS_SCALE']} != map default {WANT_SCALE}") + WANT_SCALE = float(os.environ['OBS_SCALE']) + else: + sys.exit(f"FATAL: OBS_SCALE={os.environ['OBS_SCALE']} but map '{args.map}' needs {WANT_SCALE}") +if abs(float(os.environ.get('ROBOT_R', 0.05)) - WANT_R) > 1e-9: + sys.exit(f"FATAL: ROBOT_R={os.environ.get('ROBOT_R')} but map '{args.map}' needs {WANT_R}") +if not args.models_root: + sys.exit("FATAL: MODELS_ROOT not set (dir containing -RobotPlanarDisk/{args.yaml,checkpoints})") + +MULTI_ROBOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # this fork (DiRecT/) +os.chdir(MULTI_ROBOT) +# The installed torch_robotics is mdoc's fork, which lacks DEFAULT_TENSOR_ARGS that the +# DiRecT 'mmd' package imports. MMD-CBS must run against DiRecT's own torch_robotics fork. +# Obstacle geometry is byte-identical between the forks; only data-adherence + imports differ. +sys.path.insert(0, MULTI_ROBOT) +# torch_robotics: use the ARENA/ISO/r0.13-GATED rbtB fork. base repro/DiRecT-main is UNGATED +# (limits stay [-1,1]) and would reject certified endpoints spanning the widened arena |x|<=1.6. +_GATED_TR = os.path.join(MULTI_ROBOT, 'deps', 'torch_robotics') +sys.path.insert(0, _GATED_TR) + +T0 = time.time() +def tick(m): print(f"[T+{time.time()-T0:7.1f}s] {m}", flush=True) + +tick("import torch") +import torch, numpy as np + +import mmd.datasets.trajectories as _traj +_traj.dataset_base_dir = args.data_root + +from mmd.config.mmd_params import MMDParams as params +params.n_samples = args.n_samples +params.runtime_limit = args.runtime_limit +params.seed = args.seed +params.debug = False +params.robot_planar_disk_radius = WANT_R +params.vertex_constraint_radius = WANT_R * 2.4 +params.results_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), 'mmd_logs') + +import mmd.planners.single_agent.mpd as _mpd +_DS_CACHE = {} +_orig_get_dataset = _mpd.get_dataset +def _cached_get_dataset(**kw): + key = (kw.get('dataset_subdir'), kw.get('include_velocity'), kw.get('use_extra_objects')) + if args.no_cache_dataset: + t = time.time(); out = _orig_get_dataset(**kw) + tick(f" get_dataset {key[0]} NOCACHE {time.time()-t:.1f}s"); return out + if key not in _DS_CACHE: + t = time.time(); _DS_CACHE[key] = _orig_get_dataset(**kw) + tick(f" get_dataset {key[0]} MISS {time.time()-t:.1f}s") + else: + tick(f" get_dataset {key[0]} HIT") + return _DS_CACHE[key] +_mpd.get_dataset = _cached_get_dataset + +from mmd.planners.single_agent import MPD +from mmd.planners.multi_agent import CBS +import mmd.planners.multi_agent.cbs as _cbsmod +# UPSTREAM BUG: when the root CT node fails partway (an agent's low-level planner returns +# zero collision-free candidates), CBS.plan still calls global_pad_paths with fewer paths +# than agents -> grp_sz = 0 -> ts empty -> IndexError. The shipped launcher swallows this in +# a try/except and DROPS the trial, which silently inflates SR. Guard it and let the trial +# be scored as a failure instead. +_orig_pad = _cbsmod.global_pad_paths +def _safe_pad(path_l, start_time_l): + if len(path_l) < len(start_time_l): + return path_l + return _orig_pad(path_l, start_time_l) +_cbsmod.global_pad_paths = _safe_pad +from mmd.common.conflicts import PointConflict +from mmd.common.constraints import MultiPointConstraint +from mmd.common.multi_agent_utils import get_start_goal_pos_batch_random_in_env +from mmd.common.experiments import TrialSuccessStatus +from torch_robotics.environments import * +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_utils import get_torch_device + +device = get_torch_device('cuda') +tensor_args = {'device': device, 'dtype': torch.float32} +MODEL_ID = f'{ENV_CLS}-RobotPlanarDisk' +env_cls = eval(ENV_CLS) + +# ---- sanity: confirm the inflated geometry actually took ---------------------- +_e = env_cls(tensor_args=tensor_args) +_g = torch.linspace(-1, 1, 201, **tensor_args) +_X, _Y = torch.meshgrid(_g, _g, indexing='ij') +_pts = torch.stack([_X.reshape(-1), _Y.reshape(-1)], dim=-1) +_sdf = _e.grid_map_sdf_obj_fixed(_pts) +OCC = float((_sdf <= 0).float().mean()) +FREE_R = float((_sdf > WANT_R).float().mean()) +tick(f"GEOMETRY CHECK env={ENV_CLS} OBS_SCALE={os.environ['OBS_SCALE']} ROBOT_R={os.environ.get('ROBOT_R','0.05')} " + f"-> occupied_fraction={OCC:.4f} free_for_disk={FREE_R:.4f}") +del _e, _pts, _sdf +torch.cuda.empty_cache() + +LOW_ARGS = dict( + planner_alg='mmd', + use_guide_on_extra_objects_only=params.use_guide_on_extra_objects_only, + n_samples=params.n_samples, + n_local_inference_noising_steps=params.n_local_inference_noising_steps, + n_local_inference_denoising_steps=params.n_local_inference_denoising_steps, + start_guide_steps_fraction=params.start_guide_steps_fraction, + n_guide_steps=params.n_guide_steps, + n_diffusion_steps_without_noise=params.n_diffusion_steps_without_noise, + weight_grad_cost_collision=params.weight_grad_cost_collision, + weight_grad_cost_smoothness=params.weight_grad_cost_smoothness, + weight_grad_cost_constraints=params.weight_grad_cost_constraints, + weight_grad_cost_soft_constraints=params.weight_grad_cost_soft_constraints, + factor_num_interpolated_points_for_collision=params.factor_num_interpolated_points_for_collision, + trajectory_duration=params.trajectory_duration, + device='cuda', debug=False, seed=params.seed, + results_dir=params.results_dir, + trained_models_dir=args.models_root, + model_ids=[MODEL_ID], model_id=MODEL_ID, + transforms={0: torch.zeros(2, **tensor_args)}, +) + +# CS instrumentation: fraction of the candidate batch that is collision-free. +# MMD-CBS enforces no velocity limit, so there is nothing to SET -- but constraint +# safety is a property of the trajectories MEASURED against a cap, so it is measured +# against all three. DiRecT's Tables 9-10 instead print one run's CS in all three +# columns; since their CS is defined as the fraction of samples satisfying the velocity +# limit, a repeated value below 100 (Conveyor N=12: 79.2 in every column) is a lower +# bound shown as a value, because a looser cap can only admit more. +VMAX_TRIPLE = {'empty': (0.483, 0.571, 0.675), 'basic': (0.477, 0.567, 0.675), + 'dense': (0.565, 0.617, 0.675), 'room': (0.647, 0.783, 0.878), + 'corridor': (1.21, 1.46, 1.76), 'corridorbay': (1.21, 1.46, 1.76), + 'shelf': (0.928, 1.13, 1.34), 'crossroom': (0.928, 1.13, 1.34)} + +CS_LOG = [] +_orig_call = MPD.__call__ +def _call(self, *a, **kw): + out = _orig_call(self, *a, **kw) + n_free = 0 if out.trajs_final_free_idxs is None else int(out.trajs_final_free_idxs.shape[0]) + CS_LOG.append(n_free / float(self.num_samples)) + return out +MPD.__call__ = _call + +# One env instance reused for data adherence: constructing it per agent costs ~2 s each +# (SDF precompute) and silently added ~N*2 s of scoring time per trial. +DA_ENV = env_cls(tensor_args=tensor_args) + +# ---- instances --------------------------------------------------------------- +AGENT_LIST = [int(x) for x in str(args.n_agents).split(',')] +OBST_MARGIN = float(os.environ.get('OBSTACLE_MARGIN', 0.2)) +INTER_MARGIN = float(os.environ.get('INTER_AGENT_MARGIN', 0.2)) +rows = [] +for N_AG in AGENT_LIST: + args.n_agents = N_AG + fix_random_seed(args.seed) + tick(f"sampling start/goal instances for N={N_AG} (obstacle_margin={OBST_MARGIN}, inter={INTER_MARGIN})") + t = time.time() + _shared = os.environ.get('SHARED_INSTANCES_NPZ') + if _shared: + _z = np.load(_shared) + _key = f"{args.map}rbt/N{N_AG}" + _sa = _z[f"{_key}/starts"][:args.n_instances] # (I, N, 2) world metres, certified + _ga = _z[f"{_key}/goals"][:args.n_instances] + start_bl = [[torch.tensor(_sa[tt, ii], **tensor_args) for ii in range(N_AG)] + for tt in range(_sa.shape[0])] + goal_bl = [[torch.tensor(_ga[tt, ii], **tensor_args) for ii in range(N_AG)] + for tt in range(_ga.shape[0])] + args.n_instances = len(start_bl) + tick(f"loaded {args.n_instances} CERTIFIED shared instances ({os.path.basename(_shared)} :: {_key})") + else: + start_bl, goal_bl = get_start_goal_pos_batch_random_in_env( + N_AG, args.n_instances, env_cls, tensor_args, margin=INTER_MARGIN, obstacle_margin=OBST_MARGIN) + tick(f"instance sampling {time.time()-t:.1f}s") + for trial in range(args.n_instances): + try: + tw0 = time.time() + start_l, goal_l = start_bl[trial], goal_bl[trial] + tick(f"=== trial {trial}: building {args.n_agents}+1 MPD planners ===") + tb = time.time() + ref = MPD(start_state_pos=torch.tensor([0.5, 0.9], **tensor_args), + goal_state_pos=torch.tensor([-0.5, 0.9], **tensor_args), **LOW_ARGS) + t_ref = time.time() - tb + planners = [] + tb2 = time.time() + for i in range(args.n_agents): + planners.append(MPD(start_state_pos=start_l[i], goal_state_pos=goal_l[i], **LOW_ARGS)) + t_agents = time.time() - tb2 + tick(f"planner build: reference {t_ref:.1f}s + {args.n_agents} agents {t_agents:.1f}s " + f"({t_agents/max(args.n_agents,1):.1f}s/agent)") + + cbs = CBS(planners, start_l, goal_l, + start_time_l=[0]*args.n_agents, is_xcbs=False, is_ecbs=False, + conflict_type_to_constraint_types={PointConflict: {MultiPointConstraint}}, + reference_task=ref.task, reference_robot=ref.robot, + runtime_limit=args.runtime_limit) + CS_LOG.clear() + tp = time.time() + paths_l, n_ct, status, n_coll_sol = cbs.plan(runtime_limit=args.runtime_limit) + t_plan = time.time() - tp + tick(f"CBS plan {t_plan:.1f}s status={status} ct_expansions={n_ct} low_level_calls={len(CS_LOG)}") + + # ---- scoring ------------------------------------------------------------ + row = dict(trial=trial, map=args.map, env=ENV_CLS, n_agents=args.n_agents, + n_samples=args.n_samples, status=str(status), num_ct_expansions=n_ct, + t_plan_s=t_plan, t_build_s=t_ref + t_agents, t_wall_s=time.time()-tw0, + low_level_calls=len(CS_LOG), + # NOT constraint safety: this is the fraction of the CBS low-level + # planner's diffusion samples that came back collision-free. It was + # reported in the CS column, where every other method reports + # collision-and-velocity feasibility of the returned plan -- a different + # quantity, so the column was not comparable across rows. + low_level_free_frac=float(np.mean(CS_LOG)) if CS_LOG else 0.0) + ok_len = len(paths_l) == args.n_agents and all(p is not None and p.numel() for p in paths_l) + if not ok_len: + row.update(SR=0, SOLVED_005=0, SOLVED_010=0, coll_env=-1, coll_agents=-1, + DA=float('nan'), goal_err_max=float('nan')) + else: + P = torch.stack([p for p in paths_l]) # [N, H, 4] + pos = P[..., :2] + # env collisions + coll, coll_idx, free, free_idx, _ = ref.task.get_trajs_collision_and_free(P, return_indices=True) + n_env_coll_agents = int(coll_idx.shape[0]) if coll_idx is not None else 0 + # agent-agent collisions (any pair closer than 2r at the same step) + d = torch.cdist(pos.transpose(0, 1), pos.transpose(0, 1)) # [H, N, N] + iu = torch.triu_indices(args.n_agents, args.n_agents, offset=1) + n_pair_coll = int((d[:, iu[0], iu[1]] < 2.0 * WANT_R).sum().item()) + goal_err = torch.stack([torch.norm(pos[i, -1] - goal_l[i]) for i in range(args.n_agents)]) + collision_free = (n_env_coll_agents == 0) and (n_pair_coll == 0) + _sv = os.environ.get('MMD_SAVE_TRAJ') + if _sv: + os.makedirs(_sv, exist_ok=True) + np.savez(os.path.join(_sv, f'trial_{trial}.npz'), pos=pos.detach().cpu().numpy(), + goals=torch.stack(goal_l).detach().cpu().numpy(), + coll_env=n_env_coll_agents, coll_agents=n_pair_coll) + # velocity satisfaction, measured not imposed. dt matches every other runner: + # trajectory_duration / horizon. + dt = params.trajectory_duration / pos.shape[1] + step = torch.linalg.norm(pos[:, 1:, :] - pos[:, :-1, :], dim=-1) # [N, H-1] + vmax_obs = float(step.max() / dt) + row['vmax_observed'] = vmax_obs + for _nm, _cap in zip(('low', 'medium', 'high'), VMAX_TRIPLE[args.map]): + row[f'CS_vel_{_nm}'] = int(vmax_obs <= _cap * 1.01) + # CS on the same footing as the other methods: collision-free AND under the cap + row[f'CS_{_nm}'] = int(collision_free and vmax_obs <= _cap * 1.01) + # data adherence + da = 0.0 + for i in range(args.n_agents): + da += float(DA_ENV.compute_traj_data_adherence(pos[i])) + da /= args.n_agents + row.update(SR=int(collision_free), + SOLVED_005=int(collision_free and bool((goal_err <= 0.05).all())), + SOLVED_010=int(collision_free and bool((goal_err <= 0.10).all())), + coll_env=n_env_coll_agents, coll_agents=n_pair_coll, + DA=da, goal_err_max=float(goal_err.max())) + tick(f"trial {trial} -> {json.dumps({k: v for k, v in row.items() if k != 'trial'})}") + rows.append(row) + del planners, cbs, ref + torch.cuda.empty_cache() + except Exception as e: + import traceback; traceback.print_exc() + tick(f"trial {trial} CRASHED: {type(e).__name__}: {e}") + rows.append(dict(trial=trial, map=args.map, env=ENV_CLS, n_agents=args.n_agents, + n_samples=args.n_samples, status=f'CRASH:{type(e).__name__}', + SR=0, SOLVED_005=0, SOLVED_010=0, t_wall_s=time.time()-tw0)) + torch.cuda.empty_cache() + +def agg(k, rs): + v = [r[k] for r in rs if isinstance(r.get(k), (int, float)) and r[k] == r[k]] + return [round(float(np.mean(v)), 4), round(float(np.std(v)), 4)] if v else [None, None] + +per_n = {} +for N_AG in AGENT_LIST: + rs = [r for r in rows if r.get('n_agents') == N_AG] + if not rs: + continue + per_n[N_AG] = dict( + n_trials=len(rs), + SR=agg('SR', rs)[0], SOLVED_005=agg('SOLVED_005', rs)[0], SOLVED_010=agg('SOLVED_010', rs)[0], + CS_low=agg('CS_low', rs), CS_medium=agg('CS_medium', rs), CS_high=agg('CS_high', rs), + CS_vel_low=agg('CS_vel_low', rs), CS_vel_medium=agg('CS_vel_medium', rs), + CS_vel_high=agg('CS_vel_high', rs), vmax_observed=agg('vmax_observed', rs), + low_level_free_frac=agg('low_level_free_frac', rs), + Coll_env=agg('coll_env', rs), Coll_agents=agg('coll_agents', rs), + DA=agg('DA', rs), Time_plan_s=agg('t_plan_s', rs), Time_build_s=agg('t_build_s', rs), + Time_wall_s=agg('t_wall_s', rs), CT_expansions=agg('num_ct_expansions', rs), + low_level_calls=agg('low_level_calls', rs), + statuses={st: sum(1 for r in rs if r.get('status') == st) for st in sorted({r.get('status') for r in rs})}, + ) + +summary = dict( + map=args.map, env=ENV_CLS, obs_scale=WANT_SCALE, robot_r=WANT_R, + occupied_fraction=OCC, free_for_disk=FREE_R, + agent_list=AGENT_LIST, n_instances=args.n_instances, n_samples=args.n_samples, + runtime_limit=args.runtime_limit, models_root=args.models_root, + obstacle_margin=OBST_MARGIN, inter_agent_margin=INTER_MARGIN, + per_n=per_n, total_wall_s=time.time() - T0, rows=rows, +) +os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) +with open(args.out, 'w') as f: + json.dump(summary, f, indent=2, default=str) +print("\n=== CELL SUMMARY ===") +print(json.dumps({k: v for k, v in summary.items() if k != 'rows'}, indent=2, default=str)) +tick("done") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_single_method.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_single_method.sh new file mode 100644 index 0000000000000000000000000000000000000000..e7c68fe7abc132e703c28bc7636144cdd284e221 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/run_single_method.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Run a single method from the benchmark suite. +# +# Usage: +# bash run_single_method.sh GPU_ID METHOD [ENV] [N_AGENTS] [--dry-run] +# +# METHOD is one of: diffusion_policy, cd, pcd, direct, final_projection, mmd_cbs +# +# Examples: +# bash run_single_method.sh 0 direct empty 4 +# bash run_single_method.sh 0 pcd highway 4 --dry-run +# bash run_single_method.sh 0 diffusion_policy # defaults: all envs, all agents + +set -e + +GPU=${1:?Usage: run_single_method.sh GPU_ID METHOD [ENV] [N_AGENTS] [--dry-run]} +METHOD=${2:?Usage: run_single_method.sh GPU_ID METHOD [ENV] [N_AGENTS] [--dry-run]} +ENV=${3:-} +N_AGENTS=${4:-} +shift 2 +[ -n "$ENV" ] && shift +[ -n "$N_AGENTS" ] && shift + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +EXTRA_ARGS=() +[ -n "$ENV" ] && EXTRA_ARGS+=(--envs "$ENV") +[ -n "$N_AGENTS" ] && EXTRA_ARGS+=(--agents "$N_AGENTS") + +python run_benchmark.py --gpu-id "$GPU" --only "$METHOD" "${EXTRA_ARGS[@]}" "$@" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/sbatch_benchmark.sh b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/sbatch_benchmark.sh new file mode 100644 index 0000000000000000000000000000000000000000..fd307ddb7712d63e6246ae96b82fde2b53d3b675 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/DiRecT/scripts/inference/sbatch_benchmark.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================ +# Submit N SLURM jobs, splitting the (env, vel_max) work evenly. +# +# Usage: +# cd /path/to/multi_robot +# bash scripts/inference/sbatch_benchmark.sh # default 6 jobs +# bash scripts/inference/sbatch_benchmark.sh -n 3 # 3 jobs +# bash scripts/inference/sbatch_benchmark.sh -n 4 --dry-run +# +# Extra args after -n are forwarded to run_benchmark.py. +# ============================================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$PROJECT_ROOT" + +# ─── CONFIGURATION (edit these) ─── +ENVS=("empty" "highway" "conveyor" "dropregion") + +# Per-env vel_maxs (must match run_benchmark.py ENVS dict) +declare -A ENV_VMAXS +ENV_VMAXS[empty]="0.675 0.692 0.703" +ENV_VMAXS[highway]="0.647 0.781 0.878" +ENV_VMAXS[conveyor]="1.21 1.46 1.76" +ENV_VMAXS[dropregion]="0.928 1.13 1.34" + +# Default number of SLURM jobs +N_JOBS=6 + +# SLURM settings +PARTITION="${SLURM_PARTITION:-YOUR_GPU_PARTITION}" # adjust to your cluster +GRES="gpu:1" +TIME="4-00:00:00" +CPUS=8 +MEM="64G" # total memory (not per-cpu) +CONDA_ENV="direct-mmd" + +# ─── PARSE -n and --dry-run ─── +DRY_RUN=false +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + -n) N_JOBS="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; ARGS+=("$1"); shift ;; + *) ARGS+=("$1"); shift ;; + esac +done +EXTRA_ARGS="${ARGS[*]}" + +# ─── BUILD WORK LIST: (env, vmax) pairs ─── +WORK=() +for ENV in "${ENVS[@]}"; do + for VMAX in ${ENV_VMAXS[$ENV]}; do + WORK+=("${ENV}:${VMAX}") + done +done +TOTAL=${#WORK[@]} + +if [[ $N_JOBS -gt $TOTAL ]]; then + N_JOBS=$TOTAL +fi + +# ─── LOG DIRECTORY ─── +TIMESTAMP=$(date +%y%m%d_%H%M%S) +SLURM_LOG_DIR="$PROJECT_ROOT/logs/benchmark_${TIMESTAMP}" +mkdir -p "$SLURM_LOG_DIR" + +echo "============================================" +echo " Submitting benchmark: ${TOTAL} tasks -> ${N_JOBS} jobs" +echo " Envs: ${ENVS[*]}" +echo " Tasks: ${WORK[*]}" +echo " Logs: ${SLURM_LOG_DIR}" +echo "============================================" + +# ─── SPLIT WORK INTO N_JOBS CHUNKS AND SUBMIT ─── +for ((JOB=0; JOB1, +Yi Xian Goh2, +[See-Kiong Ng](https://comp.nus.edu.sg/~ngsk)1,3, +[Chun Kai Ling](https://lingchunkai.github.io)1 + +1Department of Computer Science, School +of Computing, National University of Singapore; +2Department of Artificial Intelligence, Faculty of Computer Science \& Information Technology, Universiti Malaya; +3Institute of Data Science, National University of Singapore. + + +This repository contains the implementation of *Projected Coupled Diffusion* (PCD). + + +## 📢 Introduction +We propose *Projected Coupled Diffusion (PCD)*, a novel test-time framework for constrained joint generation. PCD introduces a coupled guidance term into the generative dynamics to encourage coordination between diffusion models and incorporates a projection step at each diffusion step to enforce hard constraints. +Empirically, we demonstrate the effectiveness of PCD in application scenarios of image-pair generation, object manipulation, and multi-robot motion planning. + + +## 🔨 Usage +Please see specific instructions in the folders [Toy-Example](./toy_example/README.md), [Multi-Robot](./multi_robot/README.md), [PushT](./pusht/README.md), and [Image-Pair](./image_pair/README.md), respectively. + + + + + +## 🏷️ License +This repository is released under the MIT license. See [LICENSE](LICENSE) for additional details. + + +## 🙏 Acknowledgement +- Our implementation of multi-robot experiments is based on [MMD](https://github.com/yoraish/mmd). +- Our PushT implementation is based on [Diffusion Policy](https://github.com/real-stanford/diffusion_policy) and [LTLDoG](https://ieeexplore.ieee.org/document/10637680). +- Our image generation experiments use [Diffusers](https://github.com/huggingface/diffusers), pretrained model weights of [Stable Diffusion v2.1](https://huggingface.co/stabilityai/stable-diffusion-2-1-base), and the [FFHQ-Aging Dataset](https://github.com/royorel/FFHQ-Aging-Dataset). + + +## ✏️ Citation +If you find this repo or the ideas presented in our paper useful for your research, please consider citing our paper. +``` +@inproceedings{luan2026projected, + title={Projected Coupled Diffusion for Test-Time Constrained Joint Generation}, + author={Hao Luan and Yi Xian Goh and See-Kiong Ng and Chun Kai Ling}, + booktitle={The Fourteenth International Conference on Learning Representations}, + year={2026}, + url={https://openreview.net/forum?id=1FEm5JLpvg} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9fac3febcf6a0b9cae537876d02687493c0f2168 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/LICENSE @@ -0,0 +1,72 @@ +MIT License + +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Large portions of the included code are from/modified-from the following source: https://github.com/yoraish/mmd. +We repeat their license below. + + +MIT License + +Copyright (c) 2024 Yorai Shaoul, Itamar Mishani, and Shivam Vats. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Large portions of the included code are from/modified-from the following source: https://github.com/jacarvalho/mpd-public +We repeat their license below. + +MIT License + +Copyright (c) 2022 Julen Urain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/README.md b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/README.md new file mode 100644 index 0000000000000000000000000000000000000000..de341264b36d39b27fd274f9b537bb8a74bc9146 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/README.md @@ -0,0 +1,164 @@ +# PCD Implementation on MMD Benchmark + +This implementation is built upon [MMD](https://github.com/yoraish/mmd) [1]. + +## What This Repo Adds/Modifies + +This repo extends the upstream MMD release with: +- PCD as a planning method +- projection and coupling-cost logic under `mmd/models/projection/` and `mmd/coupling_costs/` +- inference launchers in `scripts/inference/` +- result analysis scripts in `scripts/analyze/` +- Some patches for certain files in `deps/torch_robotics/` + +If you are reproducing results, use the our entrypoints detailed below rather than the upstream examples alone. + +--- + +## Installation + +Follow the setup steps from MMD as below. + +### Requirements +- [miniconda](https://docs.conda.io/projects/miniconda/en/latest/index.html) + +### Steps + +1. Create a conda environment and activate it: + ```sh + conda env create -f environment.yml + conda activate pcdmmd + ``` +2. Install PyTorch. This may be different depending on your system. We used the following setup: + ```sh + conda install pytorch==2.1.1 torchvision==0.16.1 torchaudio==2.1.1 pytorch-cuda=12.1 -c pytorch -c nvidia + ``` +3. Install some local packages (modified version of [2, 3]): + ```sh + cd deps/torch_robotics + pip install -e . + cd ../experiment_launcher + pip install -e . + cd ../motion_planning_baselines + pip install -e . + cd ../.. + ``` +4. Install the `mmd` package: + ```sh + pip install -e . + ``` + + +### Get Datasets + +Download the sample datasets and models provided by MMD. + +```sh +conda activate pcdmmd +``` + +```sh +gdown --id 1Onw0s1pDsMLDfJVOAqmNme4eVVoAkkjz +tar -xJvf data_trajectories.tar.xz + +gdown --id 1WO3tpvg-HU0m9RyDvGyfDamo7roBYMud +tar -xJvf data_trained_models.tar.xz +``` + +### Generating a Missing Dataset + +Dataset for `DropRegion` is missing from the official release of MMD. +Do the following to generate a few (all we need is some confiuration files actually): + +```sh +conda activate pcdmmd + +python scripts/generate_data/launch_generate_trajectories.py +``` + +- The generated data will be saved in the `logs/` directory. In order to use the generated data, please move it to the `data_trajectories/` directory. Rename and reorganize the folder structure following other environments therein. + + +## Planning with PCD-MMD + +### Running Inference with PCD/PD/Coupling + +Launch planning by executing `scripts/inference/launch_pcdiff.py`. +***Users need to check and adjust settings in the script***. + +```bash +conda activate pcdmmd +python scripts/inference/launch_pcdiff.py --config pcdiff_empty.yaml -g coupling -p max_vel_admm -c "hinge_sqr_l2" -r --gpu-id 0 -m custom_comments +``` +Usage: +- `--config`: to designate config file for the task to run. See all under `configs/`. +- `-g`: use coupling or not, should be one of `{'coupling', 'vanilla'}`. +- `-p`: use projection or not, should be one of `{'max_vel_admm', 'none'}`. +- `-c`: specify the cost function should be one of `{'hinge_sqr_l2', 'sum_log_l2'}`, corresponding to the SHD and LB cost specified in the paper, respectively. +- `-r`: Cartesian product of all specified hyperparameters for multiple runs. +- `--gpu_id`: specify CUDA device number. + +Results will be saved under the `results/` directory: +- A log will be saved under `results/000event_logs`, containing result directories of (potentially multiple) experiments. + +### Process and Analyze Results + +1. Edit the config file for analysis `configs/analysis_config.yaml`: + - Paste the relative directory to the log file `xxx.log` under the `log_paths` field. + - Configure other switches and default constants if needed: + - E.g., `DEFAULT_MAX_VEL` specifies the value of max-velocity constraint; *this only affects methods that are NOT configured with such info*, e.g., vanilla diffusion or coupling-only method. The analysis script automatically retrieves the velocity constraint used for PCD or projection-only method. +2. Run the analysis script `scripts/analyze/analyzing_pcd.py` to calculate all the metrics: + ```bash + conda activate pcdmmd + python scripts/analyze/analyzing_pcd.py --config analysis_config.yaml + ``` + - A JSON file will be generated and placed under each directory specified in the log file; this JSON file contains all the metrics. + - You need to run the analysis script multiple times if you are calculating velocity constraint satisfaction rates for vanilla diffusion or coupling-only method *for different velocity thresholds*. + +## Compare with MMD + +### Launching MMD + +1. Configure the task to run and configure number of trials in `scripts/inference/launch_mmd.py`. +2. Run the script: + ```bash + conda activate pcdmmd + python scripts/inference/launch_mmd.py + ``` + +### Postprocess and Analyze MMD Results + +1. Configure the script `scripts/analyze/process_mmd.py` following the examples therein. +2. Run the postprocessing script for MMD: + ```bash + python scripts/analyze/process_mmd.py + ``` + - A dummy log file will be created under *the **parent** directory of the **first** experiment directory specified in the script*, with a timestamp in its file name. +3. Configure the analysis config `configs/analyze_mmd.yaml` similar to the above-mentioned process for PCD. +4. Run the analysis script: + ```bash + python scripts/analyze/analyzing_mmd.py --config analyze_mmd.yaml + ``` + - Similarly, a JSON file will be saved under the directory of each experiment run. + + +## ✏️ Citation +If you find this repo or the ideas presented in our paper useful for your research, please consider citing our paper. +``` +@inproceedings{luan2026projected, + title={Projected Coupled Diffusion for Test-Time Constrained Joint Generation}, + author={Hao Luan and Yi Xian Goh and See-Kiong Ng and Chun Kai Ling}, + booktitle={The Fourteenth International Conference on Learning Representations}, + year={2026}, + url={https://openreview.net/forum?id=1FEm5JLpvg} +} +``` + + +--- +## References + +- [1] Shaoul Y, Mishani I, Vats S, Li J, Likhachev M. Multi-robot motion planning with diffusion models. ICLR. 2025. +- [2] Carvalho J, Le AT, Baierl M, Koert D, Peters J. Motion planning diffusion: Learning and planning of robot motions with diffusion models. 2023 IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS). 2023 Oct 1 (pp. 1916-1923). +- [3] Le AT, Chalvatzaki G, Biess A, Peters JR. Accelerating motion planning via optimal transport. Advances in Neural Information Processing Systems. 2023 Dec 15;36:78453-82. + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analysis_config.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analysis_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cba41463ba9ba82a6c06fc06151ffe71fc21bc90 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analysis_config.yaml @@ -0,0 +1,60 @@ +# ------------------- +# ANALYSIS CONFIG +# ------------------- + +# List of data directories to process. The script will loop through each directory. +log_paths: + - "results/000event_logs/eval_runs-some_runs.log" + - "results/000event_logs/eval_runs-some_other_runs.log" + + +# Switches for plotting and saving figures/data. +switches: + # plot_trajectories: true + plot_trajectories: false + plot_velocity_stats: true + save_plots: true + save_stats_json: true + +# General constants and parameters for the analysis. +constants: + EPS: 1.0e-6 + VEL_TOL: 5.0e-5 # Tolerance for velocity checks + ## --------------- Highway ------------------- + # DEFAULT_MAX_VEL: 0.647 # Default maximum velocity threshold (80% of max action) + # DEFAULT_MAX_VEL: 0.781 # (90% of max action) + # DEFAULT_MAX_VEL: 0.878 # (95% of max action) + ## --------------- Empty ------------------- + ##[0.675, 0.692, 0.703] + # DEFAULT_MAX_VEL: 0.675 # (75% of max action) + # DEFAULT_MAX_VEL: 0.692 # (90% of max action) + # DEFAULT_MAX_VEL: 0.703 # (95% of max action) + ## --------------- Conveyor ------------------- + ## [1.08, 1.46, 1.76] + # DEFAULT_MAX_VEL: 1.21 # (85% of max action) + # DEFAULT_MAX_VEL: 1.46 # (90% of max action) + # DEFAULT_MAX_VEL: 1.76 # (95% of max action) + ## --------------- DropRegion ------------------- + ## [0.928, 1.13, 1.34] + DEFAULT_MAX_VEL: 0.928 # (80% of max action) + # DEFAULT_MAX_VEL: 1.13 # (90% of max action) + # DEFAULT_MAX_VEL: 1.34 # (95% of max action) + + + VEL_HIST_QUANTILES: [0.75, 0.8, 0.85, 0.9, 0.95, 0.99] + +# Plotting-specific parameters. +plotting: + # Number of initial states figures to plot + num_inits_plot: 10 + # Number of trajectory tuples to plot + num_trj_tups_plot: 64 + # Trajectory plotting settings + traj_plot_xlim: [-1, 1] + traj_plot_ylim: [-1, 1] + traj_plot_h_end: 64 + # Velocity histogram settings + vel_hist_bins: 100 + +data: + default_trained_models_dir: "default_trained_models_dir" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analyze_mmd.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analyze_mmd.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bfa907c211940fba38f3c57e02d26cd77775eaea --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/analyze_mmd.yaml @@ -0,0 +1,59 @@ +# ------------------- +# ANALYSIS CONFIG +# ------------------- + +# List of data directories to process. The script will loop through each directory. +log_paths: + ##---------------------- Highway ---------------------- + # - "results/EnvHighways2DRobotPlanarDiskRandom/dummy_logs.log" + ### -------------- Empty ------------------- + # - "results/EnvEmpty2DRobotPlanarDiskRandom/dummy_logs.log" + ### ----------------- Conveyor ------------------- + - "results/EnvConveyor2DRobotPlanarDiskRandom/dummy_logs.log" + +switches: + plot_trajectories: false + plot_velocity_stats: false + save_plots: false + save_stats_json: true + +constants: + EPS: 1.0e-6 + VEL_TOL: 5.0e-5 # Tolerance for velocity checks + ## --------------- Highway ------------------- + # DEFAULT_MAX_VEL: 0.647 # Default maximum velocity threshold (80% of max action) + # DEFAULT_MAX_VEL: 0.781 # (90% of max action) + # DEFAULT_MAX_VEL: 0.878 # (95% of max action) + ## --------------- Empty ------------------- + ##[0.675, 0.692, 0.703] + # DEFAULT_MAX_VEL: 0.675 # (75% of max action) + # DEFAULT_MAX_VEL: 0.692 # (90% of max action) + # DEFAULT_MAX_VEL: 0.703 # (95% of max action) + ## --------------- Conveyor ------------------- + ## [1.21, 1.46, 1.76] + # DEFAULT_MAX_VEL: 1.21 # (85% of max action) + # DEFAULT_MAX_VEL: 1.46 # (90% of max action) + DEFAULT_MAX_VEL: 1.76 # (95% of max action) + ## --------------- DropRegion ------------------- + ## [0.928, 1.13, 1.34] + # DEFAULT_MAX_VEL: 0.928 # (85% of max action) + # DEFAULT_MAX_VEL: 1.13 # (90% of max action) + # DEFAULT_MAX_VEL: 1.34 # (95% of max action) + + VEL_HIST_QUANTILES: [0.75, 0.8, 0.85, 0.9, 0.95, 0.99] + +# Plotting-specific parameters. +plotting: + # Number of initial states figures to plot + num_inits_plot: 10 + # Number of trajectory tuples to plot + num_trj_tups_plot: 64 + # Trajectory plotting settings + traj_plot_xlim: [-1, 1] + traj_plot_ylim: [-1, 1] + traj_plot_h_end: 64 + # Velocity histogram settings + vel_hist_bins: 100 + +data: + default_trained_models_dir: "default_trained_models_dir" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_conveyor.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_conveyor.yaml new file mode 100644 index 0000000000000000000000000000000000000000..84473762be173b886a02b1982375a1b8ef2bdfc6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_conveyor.yaml @@ -0,0 +1,90 @@ +# config.yaml +# +# This YAML file centralizes all configurations for the multi-robot planning experiment. +# To run an experiment, execute: +# python script_name.py --config path/to/this/config.yaml + +# General experiment settings +experiment: + name: "single_tile_conveyor" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # in seconds + seed: 18 + time_str: "auto" # "auto" will generate from current time, or provide a string like "07132025_100000" + render_animation: true + +# System and directory settings +system: + device: "cuda:0" + base_dir: "auto" # "auto" resolves to the project root, or provide a full path + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # "auto" resolves to the results directory, or provide a full path + +# Robot-specific parameters +robot: + planar_disk_radius: 0.05 + +# Environment and scenario setup +environment: + type: "single_tile" + global_model_ids: [['EnvConveyor2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvConveyor2D" + margin: 0.2 + obstacle_margin: 0.2 + +# Planner configurations +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + # Low-level single-agent trajectory generation planner (the diffusion model) + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 # Number of samples **per agent** + debug: true + timeit: false # Enable timing for performance measurement + # --- Guidance and Diffusion Parameters --- + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" # Key for the cost function used in guidance + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 # Coefficient for the collision cost radius + # --- Cost Weights for Guidance --- + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_corridorbay.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_corridorbay.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fa8f1cbfccc59e075accd4c32109cc65b44f5eae --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_corridorbay.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_corridorbay" + num_agents: 2 + n_inits: 3 + runtime_limit: 30 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvCorridorSMD2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvCorridorSMD2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 1.21 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_crossroom.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_crossroom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..26b122f80faa02f73ab44f24239277f4fdf5c10d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_crossroom.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_crossroom" + num_agents: 2 + n_inits: 3 + runtime_limit: 30 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvCrossRoom2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvCrossRoom2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.928 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_dropregion.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_dropregion.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e51277ec877c37d18bd88103c2095c7f109c321b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_dropregion.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_dropregion" + num_agents: 2 + n_inits: 3 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvDropRegion2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvDropRegion2D" + margin: 0.2 + obstacle_margin: 0.2 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.05 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_empty.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_empty.yaml new file mode 100644 index 0000000000000000000000000000000000000000..05b8108386e6442ff8d019996d9e1eff871f886b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_empty.yaml @@ -0,0 +1,77 @@ +experiment: + name: "single_tile_empty" + num_agents: 8 + n_inits: 10 + runtime_limit: 60 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvEmpty2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvEmpty2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 # Number of samples **per agent** + debug: true + timeit: false # Enable timing for performance measurement + # --- Guidance and Diffusion Parameters --- + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 10.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.675 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_highway.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_highway.yaml new file mode 100644 index 0000000000000000000000000000000000000000..610ffd592a16dab6f4fbd5ad90ad7e781cdbb7b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/configs/pcdiff_highway.yaml @@ -0,0 +1,76 @@ +experiment: + name: "single_tile_highway" + num_agents: 2 + n_inits: 3 + runtime_limit: 30 # seconds + seed: 18 + time_str: "auto" # auto timestamp at runtime + render_animation: true + +system: + device: "cuda:0" + base_dir: "auto" # auto resolve base project directory + results_dir_name: "logs" + trained_models_dir_name: "data_trained_models" + log_pth: null # use default logger path behavior + +robot: + planar_disk_radius: 0.05 + +environment: + type: "single_tile" + global_model_ids: [['EnvHighways2D-RobotPlanarDisk']] + agent_skeleton_l: "auto" # auto-generate skeleton from environment + start_goal_config: + method: "random_in_env" + env_class_for_random: "EnvHighways2D" + margin: 0.2 + obstacle_margin: 0.11 + +planner: + multi_agent: + class: "DummyPlanning" + stagger_start_time_dt: 0 + is_xcbs: false + is_ecbs: false + conflict_type_to_constraint_types: + PointConflict: [ "MultiPointConstraint" ] + + single_agent: + class: "PCD" + planner_alg: "pcd" + horizon: 64 + trajectory_duration: 5.0 + n_samples: 128 + debug: true + timeit: false + use_guide_on_extra_objects_only: false + n_local_inference_noising_steps: 3 + n_local_inference_denoising_steps: 3 + start_guide_steps_fraction: 1.0 + n_guide_steps: 1 + n_diffusion_steps_without_noise: 0 + cost_func_key: "sum_log_l2" + cost_func_param: + margin_coeff: 1.9 + radius_coeff: 6.0 + weight_grad_cost_collision: 0.1 + weight_grad_cost_smoothness: 0.0 + weight_grad_cost_constraints: 0.2 + weight_grad_cost_soft_constraints: 0.0 + factor_num_interpolated_points_for_collision: 0 + low_level_choose_path_from_batch_strategy: "least_collisions" + + projection: + projector: + _target_: "ADMMProjectionOperatorCUDAGraph" + rho: 6.0 + max_iter: 500 + tol: 1.0e-5 + decomp: "lu" + convergence_check: true + check_period: 200 + verbose: false + + project_params: + vel_max: 0.8 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/CITATION.cff b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..1eb6ec957cf945952a32aef09e7cd1ddcfd08ea9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: +- family-names: "Le" + given-names: "An" + affiliation: "TU Darmstadt" +- family-names: "Carvalho" + given-names: "Joao" + affiliation: "TU Darmstadt" +title: "torch_robotics" +version: 0.1.0 +date-released: 15-10-23 +url: "https://github.com/anindex/torch_robotics" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9c052bb71ac9fb2c3cf5180c836d3f317ebfc4da --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 An Thai Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/MANIFEST.in b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..0752b91419620afd59f77fd95c8471a4d5fbb832 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +include CITATION.cff +recursive-include torch_robotics/data * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/README.md b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c198bf94e6c454405c2c200ec68e93a37ffab252 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/README.md @@ -0,0 +1,61 @@ +The original repository is this one: https://github.com/anindex/torch_robotics. +Their original README follows. + +# TorchRobotics + +This library implements differentiable robot tree from URDF or MCJF robot format, and the differentiable planning objects such as obstacle avoidance, self-collision avoidance and via point. + +**NOTE**: `torch_robotics` is under heavy development and highly experimental. + +## Installation + +Simply activate your conda/Python environment and run + +```azure +pip install -e . +``` + +## Examples + +To see FK, IK of all available robot kinematics + +```azure +python examples/forward_kinematics.py +``` + +and + +```azure +python examples/inverse_kinematics.py +``` + +## Acknowledgements + +A part of this implementation is inspired from the library [differentiable robot model](https://github.com/facebookresearch/differentiable-robot-model). + +## Contact + +If you have any questions or find any bugs, please let us know: + +- [An Le](https://www.ias.informatik.tu-darmstadt.de/Team/AnThaiLe), [an@robot-learning.de](an@robot-learning.de) +- [Joao Carvalho](https://www.ias.informatik.tu-darmstadt.de/Team/JoaoCarvalho), [joao@robot-learning.de](joao@robot-learning.de) + +## Citation + +If you found this repository useful, please consider citing these references: + +```azure +@inproceedings{le2023accelerating, + title={Accelerating Motion Planning via Optimal Transport}, + author={Le, An T. and Chalvatzaki, Georgia and Biess, Armin and Peters, Jan}, + booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, + year={2023} +} + +@article{carvalho2023motion, + title={Motion planning diffusion: Learning and planning of robot motions with diffusion models}, + author={Carvalho, Joao and Le, An T and Baierl, Mark and Koert, Dorothea and Peters, Jan}, + journal={arXiv preprint arXiv:2308.01557}, + year={2023} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/forward_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/forward_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..30b502a35bbe2b0669bd90184bbc688c922f1378 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/forward_kinematics.py @@ -0,0 +1,82 @@ +import torch +import time + +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_quat_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda, DifferentiableUR10, \ + DifferentiableTiagoDualHoloMove, DifferentiableShadowHand, DifferentiableAllegroHand, DifferentiableHabitatStretch +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +if __name__ == "__main__": + seed = 1 + fix_random_seed(seed) + + batch_size = 10 + device = "cpu" + # device = "cuda:0" + + print("\n===========================Panda Model===============================") + diff_panda = DifferentiableFrankaPanda(device=device) + diff_panda.print_link_names() + print(diff_panda.get_joint_limits()) + print(diff_panda._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_panda._n_dofs).to(device).requires_grad_(True) + data = diff_panda.compute_forward_kinematics_all_links(q) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================UR10 Model===============================") + diff_ur10 = DifferentiableUR10(device=device) + diff_ur10.print_link_names() + print(diff_ur10.get_joint_limits()) + print(diff_ur10._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_ur10._n_dofs).to(device).requires_grad_(True) + data = diff_ur10.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Habitat Stretch Model===============================") + diff_str = DifferentiableHabitatStretch(device=device) + diff_str.print_link_names() + print(diff_str.get_joint_limits()) + print(diff_str._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_str._n_dofs).to(device).requires_grad_(True) + data = diff_str.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Tiago Model===============================") + diff_tiago = DifferentiableTiagoDualHoloMove(device=device) + diff_tiago.print_link_names() + print(diff_tiago.get_joint_limits()) + print(diff_tiago._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_tiago._n_dofs).to(device).requires_grad_(True) + data = diff_tiago.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Shadow Hand Model===============================") + hand = DifferentiableShadowHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Allegro Hand Model===============================") + hand = DifferentiableAllegroHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/inverse_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/inverse_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..e412cdbd7e7aa503c2b7e434eca5742d03cde3db --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/examples/inverse_kinematics.py @@ -0,0 +1,82 @@ +from copy import copy + +import matplotlib.pyplot as plt +import torch + +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame +from torch_robotics.torch_kinematics_tree.geometrics.skeleton import get_skeleton_from_model +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import y_rot, z_rot +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_rot_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from torch_robotics.visualizers.plot_utils import plot_coordinate_frame + + +if __name__ == "__main__": + seed = 0 + fix_random_seed(seed) + + batch_size = 10 + device = 'cuda' + # device = 'cpu' + tensor_args = dict(device=device, dtype=torch.float32) + + pos_target = torch.tensor([0.2, 0.4, 0.1], **tensor_args) + rot_target = (z_rot(-torch.tensor(torch.pi/2)) @ y_rot(-torch.tensor(torch.pi))).to(**tensor_args) + frame_target = Frame(rot=rot_target, trans=pos_target, device=device) + H_target = frame_target.get_transform_matrix() # set translation and orientation of target here + + print("===========================Panda Model===============================") + # Panda Kinematics + diff_panda = DifferentiableFrankaPanda(gripper=False, device=device) + + with TimerCUDA() as t: + q_ik, idx_valid = diff_panda.inverse_kinematics( + H_target, link_name='ee_link', batch_size=batch_size, max_iters=500, lr=2e-1, se3_eps=5e-2, + eps_joint_lim=torch.pi/64, + # print_freq=-1, + debug=False + ) + print(f"\nIK time: {t.elapsed:.3f} sec") + + print(f'idx_valid: {idx_valid.nelement()}/{batch_size}') + + ############################################################################################################ + # plot the result + fig, ax = create_fig_and_axes(3) + ax.set_aspect('equal') + ax.set_xlim(-0.75, 0.75) + ax.set_ylim(-0.75, 0.75) + ax.set_zlim(-0.5, 1) + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + + # Origin frame + frame_origin = Frame(device=device) + ax.plot(0, 0, 0, color='b', marker='D', markersize=10, zorder=100) + plot_coordinate_frame(ax, frame_origin, arrow_length=0.1, arrow_linewidth=3., tensor_args=tensor_args) + + # Target frame + target_pos_np = to_numpy(pos_target) + ax.plot(target_pos_np[0], target_pos_np[1], target_pos_np[2], 'r*', markersize=20, zorder=100) + plot_coordinate_frame(ax, frame_target, arrow_length=0.15, arrow_linewidth=3., tensor_args=tensor_args) + + # Robot + for q in q_ik[idx_valid]: + skeleton = get_skeleton_from_model(diff_panda, q, diff_panda.get_link_names()) # visualize IK solution + skeleton.draw_skeleton(ax=ax, color='blue') + + # draw EE frame + H_EE = diff_panda.compute_forward_kinematics_all_links(q.unsqueeze(0), link_list=['ee_link']) + frame_EE = Frame( + rot=link_rot_from_link_tensor(H_EE).squeeze(), + trans=link_pos_from_link_tensor(H_EE).squeeze(), + device=device + ) + plot_coordinate_frame(ax, frame_EE, arrow_length=0.1, arrow_alpha=0.5, tensor_args=tensor_args) + + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c8cdb7b1095c010a77a5dbe88780e89b992b247 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/requirements.txt @@ -0,0 +1,5 @@ +pyyaml +urdf-parser-py +matplotlib +trimesh +einops diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..911e23eac7991a75b5c6ea6c4d0744143aa152cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/setup.py @@ -0,0 +1,23 @@ +import setuptools +from codecs import open +from os import path + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + + +setuptools.setup( + name='torch_robotics', + description='Full Differentiable Kinematics Tree Implementation constructed from URDF', + author='An Thai Le, Joao Carvalho', + author_email='an@robots-learning.de, joao@robots-learning.de', + packages=setuptools.find_namespace_packages(), + include_package_data=True, + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py new file mode 100644 index 0000000000000000000000000000000000000000..f01e409cd977d6f508ade1bfa35f9cb9aec36754 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py @@ -0,0 +1,86 @@ +import torch +import numpy as np + + +class Gaussian(object): + """ + Batch implementation of Gaussian distribution defined on a manifold. + """ + + def __init__(self, manifold, mean, cov): + self._manifold = manifold + if mean.shape[0] != manifold.dim_M: + raise TypeError(f'[Gaussian]: mean dim_M {mean.shape[0]} and the manifold dim_M {manifold.dim_M} mismatch!') + if cov.shape[0] != manifold.dim_T or cov.shape[1] != manifold.dim_T: + raise TypeError(f'[Gaussian]: cov shape {cov.shape} and the manifold dim_T {manifold.dim_T, manifold.dim_T} mismatch!') + self.mean = mean + self.cov = cov + + def pdf(self, x): + """ + Evaluates pdf of the distribution at the point x. + Parameters + ---------- + :param x: torch of shape (..., self.manifold.dim_M) + Returns + ------- + :return pdf_p: torch of shape (...,) + """ + v = self.manifold.log_map(x, base=self.mean) + return self._nf * torch.exp(-(v.transpose(-2, -1) @ self.cov_inv @ v).sum(-1) / 2) + + def transform(self, A, b): + """ + Gaussian transformation. + :param A: torch of shape (..., dim_T, dim_T), rotation in tangent space + :param b: np.array of shape (..., dim_M), translation in manifold space + Returns + ------- + :return np array of the transformed Gaussians with shape (..., ) + """ + if A.shape[-2] != self.manifold.dim_T or A.shape[-1] != self.manifold.dim_T: + raise RuntimeError('[Gaussian]: Expected A to be of the dimension of the tangent' + ' space (%s, %s) and not %s' % (self.manifold.dim_T, self.manifold.dim_T, A.shape)) + if b.shape[-1] != self.manifold.dim_M: + raise RuntimeError('[Gaussian]: Expected b to be of the dimension of the manifold' + ' space (%s,) and not %s' % (self.manifold.dim_M, b.shape)) + batch_dim = b.shape[:-1] + mu_trafo = self.manifold.exp_map(A @ (self.manifold.log_map(self.mean)), base=b).view((-1, self.manifold.dim_M)) # (2.53) + sigma_trafo = self.manifold.matrix_parallel_transport(A @ self.cov @ A.transpose(-2, -1), b, mu_trafo).view((-1, self.manifold.dim_T, self.manifold.dim_T)) # (2.54) + gaussians = [] + for i in range(mu_trafo.shape[0]): + gaussians.append(Gaussian(self.manifold, mu_trafo[i], sigma_trafo[i])) + gaussians = np.array(gaussians).reshape(batch_dim) + return gaussians + + def kl_divergence_mvn(self, g): + term1 = torch.log(torch.linalg.det(g.cov)) - torch.log(torch.linalg.det(self.cov)) + term2 = torch.trace(torch.linalg.solve(g.cov, self.cov)) - self.manifold.dim_T + v = g.manifold.log_map(self.mean, base=g.mean) + term3 = v.transpose(-2, -1) @ g.cov_inv @ v + return (term1 + term2 + term3) / 2 + + def recompute_inv(self): + self._cov_inv = torch.linalg.inv(self.cov) + self._nf = 1 / (torch.sqrt((2 * np.pi)**self.manifold.dim_T * torch.linalg.det(self.cov))) + + @property + def manifold(self): + return self._manifold + + @property + def cov(self): + return self._cov + + @property + def nf(self): + return self._nf + + @cov.setter + def cov(self, value): + self._cov = value + self.recompute_inv() + + @property + def cov_inv(self): + return self._cov_inv diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py new file mode 100644 index 0000000000000000000000000000000000000000..a7e6c80be65585526fd2fa66910432c89d82452a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py @@ -0,0 +1,20 @@ + +def e_log_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x - base + + +def e_exp_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x + base + + +def e_parallel_transport(x, g, h): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + return x \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py new file mode 100644 index 0000000000000000000000000000000000000000..b515eef0fd46038211629a5a5b819c83b39a16c6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py @@ -0,0 +1,278 @@ +import torch +import numpy as np +import logging +from torch_kinematics_tree.geometrics.quaternion import q_log_map, q_exp_map, q_parallel_transport +from torch_kinematics_tree.geometrics.euclidean import e_log_map, e_exp_map, e_parallel_transport +from torch_kinematics_tree.geometrics.distributions import Gaussian + + +class Manifold(object): + """ + Riemannian manifolds. + "Programming by Demonstration on Riemannian Manifolds", M.J.A. Zeestraten, 2018. + Attributes + ---------- + self.name, str, the manifold name, e.g. "R^3 x S^3" + self.dim_M, int, the manifold space dim_M + self.dim_T, int, the tangent space dim_M, dim_T <= dim_M + self.log_map, function, the map from tangent space to manifold: p_M = self.log_map(p_T, base=None), base=None is global map + self.exp_map, function, the map from manifold to tangent space: p_T = self.exp_map(p_M, base=None), base=None is global map + self.parallel_transport, the parallel transport map: p_hT = self.parallel_transport(p_gT, g, h) + """ + logger = logging.getLogger(__name__) + + def __init__(self, dim_M, dim_T, log_map, exp_map, parallel_transport, name, tensor_args=None): + self._dim_M = dim_M + self._dim_T = dim_T + self._log_map = log_map + self._exp_map = exp_map + self._parallel_transport = parallel_transport + self._name = name + if tensor_args is None: + device = torch.device('cuda:' + str(0) if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + self.tensor_args = tensor_args + + def cartesian_product(self, other): + """ + Compute the cartesian product between the current manifold self and another manifold other. + """ + dim_M = self.dim_M + other.dim_M + dim_T = self.dim_T + other.dim_T + name = self.name + " x " + other.name + + def log_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.log_map(p1, b1), other.log_map(p2, b2)), dim=-1) + + def exp_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.exp_map(p1, b1), other.exp_map(p2, b2)), dim=-1) + + def parallel_transport(p, g, h): + g1 = g[..., :self.dim_M] + g2 = g[..., self.dim_M:] + h1 = h[..., :self.dim_M] + h2 = h[..., self.dim_M:] + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + return torch.cat((self.parallel_transport(p1, g1, h1), other.parallel_transport(p2, g2, h2)), dim=-1) + + return Manifold(dim_M, dim_T, log_map, exp_map, parallel_transport, name) + + def matrix_parallel_transport(self, sigma, g, h): + Lp = torch.linalg.cholesky(sigma).transpose(-2, -1) + A_g_h_L = self.parallel_transport(Lp, g, h) + sigma_parallel = A_g_h_L.transpose(-2, -1) @ A_g_h_L + return sigma_parallel + + def mean(self, points_in_manifold, **kwargs): + """ + Computes the mean of the points points_in_manifold on the manifold according to the iterative likelihood maximization. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 50) + return_projections = kwargs.get('return_projections', False) + if isinstance(points_in_manifold, np.ndarray): + points_in_manifold = torch.from_numpy(points_in_manifold).to(**self.tensor_args) + if weights is None: + weights = torch.ones((points_in_manifold.shape[0], 1), **self.tensor_args) # batch size + if init_mu is None: + mu = points_in_manifold[0] + else: + mu = init_mu + eps_of_mu = None + k = 0 + for k in range(max_iter): + mu_last = mu + eps_of_mu = self.log_map(points_in_manifold, base=mu) + delta = (eps_of_mu * weights).mean(0) + mu = self.exp_map(delta, base=mu) + if torch.norm(mu - mu_last) < eps: + break + if k == max_iter - 1: + Manifold.logger.warning(f'Maximum of {max_iter} iterations reached!') + if return_projections: + return mu.squeeze(), eps_of_mu.squeeze() + else: + return mu.squeeze() + + def normal_distribution(self, points_in_manifold, **kwargs): + """ + Computes the Manifold Gaussian (mean and covariance) of the points points_in_manifold on the manifold + Parameters + ---------- + :param points_in_manifold: list of np.array of shape (dim_M,) or np.array of shape (dim_M, length). + Optional parameters + ------------------- + :param weights: np.array of shape (length,) that contains a weight for each point. None for no weights (all 1). + :param init_mu: np.array of shape (dim_M,), the initial mean for the iterative algorithm. + :param regularization: float, a regularization factor that prevents singular covariances + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: ManifoldGaussian, the Gaussian in this manifold. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + regularization = kwargs.get('regularization', 1e-5) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 100) + mu, eps_of_mu = self.mean( + points_in_manifold, + weights=weights, + init_mu=init_mu, + eps=eps, + max_iter=max_iter, + return_projections=True, + ) + sigma = torch.cov(eps_of_mu.T, aweights=weights) + regularization * torch.eye(self.dim_T, **self.tensor_args) # Table 2.4 + return Gaussian(self, mu, sigma) + + def gaussian_product(self, g_list, eps=1e-5, max_iter=100): + """ + Computes the Gaussian product of the ManifoldGaussians specified in g_list with the iterative likelihood maximization. + Table 2.3, Table 2.4 + Parameters + ---------- + :param g_list: list of ManifoldGaussian + Optional parameters + ------------------- + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: Gaussian, the combined Gaussian in this manifold. + """ + dim_T = self.dim_T + mus = torch.stack([g.mean.squeeze() for g in g_list], dim=0) + mu = self.mean(mus) # initial guess + J_T_W = torch.zeros((dim_T, len(g_list) * dim_T), **self.tensor_args) + W = torch.zeros((len(g_list) * dim_T, len(g_list) * dim_T), **self.tensor_args) + sigma_inv = torch.zeros((dim_T, dim_T), **self.tensor_args) + # start + k = 0 + log_last = -np.inf + eps_of_mu = torch.zeros(dim_T * len(g_list), **self.tensor_args) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + for k in range(max_iter): + sigma_inv *= 0 + mu_last = mu + for p, g in enumerate(g_list): + sigma_parallel_p = self.matrix_parallel_transport(g.cov, g.mean, mu) + J_T_W[:, dim_T * p:dim_T * (p + 1)] = -torch.linalg.inv(sigma_parallel_p) + W[dim_T * p:dim_T * (p + 1), dim_T * p:dim_T * (p + 1)] = J_T_W[:, dim_T * p:dim_T * (p + 1)] + sigma_inv += -J_T_W[:, dim_T * p:dim_T * (p + 1)] + # backtracking line search + log_likelihood = -np.inf + delta = -torch.linalg.solve(sigma_inv, J_T_W @ eps_of_mu) + s = 1 + while log_likelihood <= log_last and np.abs(s) > 1e-30: + mu = self.exp_map(delta * s, base=mu_last) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + log_likelihood = eps_of_mu @ W @ eps_of_mu + s /= -np.sqrt(2) + if log_likelihood - log_last < eps: + break + log_last = log_likelihood + if k == max_iter - 1: + Manifold.logger.warning(f'Iterative likelihood maximization for mean terminated after reaching {max_iter} iterations') + return Gaussian(self, mu.squeeze(), torch.linalg.inv(sigma_inv)) + + def get_origin(self): + spaces = self.name.split(" x ") + origin = [] + for man_name in spaces: + if man_name == "S^3": + origin.extend([1., 0., 0., 0.]) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + origin.extend([0.] * n) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return torch.tensor(origin, **self.tensor_args) + + def get_pos_quat_indices(self, tangent=False): + spaces = self.name.split(" x ") + pos_idx, quat_idx = [], [] + i = 0 + for man_name in spaces: + if man_name == "S^3": + r = 3 if tangent else 4 + quat_idx.extend(range(i, i + r)) + i += r + elif man_name[:2] == "R^": + n = int(man_name[2:]) + pos_idx.extend(range(i, i + n)) + i += n + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return np.array(pos_idx), np.array(quat_idx) + + @staticmethod + def get_quaternion_manifold(): + return Manifold(4, 3, q_log_map, q_exp_map, q_parallel_transport, "S^3") + + @staticmethod + def get_euclidean_manifold(n): + return Manifold(n, n, e_log_map, e_exp_map, e_parallel_transport, ("R^%s" % n)) + + @staticmethod + def get_manifold_from_name(name): + manifold_names = name.split(" x ") + manifolds = [] + for man_name in manifold_names: + if man_name == "S^3": + manifolds.append(Manifold.get_quaternion_manifold()) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + manifolds.append((Manifold.get_euclidean_manifold(n))) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + full_manifold = manifolds[0] + for man in manifolds[1:]: + full_manifold = full_manifold.cartesian_product(man) + return full_manifold + + @property + def dim_M(self): # noqa + return self._dim_M + + @property + def dim_T(self): # noqa + return self._dim_T + + @property + def log_map(self): + return self._log_map + + @property + def exp_map(self): + return self._exp_map + + @property + def parallel_transport(self): + return self._parallel_transport + + @property + def name(self): + return self._name \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py new file mode 100644 index 0000000000000000000000000000000000000000..10cf9f2eaabaf37cf3aca93feee23a0327664f71 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py @@ -0,0 +1,64 @@ +import torch_robotics.torch_kinematics_tree +from pathlib import Path +import yaml + + +# get paths +def get_root_path(): + path = Path(torch_robotics.torch_kinematics_tree.__path__[0]).resolve() / '..' + return path + + +def get_data_path(): + path = get_root_path() / 'data' + return path + + +def get_json_path(): + path = get_data_path() / 'jsons' + return path + + +def get_imgs_path(): + path = get_data_path() / 'imgs' + return path + + +def get_urdf_path(): + path = get_data_path() / 'urdf' + return path + + +def get_mjcf_path(): + path = get_data_path() / 'mjcf' + return path + + +def get_usd_path(): + path = get_data_path() / 'usd' + return path + + +def get_robot_path(): + path = get_urdf_path() / 'robots' + return path + + +def get_objects_path(): + path = get_urdf_path() / 'objects' + return path + + +def get_configs_path(): + path = get_data_path() / 'configs' + return path + + +def load_yaml(filename): + with open(filename, 'r') as stream: + try: + config = yaml.safe_load(stream) + print(config) + return config + except yaml.YAMLError as exc: + print(exc) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5a190bf398fec5aa2021f1959380ec54ea453f1c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py @@ -0,0 +1,409 @@ +from abc import ABC, abstractmethod + +import einops +import torch +from matplotlib import pyplot as plt + +from torch_robotics.torch_kinematics_tree.geometrics.utils import SE3_distance +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +import torch.nn.functional as Functional + + +class DistanceField(ABC): + def __init__(self, tensor_args=None): + self.tensor_args = tensor_args + + def distances(self): + pass + + def compute_collision(self): + pass + + @abstractmethod + def compute_distance(self, *args, **kwargs): + pass + + def compute_cost(self, q, link_pos, *args, **kwargs): + q_orig_shape = q.shape + link_orig_shape = link_pos.shape + if len(link_orig_shape) == 2: + h = 1 + b, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b d -> b 1 d") # add dimension of task space link + elif len(link_orig_shape) == 3: + h = 1 + b, t, d = link_orig_shape + elif len(link_orig_shape) == 4: # batch, horizon, num_links, 3 # position tensor + b, h, t, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d -> (b h) t d") + elif len(link_orig_shape) == 5: # batch, horizon, num_links, 4, 4 # homogeneous transform tensor + b, h, t, d, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d d -> (b h) t d d") + else: + raise NotImplementedError + + # link_tensor_pos + # position: (batch horizon) x num_links x 3 + cost = self.compute_costs_impl(q, link_pos, *args, **kwargs) + + if cost.ndim == 1: + cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + # if len(link_orig_shape) == 4 or len(link_orig_shape) == 5: + # cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + return cost + + @abstractmethod + def compute_costs_impl(self, *args, **kwargs): + pass + + @abstractmethod + def zero_grad(self): + pass + + +def interpolate_points_v1(points, num_interpolated_points): + # https://github.com/SamsungLabs/RAMP/blob/c3bd23b2c296c94cdd80d6575390fd96c4f83d83/mppi_planning/cost/collision_cost.py#L89 + """ + Interpolates points to have num_interpolated_points between each pair of points. + """ + points = Functional.interpolate(points.transpose(-2, -1), size=num_interpolated_points, mode='linear', align_corners=True).transpose(-2, -1) + return points + + +# Old implementation +def interpolate_points_v2(points, num_interpolate, link_interpolate_range): + if num_interpolate > 0: + link_dim = points.shape[:-1] + alpha = torch.linspace(0, 1, num_interpolate + 2).type_as(points)[1:num_interpolate + 1] + alpha = alpha.view(tuple([1] * len(link_dim) + [-1, 1])) # 1 x 1 x 1 x ... x num_interpolate x 1 + X = points[..., link_interpolate_range[0]:link_interpolate_range[1] + 1, :].unsqueeze(-2) # batch_dim x num_interp_link x 1 x 3 + X_diff = torch.diff(X, dim=-3) # batch_dim x (num_interp_link - 1) x 1 x 3 + X_interp = X[..., :-1, :, :] + X_diff * alpha # batch_dim x (num_interp_link - 1) x num_interpolate x 3 + points = torch.cat([points, X_interp.flatten(-3, -2)], dim=-2) # batch_dim x (num_link + (num_interp_link - 1) * num_interpolate) x 3 + return points + + +class EmbodimentDistanceFieldBase(DistanceField): + + def __init__(self, + robot, + link_idxs_for_collision_checking=None, + num_interpolated_points=30, + collision_margins=0., + cutoff_margin=0.001, + field_type='sdf', clamp_sdf=True, + interpolate_link_pos=False, + **kwargs): + super().__init__(**kwargs) + assert robot is not None, "You need to pass a robot instance to the embodiment distance fields" + self.robot = robot + self.link_idxs_for_collision_checking = link_idxs_for_collision_checking + self.num_interpolated_points = num_interpolated_points + self.collision_margins = collision_margins + self.cutoff_margin = cutoff_margin + self.field_type = field_type + self.clamp_sdf = clamp_sdf + self.interpolate_link_pos = interpolate_link_pos + + def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + if field_type is None: + field_type = self.field_type + if field_type == 'rbf': + return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + margin = self.collision_margins + self.cutoff_margin # The cutoff margin is the self_collision_margin_vector in the self collision distance field. + # returns all distances from each link to the environment + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + if self.clamp_sdf: + if self.clamp_sdf is False: + raise ValueError("Debug: clamp intended to be True.") + clamped_sdf = torch.relu(margin_minus_sdf) + else: + clamped_sdf = margin_minus_sdf + if len(clamped_sdf.shape) == 3: # cover the multiple objects case + clamped_sdf = clamped_sdf.max(-2)[0] + # sum over link points for gradient computation + return clamped_sdf.sum(-1) + elif field_type == 'occupancy': + return self.compute_embodiment_collision(q, link_pos, **kwargs) + # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # return (distances < margin).sum(-1) + else: + raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + # def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + # if field_type is None: + # field_type = self.field_type + # if field_type == 'rbf': + # return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + # elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + # margin = self.collision_margins + self.cutoff_margin + # # computes sdf of all links to all objects (or sdf map) + # margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + # if self.clamp_sdf: + # clamped_sdf = torch.relu(margin_minus_sdf) + # else: + # clamped_sdf = margin_minus_sdf + # + # if len(clamped_sdf.shape) == 3: # cover the multiple objects case + # clamped_sdf = clamped_sdf.max(-2)[0] + # + # # sum over link points for gradient computation + # return clamped_sdf.sum(-1) + # + # elif field_type == 'occupancy': + # return self.compute_embodiment_collision(q, link_pos, **kwargs) + # # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # # return (distances < margin).sum(-1) + # else: + # raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + def compute_costs_impl(self, q, link_pos, **kwargs): + # position link_pos tensor # batch x num_links x 3 + # interpolate to approximate link spheres + if self.robot.grasped_object is not None: + n_grasped_object_points = self.robot.grasped_object.n_base_points_for_collision + link_pos_robot = link_pos[..., :-n_grasped_object_points, :] + link_pos_grasped_object = link_pos[..., -n_grasped_object_points:, :] + else: + link_pos_robot = link_pos + + link_pos_robot = link_pos_robot[..., self.link_idxs_for_collision_checking, :] + if self.interpolate_link_pos: + # select the robot links used for collision checking + link_pos = interpolate_points_v1(link_pos_robot, self.num_interpolated_points) + + # stack collision points from grasped object + # these points do not need to be interpolated + if self.robot.grasped_object is not None: + link_pos = torch.cat((link_pos, link_pos_grasped_object), dim=-2) + + embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) + return embodiment_cost + + def compute_distance(self, q, link_pos, **kwargs): + raise NotImplementedError + link_pos = interpolate_points_v1(link_pos, self.num_interpolated_points) + self_distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs).min(-1)[0] # batch_dim + return self_distances + + def zero_grad(self): + pass + # raise NotImplementedError + + @abstractmethod + def compute_embodiment_rbf_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_collision(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionSelfField(EmbodimentDistanceFieldBase): + + def __init__(self, *args, idxs_links_distance_matrix=None, **kwargs): + super().__init__(*args, collision_margins=0., **kwargs) + self.idxs_links_distance_matrix = idxs_links_distance_matrix + self.idxs_links_distance_matrix_tuple = tuple(zip(*idxs_links_distance_matrix)) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.cutoff_margin) + rbf_distance = torch.exp(torch.square(link_pos.unsqueeze(-2) - + link_pos.unsqueeze(-3)).sum(-1) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor + """ + Computes the distances between all pairs of links. + The distances are computed as the L2 norm between the link positions, with the position of each link being + a point in 3D space. + """ + if link_pos.shape[-2] == 1: + # if there is only one link, the self distance is very large + # implementation guarantees gradient computation + return torch.abs(link_pos).sum(-1) * 1e9 + dist_mat = torch.linalg.norm(link_pos.unsqueeze(-2) - link_pos.unsqueeze(-3), dim=-1) # batch_dim x links x links + + # select lower triangular -- distance between link points + # lower_indices = torch.tril_indices(dist_mat.shape[-1], dist_mat.shape[-1], offset=-1).unbind() + # distances = dist_mat[..., lower_indices[0], lower_indices[1]] # batch_dim x (links * (links - 1) / 2) + + # select only distances between pairs of specified links + distances = dist_mat[..., self.idxs_links_distance_matrix_tuple[0], self.idxs_links_distance_matrix_tuple[1]] + + return distances + + def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor + margin = kwargs.get('margin', self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs) + any_self_collision = torch.any(distances < margin, dim=-1) + return any_self_collision + + +def reshape_q(q): + q_orig_shape = q.shape + if len(q_orig_shape) == 2: + h = 1 + b, d = q_orig_shape + elif len(q_orig_shape) == 3: + b, h, d = q_orig_shape + q = einops.rearrange(q, "b h d -> (b h) d") + else: + raise NotImplementedError + return q, q_orig_shape, b, h, d + + +# class CollisionSelfFieldWrapperSTORM(EmbodimentDistanceFieldBase): +# +# def __init__(self, robot, weights_fname, n_joints, *args, **kwargs): +# super().__init__(robot, *args, collision_margins=0., interpolate_link_pos=False, **kwargs) +# from storm_kit.geom.nn_model.robot_self_collision import RobotSelfCollisionNet +# self.robot_self_collision_net = RobotSelfCollisionNet(n_joints) +# self.robot_self_collision_net.load_weights(weights_fname, self.tensor_args) +# +# def compute_costs_impl(self, q, link_pos, **kwargs): +# embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) +# return embodiment_cost +# +# def compute_embodiment_rbf_distances(self, *args, **kwargs): # position tensor +# raise NotImplementedError +# +# def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# return sdf_self_collision +# +# def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# if len(q_orig_shape) == 3: +# sdf_self_collision = einops.rearrange(sdf_self_collision, "(b h) 1 -> b h", b=b, h=h) +# +# any_self_collision = sdf_self_collision < -0.05 # trained on 0.02 +# return any_self_collision + + +class CollisionObjectBase(EmbodimentDistanceFieldBase): + + def __init__(self, *args, link_margins_for_object_collision_checking_tensor=None, **kwargs): + super().__init__(*args, collision_margins=link_margins_for_object_collision_checking_tensor, **kwargs) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.margin) + rbf_distance = torch.exp(torch.square(self.object_signed_distances(link_pos, **kwargs)) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): + return self.object_signed_distances(link_pos, **kwargs) + + def compute_embodiment_collision(self, q, link_pos, **kwargs): + # position tensor + margin = kwargs.get('margin', self.collision_margins + self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + signed_distances = self.object_signed_distances(link_pos, **kwargs) + collisions = signed_distances < margin + # reduce over points (dim -1) and over objects (dim -2) + any_collision = torch.any(torch.any(collisions, dim=-1), dim=-1) + return any_collision + + @abstractmethod + def object_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionObjectDistanceField(CollisionObjectBase): + + def __init__(self, + *args, + df_obj_list_fn=None, + **kwargs): + super().__init__(*args, **kwargs) + self.df_obj_list_fn = df_obj_list_fn + + def object_signed_distances(self, link_pos, **kwargs): + if self.df_obj_list_fn is None: + return torch.inf + df_obj_list = self.df_obj_list_fn() + link_dim = link_pos.shape[:-1] + link_pos = link_pos.reshape(-1, link_pos.shape[-1]) # flatten batch_dim and links + dfs = [] + for df in df_obj_list: + dfs.append(df.compute_signed_distance(link_pos).view(link_dim)) # df() returns batch_dim x links + return torch.stack(dfs, dim=-2) # batch_dim x num_sdfs x links + + +class CollisionWorkspaceBoundariesDistanceField(CollisionObjectBase): + + def __init__(self, *args, ws_bounds_min=None, ws_bounds_max=None, **kwargs): + super().__init__(*args, **kwargs) + self.ws_min = ws_bounds_min + self.ws_max = ws_bounds_max + + def object_signed_distances(self, link_pos, **kwargs): + signed_distances_bounds_min = link_pos - self.ws_min + signed_distances_bounds_min = torch.sign(signed_distances_bounds_min) * torch.abs(signed_distances_bounds_min) + signed_distances_bounds_max = self.ws_max - link_pos + signed_distances_bounds_max = torch.sign(signed_distances_bounds_max) * torch.abs(signed_distances_bounds_max) + signed_distances_bounds = torch.cat((signed_distances_bounds_min, signed_distances_bounds_max), dim=-1) + return signed_distances_bounds.transpose(-2, -1) # batch_dim x num_sdfs x links + + +class EESE3DistanceField(DistanceField): + + def __init__(self, target_H, w_pos=1., w_rot=1., square=True, **kwargs): + super().__init__(**kwargs) + self.target_H = target_H + self.square = square + self.w_pos = w_pos + self.w_rot = w_rot + + def update_target(self, target_H): + self.target_H = target_H + + def compute_distance(self, link_tensor): + # homogeneous transformation link_tensor # batch x num_links x 4 x 4 + # -1: get EE as last link # TODO - get EE from its name id + return SE3_distance(link_tensor[..., -1, :, :], self.target_H, w_pos=self.w_pos, w_rot=self.w_rot) + + def compute_costs_impl(self, q, link_tensor, **kwargs): # position tensor + dist = self.compute_distance(link_tensor).squeeze() + if self.square: + dist = torch.square(dist) + return dist + + def zero_grad(self): + raise NotImplementedError + +if __name__ == '__main__': + raise NotImplementedError + import time + mesh_file = 'models/chair.obj' + mesh = MeshDistanceField(mesh_file) + bounds = np.array(mesh.mesh.bounds) + print(np.linalg.norm(bounds[1] - bounds[0])) + print(mesh.mesh.centroid) + points = torch.rand(100, 3) + link_tensor = torch.rand(100, 10, 4, 4) + start = time.time() + distances = mesh.compute_distance(points) + costs = mesh.compute_cost(link_tensor) + print(time.time() - start) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/seed.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/seed.py new file mode 100644 index 0000000000000000000000000000000000000000..00e64763d2182e7cfe341f3009d6ab866bad958d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/seed.py @@ -0,0 +1,21 @@ +import random + +import numpy as np +import torch + + +def fix_random_seed(seed): + random.seed(seed) + + try: + np.random.seed(seed) + except NameError: + pass + + try: + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + # https://pytorch.org/docs/stable/notes/randomness.html#cuda-convolution-benchmarking + torch.backends.cudnn.benchmark = False + except NameError: + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py new file mode 100644 index 0000000000000000000000000000000000000000..85c113b5c1e0b7665b596f7b9d448ba993aa7f68 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py @@ -0,0 +1,109 @@ +# Adapted from https://github.com/brouberol/contexttimer +from time import perf_counter + +import torch + + +class TimerCUDA(object): + """ A timer as a context manager + + Wraps around a timer. A custom timer can be passed + to the constructor. The default timer is timeit.default_timer. + + Note that the latter measures wall clock time, not CPU time! + On Unix systems, it corresponds to time.time. + On Windows systems, it corresponds to time.clock. + + Keyword arguments: + output -- if True, print output after exiting context. + if callable, pass output to callable. + format -- str.format string to be used for output; default "took {} seconds" + prefix -- string to prepend (plus a space) to output + For convenience, if you only specify this, output defaults to True. + """ + + def __init__(self, timer=perf_counter, + output=None, fmt="took {:.3f} seconds", prefix="", + use_cuda_events=False + ): + self.timer = timer + self.output = output + self.fmt = fmt + self.prefix = prefix + self.start_time = None + self.end = None + + self.sync_cuda = True if torch.cuda.is_available() else False + if use_cuda_events: + assert self.sync_cuda, "CUDA must be available when using CUDA events" + self.use_cuda_events = use_cuda_events + self.factor_cuda_events = 1./1000. # transform to seconds + self.start_event = None + self.end_event = None + + def __call__(self): + """ Return the current time """ + if self.use_cuda_events: + self.end_event.record() + torch.cuda.synchronize() + return self.start_event.elapsed_time(self.end_event) * self.factor_cuda_events + else: + if self.sync_cuda: + torch.cuda.synchronize() + return self.timer() + + def __enter__(self): + """ Set the start time """ + if self.use_cuda_events: + self.start_event = torch.cuda.Event(enable_timing=True) + self.end_event = torch.cuda.Event(enable_timing=True) + self.start_event.record() + else: + self.start_time = self() + + self.start = self() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """ Set the end time """ + self.end = self() + + if self.prefix and self.output is None: + self.output = True + + if self.output: + output = " ".join([self.prefix, self.fmt.format(self.elapsed)]) + if callable(self.output): + self.output(output) + else: + print(output) + + def __str__(self): + return f'{self.elapsed:.3f}' + + @property + def elapsed(self): + """ Return the current elapsed time since start + + If the `elapsed` property is called in the context manager scope, + the elapsed time between start and property access is returned. + However, if it is accessed outside of the context manager scope, + it returns the elapsed time between entering and exiting the scope. + + The `elapsed` property can thus be accessed at different points within + the context manager scope, to time different parts of the block. + + """ + if self.end is None: + # if elapsed is called in the context manager scope + if self.use_cuda_events: + self() + else: + return self() - self.start + else: + # if elapsed is called out of the context manager scope + if self.use_cuda_events: + return self.end + else: + return self.end - self.start + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..11d79a18e4e6d36d3f79548a4bf0acfc3df2514b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py @@ -0,0 +1,162 @@ +import collections +import random +from typing import List + +import numpy as np +import torch + + +def get_torch_device(device='cuda'): + if 'cuda' in device and torch.cuda.is_available(): + device = 'cuda' + elif 'mps' in device: + device = 'mps' + else: + device = 'cpu' + return torch.device(device) + + +DEFAULT_TENSOR_ARGS = {'device': get_torch_device('cuda'), 'dtype': torch.float32} + +def dict_to_device(ob, device): + if isinstance(ob, collections.Mapping): + return {k: dict_to_device(v, device) for k, v in ob.items()} + else: + return ob.to(device) + + +def to_numpy(x, dtype=np.float32, clone=False): + if torch.is_tensor(x): + x = x.detach().cpu().numpy().astype(dtype) + return x + if isinstance(x, np.ndarray): + return x.astype(dtype, copy=clone) + return np.array(x).astype(dtype) + + +def to_torch(x, device='cpu', dtype=torch.float, requires_grad=False, clone=False): + if torch.is_tensor(x): + if clone: + x = x.clone() + return x.to(device=device, dtype=dtype) + return torch.tensor(x, dtype=dtype, device=device, requires_grad=requires_grad) + + +def to_torch_2d_min(variable): + if isinstance(variable, np.ndarray) or isinstance(variable, List): + tensor_var = to_torch(variable) + else: + tensor_var = variable + if len(tensor_var.shape) == 1: + return tensor_var.unsqueeze(0) + else: + return tensor_var + + +def freeze_torch_model_params(model): + for param in model.parameters(): + param.requires_grad = False + # If the model is frozen we do not save it again, since the parameters did not change + model.is_frozen = True + + +def batch_cov(points): + B, N, D = points.size() + mean = points.mean(dim=1).unsqueeze(1) + diffs = (points - mean).reshape(B * N, D) + prods = torch.bmm(diffs.unsqueeze(2), diffs.unsqueeze(1)).reshape(B, N, D, D) + bcov = prods.sum(dim=1) + if N > 1: + bcov = bcov / (N - 1) # Unbiased estimate + else: + bcov = bcov / N + return bcov # (B, D, D) + + +def batch_trace(covs): + return covs.diagonal(offset=0, dim1=-1, dim2=-2).sum(-1) + + +@torch.jit.script +def tensor_linspace_v1(start: torch.Tensor, end: torch.Tensor, steps: int = 10): + # https://github.com/zhaobozb/layout2im/blob/master/models/bilinear.py#L246 + """ + Vectorized version of torch.linspace. + Inputs: + - start: Tensor of any shape + - end: Tensor of the same shape as start + - steps: Integer + Returns: + - out: Tensor of shape start.size() + (steps,), such that + out.select(-1, 0) == start, out.select(-1, -1) == end, + and the other elements of out linearly interpolate between + start and end. + """ + assert start.size() == end.size() + view_size = start.size() + (1,) + w_size = (1,) * start.dim() + (steps,) + out_size = start.size() + (steps,) + + start_w = torch.linspace(1, 0, steps=steps).to(start) + start_w = start_w.view(w_size).expand(out_size) + end_w = torch.linspace(0, 1, steps=steps).to(start) + end_w = end_w.view(w_size).expand(out_size) + + start = start.contiguous().view(view_size).expand(out_size) + end = end.contiguous().view(view_size).expand(out_size) + + out = start_w * start + end_w * end + return out + + +@torch.jit.script +def torch_linspace_v2(start: torch.Tensor, stop: torch.Tensor, num: int): + """ + Creates a tensor of shape [num, *start.shape] whose values are evenly spaced from start to end, inclusive. + Replicates but the multi-dimensional bahaviour of numpy.linspace in PyTorch. + """ + # create a tensor of 'num' steps from 0 to 1 + steps = torch.arange(num, dtype=start.dtype, device=start.device) / (num - 1) + + # reshape the 'steps' tensor to [-1, *([1]*start.ndim)] to allow for broadcastings + # - using 'steps.reshape([-1, *([1]*start.ndim)])' would be nice here but torchscript + # "cannot statically infer the expected size of a list in this contex", hence the code below + for i in range(start.ndim): + steps = steps.unsqueeze(-1) + + # the output starts at 'start' and increments until 'stop' in each dimension + out = start[None] + steps * (stop - start)[None] + + return out + + +# @torch.jit.script +def batched_weighted_dot_prod(x: torch.Tensor, M: torch.Tensor, y: torch.Tensor, with_einsum: bool = False): + """ + Computes batched version of weighted dot product (distance) x.T @ M @ x + """ + assert x.ndim >= 2 + if with_einsum: + My = M.unsqueeze(0) @ y + r = torch.einsum('...ij,...ij->...j', x, My) + else: + r = x.transpose(-2, -1) @ M.unsqueeze(0) @ x + r = r.diagonal(dim1=-2, dim2=-1) + return r + + +def is_positive_semi_definite(mat): + # checks if mat is a positive semi-definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real >= 0).all()) + +def is_positive_definite(mat): + # checks if mat is a positive definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real > 0).all()) + + +def torch_intersect_1d(a, b): + a_cat_b, counts = torch.cat([a, b]).unique(return_counts=True) + intersection = a_cat_b[torch.where(counts.gt(1))] + return intersection + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f90ff0ef592ed04ba7b27c18bea03ced9b708bd9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py @@ -0,0 +1,367 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation + +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.tasks.tasks_ensemble import PlanningTaskEnsemble +from .planning_visualizer import create_fig_and_axes +import matplotlib.collections as mcoll + + +class PlanningVisualizerEnsemble: + + def __init__(self, + task_ensmeble: PlanningTaskEnsemble, + planner=None): + self.task_ensmeble = task_ensmeble + self.env = self.task_ensmeble.env + self.robot = self.task_ensmeble.tasks[0].robot + self.planner = planner + + self.colors = {'collision': 'black', 'free': 'orange'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, + return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), **kwargs) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + print(qs.shape) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else + self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color] * len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps[ + 'free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S - 1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip( + [(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # # Start and goal + # if pos_start_state is not None: + # ax[0].scatter(0, pos_start_state[i], color='green') + # if vel_start_state is not None: + # ax[1].scatter(0, vel_start_state[i], color='green') + # if pos_goal_state is not None: + # ax[0].scatter(H - 1, pos_goal_state[i], color='purple') + # if vel_goal_state is not None: + # ax[1].scatter(H - 1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S - 1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames - 1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=90) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f2353b802d06d9ebd848800be45d27e5b1f82db6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py @@ -0,0 +1,397 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation +import matplotlib.collections as mcoll + +# Project includes. +from torch_robotics.torch_utils.torch_utils import to_numpy +from mmd.plotting.base import remove_borders, remove_axes_labels_ticks + +def create_fig_and_axes(dim=2): + fig = plt.figure(layout='tight') + if dim == 3: + ax = fig.add_subplot(projection='3d') + else: + ax = fig.add_subplot() + + return fig, ax + + +class PlanningVisualizer: + + def __init__(self, task=None, planner=None): + self.task = task + self.env = self.task.env + self.robot = self.task.robot + self.planner = planner + + # self.colors = {'collision': 'black', 'free': 'cornsilk'} + self.colors = {'collision': 'black', 'free': 'lightcyan'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, + show_robot_in_image=False, constraints_l=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, constraints_l=constraints_l, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), constraints_l=constraints_l, **kwargs) + + # Add the robot with tail at the third-way time step. + if show_robot_in_image: + t_mid = trajs.shape[1] // 5 + qs = trajs[:, t_mid, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + tail_length = min(5, t_mid) + for di in range(tail_length): + q_tail.append(trajs[:, t_mid - di, :].squeeze()) + kwargs['q_tail'] = q_tail + # Random color from colormap for the robot and tail. From cmap tab20(do c optionally). + color = plt.cm.tab20(np.random.randint(0, 20)) + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color]*len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + # if start_state is not None: + # self.robot.render(ax, start_state, color='green', cmap='Greens') + # if goal_state is not None: + # self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + # Remove the axes, frame, and title. TEST TEST TEST. + remove_borders(ax) + remove_axes_labels_ticks(ax) + ax.set_title('') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S-1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip([(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # Start and goal + if pos_start_state is not None: + ax[0].scatter(0, pos_start_state[i], color='green') + if vel_start_state is not None: + ax[1].scatter(0, vel_start_state[i], color='green') + if pos_goal_state is not None: + ax[0].scatter(H-1, pos_goal_state[i], color='purple') + if vel_goal_state is not None: + ax[1].scatter(H-1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S-1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames -1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=100) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..eaae21e271ddf8302631a2ea2e63f52d9bb83218 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py @@ -0,0 +1,32 @@ +from copy import copy + +import torch + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def plot_coordinate_frame(ax, frame, arrow_length=0.1, arrow_alpha=1.0, arrow_linewidth=1.0, tensor_args=None): + target_pos_np = to_numpy(frame.translation).squeeze() + frame_target_no_trans = copy(frame) + frame_target_no_trans.set_translation(torch.zeros_like(frame.translation)) + + x_basis = torch.tensor([1, 0, 0], **tensor_args) + y_basis = torch.tensor([0, 1, 0], **tensor_args) + z_basis = torch.tensor([0, 0, 1], **tensor_args) + + x_axis_target = to_numpy(frame_target_no_trans.transform_point(x_basis).squeeze()) + y_axis_target = to_numpy(frame_target_no_trans.transform_point(y_basis).squeeze()) + z_axis_target = to_numpy(frame_target_no_trans.transform_point(z_basis).squeeze()) + + # x-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + x_axis_target[0], x_axis_target[1], x_axis_target[2], + length=arrow_length, normalize=True, color='red', alpha=arrow_alpha, linewidth=arrow_linewidth) + # y-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + y_axis_target[0], y_axis_target[1], y_axis_target[2], + length=arrow_length, normalize=True, color='green', alpha=arrow_alpha, linewidth=arrow_linewidth) + # z-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + z_axis_target[0], z_axis_target[1], z_axis_target[2], + length=arrow_length, normalize=True, color='blue', alpha=arrow_alpha, linewidth=arrow_linewidth) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/environment.yml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/environment.yml new file mode 100644 index 0000000000000000000000000000000000000000..4654c7ed2bdedb2228083f03dd2e2764462edfa4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/environment.yml @@ -0,0 +1,15 @@ +name: pcdmmd +channels: + - pytorch + - conda-forge +dependencies: + - python=3.8 + - pip + - patchelf + - pip: + - pyyaml + - matplotlib + - setuptools==70.2.0 + - ml-collections==0.1.1 + - gdown + - tqdm diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/plan_pcd.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/plan_pcd.py new file mode 100644 index 0000000000000000000000000000000000000000..1dfd9d347a298d9c5783a5680a643676a220cce6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/plan_pcd.py @@ -0,0 +1,316 @@ +""" +Plans OUR held-out test instances with PCD and writes a paths .npz for +scripts/repro/score_paths.py. + + conda activate pcd + cd baselines/pcd/multi_robot + python plan_pcd.py --instances --tags room-3 --out paths_pcd.npz + +PCD generates its own start/goal configurations via +generate_start_goal_positions(..., method='random_in_env'), which are its +instances, not ours. We monkey-patch that function to hand back the exact +instances every other method in the table receives. Patching rather than editing +their file keeps this a configuration choice, not a change to their method. + +Two things PCD gets right that MDOC did not, so there is less to map here: + - its configs already name models EnvXxx2D-RobotPlanarDisk, the same + convention our from-scratch models use + - robot.planar_disk_radius is 0.05, matching our evaluator + +run_planning_experiment() returns nothing (it saves to disk), so we capture the +planned paths by wrapping evaluate_and_save_results and stashing its +all_paths_cpu argument on the way through. +""" +import os +import sys +import time +import copy +import argparse + +import numpy as np +import torch +import yaml + +REPO = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, REPO) +os.environ.setdefault("HDF5_USE_FILE_LOCKING", "FALSE") + +import scripts.inference.inference_pcdiff as ipc + +# scenario -> (config file, env class used for the random-in-env fallback, model id) +SCENARIO_CFG = { + 'empty': ('configs/pcdiff_empty.yaml', 'EnvEmptyNoWait2D', 'EnvEmptyNoWait2D-RobotPlanarDisk'), + 'basic': ('configs/pcdiff_empty.yaml', 'EnvEmptyNoWait2D', 'EnvEmptyNoWait2D-RobotPlanarDisk'), + 'dense': ('configs/pcdiff_empty.yaml', 'EnvEmptyNoWait2D', 'EnvEmptyNoWait2D-RobotPlanarDisk'), + 'corridor': ('configs/pcdiff_conveyor.yaml', 'EnvConveyor2D', 'EnvConveyor2D-RobotPlanarDisk'), + 'room': ('configs/pcdiff_highway.yaml', 'EnvHighways2D', 'EnvHighways2D-RobotPlanarDisk'), + 'shelf': ('configs/pcdiff_dropregion.yaml', 'EnvDropRegion2D', 'EnvDropRegion2D-RobotPlanarDisk'), +} + + +# Per-map velocity limits, taken from the papers themselves. DiRecT's MRMP +# figures are captioned "Empty with agent velocities limited to 0.675", +# "Highways ... 0.647", "Conveyor ... 1.21", "Drop-Region ... 0.928", and +# launch_pcdiff.py's vmaxs sweeps open with those same four numbers. +# The committed yaml ships vel_max: 0.8 for every map except empty -- a stale +# placeholder. Using 0.8 on Conveyor caps velocity at 66% of what the paper +# allows and on Drop-Region at 86%, making both strictly harder than published. +PAPER_VEL_MAX = { + 'empty': 0.675, 'basic': 0.675, 'dense': 0.675, + 'corridor': 1.21, # Conveyor + 'room': 0.647, # Highways + 'shelf': 0.928, # Drop-Region +} + +_captured = {} + + +def _install_capture(): + """Capture the paths PCD ITSELF SELECTS, not its raw sample batch. + + all_paths_cpu (the 4th argument) is the raw [(agents*batch), inits, H, D] + diffusion output -- 128 samples per agent, unselected. Scoring sample 0 of + that batch is wrong and measurably so: its start error is 0.0000 (starts are + hard-conditioned) but its GOAL error is 0.38, i.e. sample 0 does not reach + the goal at all, which scored PCD at 0.00 across the board. + + evaluate_and_save_results RETURNS `paths_l`, documented as "The selected + final path for each agent". That is PCD's own choice among its samples and + is the fair thing to score, so we capture the return value instead. + """ + orig = ipc.evaluate_and_save_results + + def wrapper(config, mock_trial_cfg, results_dir, all_paths_cpu, *a, **kw): + _captured['raw'] = all_paths_cpu.detach().cpu().clone() + try: + out = orig(config, mock_trial_cfg, results_dir, all_paths_cpu, *a, **kw) + except Exception as e: + # their post-processing/rendering must not cost us the paths + print(f' [pcd] evaluate_and_save_results raised {type(e).__name__}: {e}') + return None, None + # documented as -> (paths_l, trial_success_status) + if isinstance(out, tuple) and len(out) >= 1 and out[0] is not None: + _captured['selected'] = out[0] + return out + + ipc.evaluate_and_save_results = wrapper + + +def _install_instances(starts, goals, tensor_args): + """Replace PCD's start/goal sampler with our fixed instances.""" + n_inst, num_agents, _ = starts.shape + + def gen(config, targs, batch_size=1, method=None): + k = min(batch_size, n_inst) + start_ls = [[torch.tensor(starts[i][a], **targs) for a in range(num_agents)] + for i in range(k)] + goal_ls = [[torch.tensor(goals[i][a], **targs) for a in range(num_agents)] + for i in range(k)] + return start_ls, goal_ls + + ipc.generate_start_goal_positions = gen + + +def plan_tag(tag, inst, args): + scenario = tag.rsplit('-', 1)[0] + if scenario not in SCENARIO_CFG: + raise KeyError(f'no PCD config mapping for scenario {scenario!r}') + cfg_path, env_class, model_id = SCENARIO_CFG[scenario] + + starts = inst[f'{tag}/starts'] + goals = inst[f'{tag}/goals'] + n_inst, num_agents, _ = starts.shape + + with open(os.path.join(REPO, cfg_path)) as f: + config = yaml.safe_load(f) + + config['experiment']['num_agents'] = num_agents + config['experiment']['n_inits'] = n_inst + config['experiment']['seed'] = args.seed + config['experiment']['runtime_limit'] = args.runtime_limit + config['experiment']['render_animation'] = False + config['environment']['global_model_ids'] = [[model_id]] + config['environment']['start_goal_config']['env_class_for_random'] = env_class + config['system']['device'] = args.device + # base_dir 'auto' resolves to Path(inference_pcdiff.py).parent.parent.parent, + # i.e. baselines/pcd -- one level ABOVE multi_robot, where data_trajectories/ + # and data_trained_models/ actually live. Pin it to the repo root we run from. + config['system']['base_dir'] = REPO + # apply the paper's velocity limit for this map + if not args.keep_yaml_velmax: + vm = PAPER_VEL_MAX.get(scenario) + pj_blk = config.get('planner', {}).get('projection') + if vm is not None and pj_blk and 'project_params' in pj_blk: + old = pj_blk['project_params'].get('vel_max') + pj_blk['project_params']['vel_max'] = vm + print(f' [{scenario}] vel_max {old} -> {vm} (paper value)') + + # The configs/*.yaml projection block is STALE relative to what PCD actually + # runs: launch_pcdiff.py overrides it with PROJECTOR_PARAMS at runtime + # (rho 10.0 / max_iter 1000 / tol 3e-6 vs the yaml's 6.0 / 500 / 1e-5). + # With the yaml values the ADMM projection logs "did NOT converge within 500 + # iterations" on every instance and drags trajectory endpoints off the goal + # -- measured goal error 0.31 even for the best of 128 samples. Use their + # launcher's parameters, which are the ones behind their reported results. + proj = config.get('planner', {}).get('projection') if args.projector != 'none' else None + if proj and not args.keep_yaml_projection: + proj['projector'].update({ + 'rho': 10.0, 'max_iter': 1000, 'tol': 3.0e-6, + 'decomp': 'lu', 'convergence_check': True, 'check_period': 100, + }) + print(f" [{tag}] projection: using launch_pcdiff.py PROJECTOR_PARAMS " + f"(rho=10.0, max_iter=1000, tol=3e-6)") + + # Mirror launch_pcdiff.py's guider/projector branches. The committed yaml + # pins ONE combination; their launcher sweeps GUIDERS x PROJECTORS and those + # sweeps are what their reported results come from. + if args.guider in ('coupling', 'coupling_ps'): + config['planner']['single_agent']['planner_alg'] = 'coupling' + if args.cost_func_key: + config['planner']['single_agent']['cost_func_key'] = args.cost_func_key + if args.projector == 'none': + config['planner']['projection'] = None # inference reads .get('projection', None) + print(f" [pcd] projector disabled (guider={args.guider})") + else: + print(f" [pcd] guider={args.guider} projector={args.projector}") + + tensor_args = {'device': torch.device(args.device), 'dtype': torch.float32} + _install_instances(starts, goals, tensor_args) + _install_capture() + _captured.pop('raw', None) + _captured.pop('selected', None) + + t0 = time.time() + try: + ipc.run_planning_experiment(config, comments=f'harness:{tag}', trial_number=0) + except Exception as e: + print(f' [{tag}] run_planning_experiment raised {type(e).__name__}: {e}') + dt = time.time() - t0 + + raw = _captured.get('raw') + if raw is None: + print(f' [{tag}] no paths captured -- all {n_inst} instances recorded as failures') + arr = np.repeat(starts[:, None, :, :], 2, axis=1) + return arr, np.zeros(n_inst, bool), np.full(n_inst, dt / max(n_inst, 1)), ['NO_PATHS'] * n_inst, None + + # PCD does NOT select a path. evaluate_and_save_results returns + # (trial_success_status, processed_data) -- its docstring claims + # (paths_l, status) but the call site at inference_pcdiff.py:517 proves + # otherwise, and processed_data holds collision masks, not trajectories. + # PCD evaluates its whole batch. So we take the raw batch and index sample 0, + # which is exactly the convention evaluate_smd_all.py:279 already uses for + # the SMD/MMD columns (sample0_feasible, not best-of-N). + # + # Layout follows PCD's own einops: '(p b) i h d -> b i p h d', p=num_agents. + NB, I, H, D = raw.shape + # A scenario whose planner partially failed (e.g. "Start or goal states are + # invalid") returns a batch that does not factor as agents*samples. Reshaping + # it silently drops data or raises, and an uncaught raise here killed the + # whole 19-scenario run at scenario 10. Record the tag as failed instead. + if num_agents == 0 or NB % num_agents != 0: + print(f' [{tag}] BAD BATCH {raw.shape} not divisible by {num_agents} agents ' + f'-- recording all {n_inst} instances as failures') + arr = np.repeat(starts[:, None, :, :], max(H, 2), axis=1) + return (arr, np.zeros(n_inst, bool), + np.full(n_inst, dt / max(n_inst, 1)), ['BAD_BATCH'] * n_inst, None) + B = NB // num_agents + t = raw.reshape(num_agents, B, I, H, D) # (p, b, i, h, d) + + # Diagnostic: does ANY sample reach the goal? If min goal error over the + # whole batch is large, goal conditioning is misconfigured and no sample + # index would be fair to score. + gl_t = torch.as_tensor(goals, dtype=t.dtype) # (I, N, 2) + ends = t[..., -1, :2].permute(2, 1, 0, 3) # (i, b, p, 2) + gerr = (ends - gl_t[:, None, :, :]).abs().amax(dim=(-1, -2)) # (i, b) + print(f' [{tag}] goal error over {B} samples: min={gerr.min():.4f} ' + f'median={gerr.median():.4f} sample0={gerr[:, 0].mean():.4f}') + + arr = t[:, 0].permute(1, 2, 0, 3)[..., :2].numpy().astype(np.float64) # (I,H,N,2) + # Full batch, (I, B, H, N, 2). PCD/DiRecT report best-of-N + # (analyzing_pcd.py:233: "whether at least one batch is collision-free"), + # NOT sample 0, which is SMD's convention. Save both so the scorer can + # report each method under its own paper's rule and under the shared one. + batch = t.permute(2, 1, 3, 0, 4)[..., :2].numpy().astype(np.float64) + planned = np.ones(I, bool) + runtime = np.full(I, dt / max(I, 1)) + status = ['SAMPLE0'] * I + if I < n_inst: + pad = n_inst - I + arr = np.concatenate([arr, np.repeat(starts[I:][:, None, :, :], H, axis=1)], axis=0) + planned = np.concatenate([planned, np.zeros(pad, bool)]) + runtime = np.concatenate([runtime, np.full(pad, np.nan)]) + status += ['MISSING'] * pad + return arr, planned, runtime, status, batch + + # Fallback: raw batch [N*B, I, H, D]. Sample 0 of this batch does NOT reach + # the goal (measured goal error 0.38) -- unselected diffusion output. Last + # resort only; it is not a fair score for PCD. + print(f' [{tag}] WARNING falling back to RAW sample 0 {tuple(raw.shape)} -- not PCD-selected') + NB, I, H, D = raw.shape + # A scenario whose planner partially failed (e.g. "Start or goal states are + # invalid") returns a batch that does not factor as agents*samples. Reshaping + # it silently drops data or raises, and an uncaught raise here killed the + # whole 19-scenario run at scenario 10. Record the tag as failed instead. + if num_agents == 0 or NB % num_agents != 0: + print(f' [{tag}] BAD BATCH {raw.shape} not divisible by {num_agents} agents ' + f'-- recording all {n_inst} instances as failures') + arr = np.repeat(starts[:, None, :, :], max(H, 2), axis=1) + return (arr, np.zeros(n_inst, bool), + np.full(n_inst, dt / max(n_inst, 1)), ['BAD_BATCH'] * n_inst, None) + B = NB // num_agents + p = raw.reshape(num_agents, B, I, H, D)[:, 0] # first sample per agent -> (N, I, H, D) + p = p.permute(1, 2, 0, 3)[..., :2] # (I, H, N, 2) + arr = p.numpy().astype(np.float64) + + planned = np.ones(I, bool) + runtime = np.full(I, dt / max(I, 1)) + status = ['PLANNED'] * I + if I < n_inst: # pad missing instances as failures + pad = n_inst - I + arr = np.concatenate([arr, np.repeat(starts[I:][:, None, :, :], H, axis=1)], axis=0) + planned = np.concatenate([planned, np.zeros(pad, bool)]) + runtime = np.concatenate([runtime, np.full(pad, np.nan)]) + status += ['MISSING'] * pad + return arr, planned, runtime, status, batch + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--instances', required=True) + ap.add_argument('--out', required=True) + ap.add_argument('--tags', default=None) + ap.add_argument('--runtime-limit', type=int, default=180) + ap.add_argument('--seed', type=int, default=0) + ap.add_argument('--keep-yaml-projection', action='store_true', + help='use the stale yaml projection params instead of the launcher ones') + ap.add_argument('--keep-yaml-velmax', action='store_true', + help="use the yaml's stale vel_max instead of the paper's") + ap.add_argument('--guider', default='vanilla', + choices=['vanilla', 'coupling', 'coupling_ps']) + ap.add_argument('--projector', default='max_vel_admm', + choices=['none', 'max_vel_admm']) + ap.add_argument('--cost_func_key', default=None) + ap.add_argument('--device', default='cuda:0' if torch.cuda.is_available() else 'cpu') + a = ap.parse_args() + + inst = np.load(a.instances, allow_pickle=True) + tags = a.tags.split(',') if a.tags else sorted({k.split('/')[0] for k in inst.files if '/' in k}) + + blob = dict(np.load(a.out, allow_pickle=True)) if os.path.exists(a.out) else {} + for tag in tags: + if f'{tag}/paths' in blob: + print(f'skip {tag} (already done)'); continue + print(f'=== {tag} [PCD] ===') + paths, planned, runtime, status, batch = plan_tag(tag, inst, a) + blob[f'{tag}/paths'] = paths + blob[f'{tag}/planned'] = planned + blob[f'{tag}/runtime'] = runtime + blob[f'{tag}/status'] = np.array(status, dtype=object) + if batch is not None: + blob[f'{tag}/paths_batch'] = batch + np.savez(a.out, **blob) + print(f' -> {tag}: {planned.sum()}/{len(planned)} planned, saved') + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..bead1e0ee9b4f23b17ab64461aaadd3e020bceb9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/requirements.txt @@ -0,0 +1,37 @@ +numpy +tensorboard +tqdm~=4.43.0 +ConfigArgParse +matplotlib~=3.7.5 +plotly +shapely +scipy +setuptools~=70.2.0 +scikit-learn +pillow +pyyaml~=6.0.2 +pandas +joblib +sentence_transformers +wandb +trimesh +mesh_to_sdf +seaborn +tabulate +einops~=0.6.1 +torchdiffeq +torch-summary +pygame +gitpython +scikit-video +opencv-python~=4.10.0.84 +moviepy +hydra~=2.5 +urdfpy~=0.0.22 +gdown +einops + +#torch~=2.0.0+cu118 +networkx~=2.2 +cholespy~=2.0.0 +mmd~=0.1 \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..44620b666733d75262f6935cacc8c2a89710ba3b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/multi_robot/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup, find_packages +from codecs import open +from os import path + + +from mmd import __version__ + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + + +setup(name='mmd', + version=__version__, + description='Projected Coupled Diffusion (PCD) implementation based on MMD', + author='Hao Luan', + author_email='haoluan@comp.nus.edu.sg', + packages=find_packages(where=''), + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..951ae9f0fca2c287b54482f5244e2324b2500d2b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/LICENSE @@ -0,0 +1,49 @@ +MIT License + +Copyright (c) 2025 Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Large portions of the included code are from/modified-from the following source: https://github.com/clear-nus/ltldog. +We repeat their license below. + +Copyright (c) 2020 Phil Wang +Copyright (c) 2022 Michael Janner and Yilun Du +Copyright (c) 2022 Ziwei XU +Copyright (c) 2023 Columbia Artificial Intelligence and Robotics Lab +Copyright (c) 2024 Zeyu Feng and Hao Luan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/README.md b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ac8fb9423248549dcf4e2a6c5f15723bbdf961fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/README.md @@ -0,0 +1,112 @@ +# Diffusion-Policy-based PCD Implementation + +**Table of Contents** +- [Diffusion-Policy-based PCD Implementation](#ltldog-diffusion-policy-based-implementation) + - [Installation](#installation) + - [Play with the Env (Optional)](#play-with-the-env-optional) + - [Pretrained Weights](#pretrained-weights) + - [Inference](#inference-with-pcd) + - [Result Analysis](#result-analysis) + - [References](#references) + + +This implementaion builds upon [Diffusion Policy](https://github.com/real-stanford/diffusion_policy)[1] and [LTLDoG](https://github.com/clear-nus/ltldog)[2]. + + +## Installation + +1. Create a Conda environment with `conda_environment.yaml` (we also recommend Mambaforge) + + ```sh + mamba env create -f conda_environment.yaml + # Or just use conda + # conda env create -f conda_environment.yaml + ``` + + Downgrade `huggingface-hub` for compatibility + + ```sh + pip install "huggingface_hub<0.14.0" + ``` + +2. Install `diffusion_policy` as a package in the newly created environment + + ```sh + mamba activate pcdp + pip install -e . + ``` + + +## Play with the PushT Task (Optional) + +The demo script inherits from Diffusion Policy. +Familiarize yourself with the environment by + +```sh +python demo_pusht.py --help +``` + +## Pretrained Model Weights + +- Pretained weights are from LTLDoG [1]; check its [official code release](https://github.com/clear-nus/ltldog) to download the weights for the PushT task. +- Move the checkpoint(s) to `data/pretrained/diffusion` for inference. +- See [Inference Configs](#inference-configs) for configuring the weights path. + +### Configs + +The configuration setting pipeline inherits from Diffusion Policy [1] and LTLDoG [2]. + + +## Inference with PCD + +Inference scripts are under `scripts/`: +- We provide a sequential executing scripts that could be used to run multiple experiments (for parameter sweeps). +- Run `eval_H16_pusht_seq.py` with proper args for inference. ***Set up hyperparameters in the script first***. +- Calling example: + ```bash + python scripts/eval_H16_seq.py -r --gpu-id 0 --guider coupling --cost-func-key "dpp" --projector max_vel_admm -m "custom_comments" + ``` + - Args: + - `--guider` denotes the type of coupling costs and should be one of `{'vanilla', 'coupling', 'coupling_ps'}`, corresponding to no coupling at all, standard coupling costs, or posteior sampling cost variants. + - `--cost-func-key` should be in `{'dpp', 'sum_log_l2'}`. + - `--projector` should be one of `{'none', 'max_vel_admm'}`. + - `-r` means running with the *Cartesian product* of all hyperparameters set in the script. Check the details in the script. +- Results are recorded by default under `logs/tests/`. +- A log will be saved under `logs/tests/event_logs`, containing result directories of (potentially multiple) experiments. + + +### Inference Configs + +- Configuration files are at `diffusion_policy/config/eval_*.yaml`. You may need to change the `diffusion_checkpoint` field. + + +## Result Analysis +1. Edit the config file for analysis `config/analysis_config.yaml`: + - Paste the relative directory to the log file `xxx.log` under the `log_paths` field. + - Configure other switches and default constants if needed: + - E.g., `DEFAULT_MAX_VEL` specifies the value of max-velocity constraint; *this only affects methods that are NOT configured with such info*, e.g., vanilla diffusion or coupling-only method. The analysis script automatically retrieves the velocity constraint used for PCD or projection-only method. +2. Run the analysis script `scripts/analyzing_results.py` to calculate all the metrics: + ```bash + python scripts/analyzing_results.py --config config/analysis_config.yaml + ``` + - A JSON file will be generated and placed under each directory specified in the log file; this JSON file contains all the metrics. + - You need to run the analysis script multiple times if you are calculating velocity constraint satisfaction rates for *vanilla diffusion or coupling-only* method *for different velocity thresholds*, as those are velocity-constraint-agnostic. + +--- +## ✏️ Citation +If you find this repo or the ideas presented in our paper useful for your research, please consider citing our paper. +``` +@inproceedings{luan2026projected, + title={Projected Coupled Diffusion for Test-Time Constrained Joint Generation}, + author={Hao Luan and Yi Xian Goh and See-Kiong Ng and Chun Kai Ling}, + booktitle={The Fourteenth International Conference on Learning Representations}, + year={2026}, + url={https://openreview.net/forum?id=1FEm5JLpvg} +} +``` + +--- +## References + +- [1] Chi C, Xu Z, Feng S, Cousineau E, Du Y, Burchfiel B, Tedrake R, Song S. Diffusion Policy: Visuomotor policy learning via action diffusion. *The International Journal of Robotics Research*. 2023 Jun:02783649241273668. +- [2] Feng Z, Luan H, Goyal P, Soh H. LTLDoG: Satisfying Temporally-Extended Symbolic Constraints for Safe Diffusion-Based Planning, *IEEE Robotics and Automation Letters*, vol. 9, no. 10, pp. 8571-8578, Oct. 2024. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/conda_environment.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/conda_environment.yaml new file mode 100644 index 0000000000000000000000000000000000000000..87e5801edcd528bb5aa5f053a6f7b384bee3d414 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/conda_environment.yaml @@ -0,0 +1,75 @@ +name: pcdp +channels: + - pytorch + - nvidia + - conda-forge +dependencies: + - python=3.9 + - pip=22.2.2 + - pytorch=2.1.2 + - pytorch-cuda=12.1 + - torchvision=0.16.2 + - numpy=1.23.3 + - numba==0.56.4 + - scipy==1.9.1 + - py-opencv=4.6.0 + - cffi=1.15.1 + - ipykernel=6.16 + - matplotlib=3.6.1 + - zarr=2.12.0 + - numcodecs=0.10.2 + - h5py=3.7.0 + - hydra-core=1.2.0 + - einops=0.4.1 + - tqdm=4.64.1 + - dill=0.3.5.1 + - scikit-video=1.1.11 + - scikit-image=0.19.3 + - gym=0.21.0 + - pymunk=6.2.1 + - wandb=0.13.3 + - threadpoolctl=3.1.0 + - shapely=1.8.4 + - cython=0.29.32 + - imageio=2.22.0 + - imageio-ffmpeg=0.4.7 + - termcolor=2.0.1 + - tensorboard=2.10.1 + - tensorboardx=2.5.1 + - psutil=5.9.2 + - click=8.0.4 + - boto3=1.24.96 + - accelerate=0.13.2 + - datasets=2.6.1 + - diffusers=0.11.1 + - av=10.0.0 + - cmake=3.24.3 + # trick to avoid cpu affinity issue described in https://github.com/pytorch/pytorch/issues/99625 + - llvm-openmp=14 + # trick to force reinstall imagecodecs via pip + - imagecodecs==2022.8.8 + - pip: + - ray[default,tune]==2.2.0 + # requires mujoco py dependencies libosmesa6-dev libgl1-mesa-glx libglfw3 patchelf + - free-mujoco-py==2.1.6 + - pygame==2.1.2 + - pybullet-svl==3.1.6.4 + - robosuite @ https://github.com/cheng-chi/robosuite/archive/277ab9588ad7a4f4b55cf75508b44aa67ec171f0.tar.gz + - robomimic==0.2.0 + - pytorchvideo==0.1.5 + # pip package required for jpeg-xl + - imagecodecs==2022.9.26 + - r3m @ https://github.com/facebookresearch/r3m/archive/b2334e726887fa0206962d7984c69c5fb09cceab.tar.gz + - dm-control==1.0.9 + - ring==0.10.1 + - packaging==23.2 + - scikit-learn==1.4.0 + - parsimonious==0.9.0 + - funcy==1.17 + - ipdb==0.13.9 + - lenses==0.5.0 + - pptree==3.1 + ## Projection + - cvxpy==1.6.6 + - ecos==2.0.14 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/config/analysis_config.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/config/analysis_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4d13aecee0e18a6bb01af844540177e2030ab5b2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/config/analysis_config.yaml @@ -0,0 +1,40 @@ +# ------------------- +# ANALYSIS CONFIG +# ------------------- + +# List of data directories to process. The script will loop through each directory. +log_paths: + - "logs/tests/event_logs/eval_runs-some_tags.log" + - "logs/tests/event_logs/eval_runs-some_other_tags.log" + + +# Switches for plotting and saving figures/data. +switches: + plot_trajectories: false + plot_velocity_stats: true + save_plots: true + + save_stats_json: true + +# General constants and parameters for the analysis. +constants: + EPS: 1.0e-6 + VEL_TOL: 5.0e-4 # Tolerance for velocity checks + DT: 0.01 # time step size + SAFE_DIST: 30.0 + ACTION_HORIZON: 8 + MAX_ACTION: [511.9651, 511.9563] + MIN_ACTION: [0.0091, 0.2790] + # DEFAULT_MAX_VEL: 6.2 # Default maximum velocity threshold (80% of max action) + DEFAULT_MAX_VEL: 8.4 # (90% of max action) + # DEFAULT_MAX_VEL: 10.7 # (95% of max action) + VEL_HIST_QUANTILES: [0.75, 0.8, 0.9, 0.95, 0.99] + +# Plotting-specific parameters. +plotting: + # Trajectory plotting settings + traj_plot_xlim: [0, 550] + traj_plot_ylim: [0, 550] + traj_plot_h_end: 48 + # Velocity histogram settings + vel_hist_bins: 100 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/demo_pusht.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/demo_pusht.py new file mode 100644 index 0000000000000000000000000000000000000000..0e3cf639c1d24baddeaf65b7c7a94d1be9108836 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/demo_pusht.py @@ -0,0 +1,120 @@ +import numpy as np +import click +from diffusion_policy.common.replay_buffer import ReplayBuffer +from diffusion_policy.env.pusht.pusht_keypoints_env import PushTKeypointsEnv +import pygame + +@click.command() +@click.option('-o', '--output', required=True) +@click.option('-rs', '--render_size', default=96, type=int) +@click.option('-hz', '--control_hz', default=10, type=int) +def main(output, render_size, control_hz): + """ + Collect demonstration for the Push-T task. + + Usage: python demo_pusht.py -o data/pusht_demo.zarr + + This script is compatible with both Linux and MacOS. + Hover mouse close to the blue circle to start. + Push the T block into the green area. + The episode will automatically terminate if the task is succeeded. + Press "Q" to exit. + Press "R" to retry. + Hold "Space" to pause. + """ + + # create replay buffer in read-write mode + replay_buffer = ReplayBuffer.create_from_path(output, mode='a') + + # create PushT env with keypoints + kp_kwargs = PushTKeypointsEnv.genenerate_keypoint_manager_params() + env = PushTKeypointsEnv(render_size=render_size, render_action=False, **kp_kwargs) + agent = env.teleop_agent() + clock = pygame.time.Clock() + + # episode-level while loop + while True: + episode = list() + # record in seed order, starting with 0 + seed = replay_buffer.n_episodes + print(f'starting seed {seed}') + + # set seed for env + env.seed(seed) + + # reset env and get observations (including info and render for recording) + obs = env.reset() + info = env._get_info() + img = env.render(mode='human') + + # loop state + retry = False + pause = False + done = False + plan_idx = 0 + pygame.display.set_caption(f'plan_idx:{plan_idx}') + # step-level while loop + while not done: + # process keypress events + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_SPACE: + # hold Space to pause + plan_idx += 1 + pygame.display.set_caption(f'plan_idx:{plan_idx}') + pause = True + elif event.key == pygame.K_r: + # press "R" to retry + retry=True + elif event.key == pygame.K_q: + # press "Q" to exit + exit(0) + if event.type == pygame.KEYUP: + if event.key == pygame.K_SPACE: + pause = False + + # handle control flow + if retry: + break + if pause: + continue + + # get action from mouse + # None if mouse is not close to the agent + act = agent.act(obs) + if not act is None: + # teleop started + # state dim 2+3 + state = np.concatenate([info['pos_agent'], info['block_pose']]) + # discard unused information such as visibility mask and agent pos + # for compatibility + keypoint = obs.reshape(2,-1)[0].reshape(-1,2)[:9] + data = { + 'img': img, + 'state': np.float32(state), + 'keypoint': np.float32(keypoint), + 'action': np.float32(act), + 'n_contacts': np.float32([info['n_contacts']]) + } + episode.append(data) + + # step env and render + obs, reward, done, info = env.step(act) + img = env.render(mode='human') + + # regulate control frequency + clock.tick(control_hz) + if not retry: + # save episode buffer to replay buffer (on disk) + data_dict = dict() + for key in episode[0].keys(): + data_dict[key] = np.stack( + [x[key] for x in episode]) + replay_buffer.add_episode(data_dict, compressors='disk') + print(f'saved seed {seed}') + else: + print(f'retry seed {seed}') + + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/codecs/imagecodecs_numcodecs.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/codecs/imagecodecs_numcodecs.py new file mode 100644 index 0000000000000000000000000000000000000000..cf55b97b4dfb76af68421584b3f40db24fa119bd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/codecs/imagecodecs_numcodecs.py @@ -0,0 +1,1386 @@ + +# imagecodecs/numcodecs.py + +# Copyright (c) 2021-2022, Christoph Gohlke +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +"""Additional numcodecs implemented using imagecodecs.""" + +__version__ = '2022.9.26' + +__all__ = ('register_codecs',) + +import numpy +from numcodecs.abc import Codec +from numcodecs.registry import register_codec, get_codec + +import imagecodecs + + +def protective_squeeze(x: numpy.ndarray): + """ + Squeeze dim only if it's not the last dim. + Image dim expected to be *, H, W, C + """ + img_shape = x.shape[-3:] + if len(x.shape) > 3: + n_imgs = numpy.prod(x.shape[:-3]) + if n_imgs > 1: + img_shape = (-1,) + img_shape + return x.reshape(img_shape) + +def get_default_image_compressor(**kwargs): + if imagecodecs.JPEGXL: + # has JPEGXL + this_kwargs = { + 'effort': 3, + 'distance': 0.3, + # bug in libjxl, invalid codestream for non-lossless + # when decoding speed > 1 + 'decodingspeed': 1 + } + this_kwargs.update(kwargs) + return JpegXl(**this_kwargs) + else: + this_kwargs = { + 'level': 50 + } + this_kwargs.update(kwargs) + return Jpeg2k(**this_kwargs) + +class Aec(Codec): + """AEC codec for numcodecs.""" + + codec_id = 'imagecodecs_aec' + + def __init__( + self, bitspersample=None, flags=None, blocksize=None, rsi=None + ): + self.bitspersample = bitspersample + self.flags = flags + self.blocksize = blocksize + self.rsi = rsi + + def encode(self, buf): + return imagecodecs.aec_encode( + buf, + bitspersample=self.bitspersample, + flags=self.flags, + blocksize=self.blocksize, + rsi=self.rsi, + ) + + def decode(self, buf, out=None): + return imagecodecs.aec_decode( + buf, + bitspersample=self.bitspersample, + flags=self.flags, + blocksize=self.blocksize, + rsi=self.rsi, + out=_flat(out), + ) + + +class Apng(Codec): + """APNG codec for numcodecs.""" + + codec_id = 'imagecodecs_apng' + + def __init__(self, level=None, photometric=None, delay=None): + self.level = level + self.photometric = photometric + self.delay = delay + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.apng_encode( + buf, + level=self.level, + photometric=self.photometric, + delay=self.delay, + ) + + def decode(self, buf, out=None): + return imagecodecs.apng_decode(buf, out=out) + + +class Avif(Codec): + """AVIF codec for numcodecs.""" + + codec_id = 'imagecodecs_avif' + + def __init__( + self, + level=None, + speed=None, + tilelog2=None, + bitspersample=None, + pixelformat=None, + numthreads=None, + index=None, + ): + self.level = level + self.speed = speed + self.tilelog2 = tilelog2 + self.bitspersample = bitspersample + self.pixelformat = pixelformat + self.numthreads = numthreads + self.index = index + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.avif_encode( + buf, + level=self.level, + speed=self.speed, + tilelog2=self.tilelog2, + bitspersample=self.bitspersample, + pixelformat=self.pixelformat, + numthreads=self.numthreads, + ) + + def decode(self, buf, out=None): + return imagecodecs.avif_decode( + buf, index=self.index, numthreads=self.numthreads, out=out + ) + + +class Bitorder(Codec): + """Bitorder codec for numcodecs.""" + + codec_id = 'imagecodecs_bitorder' + + def encode(self, buf): + return imagecodecs.bitorder_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.bitorder_decode(buf, out=_flat(out)) + + +class Bitshuffle(Codec): + """Bitshuffle codec for numcodecs.""" + + codec_id = 'imagecodecs_bitshuffle' + + def __init__(self, itemsize=1, blocksize=0): + self.itemsize = itemsize + self.blocksize = blocksize + + def encode(self, buf): + return imagecodecs.bitshuffle_encode( + buf, itemsize=self.itemsize, blocksize=self.blocksize + ).tobytes() + + def decode(self, buf, out=None): + return imagecodecs.bitshuffle_decode( + buf, + itemsize=self.itemsize, + blocksize=self.blocksize, + out=_flat(out), + ) + + +class Blosc(Codec): + """Blosc codec for numcodecs.""" + + codec_id = 'imagecodecs_blosc' + + def __init__( + self, + level=None, + compressor=None, + typesize=None, + blocksize=None, + shuffle=None, + numthreads=None, + ): + self.level = level + self.compressor = compressor + self.typesize = typesize + self.blocksize = blocksize + self.shuffle = shuffle + self.numthreads = numthreads + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.blosc_encode( + buf, + level=self.level, + compressor=self.compressor, + typesize=self.typesize, + blocksize=self.blocksize, + shuffle=self.shuffle, + numthreads=self.numthreads, + ) + + def decode(self, buf, out=None): + return imagecodecs.blosc_decode( + buf, numthreads=self.numthreads, out=_flat(out) + ) + + +class Blosc2(Codec): + """Blosc2 codec for numcodecs.""" + + codec_id = 'imagecodecs_blosc2' + + def __init__( + self, + level=None, + compressor=None, + typesize=None, + blocksize=None, + shuffle=None, + numthreads=None, + ): + self.level = level + self.compressor = compressor + self.typesize = typesize + self.blocksize = blocksize + self.shuffle = shuffle + self.numthreads = numthreads + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.blosc2_encode( + buf, + level=self.level, + compressor=self.compressor, + typesize=self.typesize, + blocksize=self.blocksize, + shuffle=self.shuffle, + numthreads=self.numthreads, + ) + + def decode(self, buf, out=None): + return imagecodecs.blosc2_decode( + buf, numthreads=self.numthreads, out=_flat(out) + ) + + +class Brotli(Codec): + """Brotli codec for numcodecs.""" + + codec_id = 'imagecodecs_brotli' + + def __init__(self, level=None, mode=None, lgwin=None): + self.level = level + self.mode = mode + self.lgwin = lgwin + + def encode(self, buf): + return imagecodecs.brotli_encode( + buf, level=self.level, mode=self.mode, lgwin=self.lgwin + ) + + def decode(self, buf, out=None): + return imagecodecs.brotli_decode(buf, out=_flat(out)) + + +class ByteShuffle(Codec): + """ByteShuffle codec for numcodecs.""" + + codec_id = 'imagecodecs_byteshuffle' + + def __init__( + self, shape, dtype, axis=-1, dist=1, delta=False, reorder=False + ): + self.shape = tuple(shape) + self.dtype = numpy.dtype(dtype).str + self.axis = axis + self.dist = dist + self.delta = bool(delta) + self.reorder = bool(reorder) + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + assert buf.shape == self.shape + assert buf.dtype == self.dtype + return imagecodecs.byteshuffle_encode( + buf, + axis=self.axis, + dist=self.dist, + delta=self.delta, + reorder=self.reorder, + ).tobytes() + + def decode(self, buf, out=None): + if not isinstance(buf, numpy.ndarray): + buf = numpy.frombuffer(buf, dtype=self.dtype).reshape(*self.shape) + return imagecodecs.byteshuffle_decode( + buf, + axis=self.axis, + dist=self.dist, + delta=self.delta, + reorder=self.reorder, + out=out, + ) + + +class Bz2(Codec): + """Bz2 codec for numcodecs.""" + + codec_id = 'imagecodecs_bz2' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + return imagecodecs.bz2_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.bz2_decode(buf, out=_flat(out)) + + +class Cms(Codec): + """CMS codec for numcodecs.""" + + codec_id = 'imagecodecs_cms' + + def __init__(self, *args, **kwargs): + pass + + def encode(self, buf, out=None): + # return imagecodecs.cms_transform(buf) + raise NotImplementedError + + def decode(self, buf, out=None): + # return imagecodecs.cms_transform(buf) + raise NotImplementedError + + +class Deflate(Codec): + """Deflate codec for numcodecs.""" + + codec_id = 'imagecodecs_deflate' + + def __init__(self, level=None, raw=False): + self.level = level + self.raw = bool(raw) + + def encode(self, buf): + return imagecodecs.deflate_encode(buf, level=self.level, raw=self.raw) + + def decode(self, buf, out=None): + return imagecodecs.deflate_decode(buf, out=_flat(out), raw=self.raw) + + +class Delta(Codec): + """Delta codec for numcodecs.""" + + codec_id = 'imagecodecs_delta' + + def __init__(self, shape=None, dtype=None, axis=-1, dist=1): + self.shape = None if shape is None else tuple(shape) + self.dtype = None if dtype is None else numpy.dtype(dtype).str + self.axis = axis + self.dist = dist + + def encode(self, buf): + if self.shape is not None or self.dtype is not None: + buf = protective_squeeze(numpy.asarray(buf)) + assert buf.shape == self.shape + assert buf.dtype == self.dtype + return imagecodecs.delta_encode( + buf, axis=self.axis, dist=self.dist + ).tobytes() + + def decode(self, buf, out=None): + if self.shape is not None or self.dtype is not None: + buf = numpy.frombuffer(buf, dtype=self.dtype).reshape(*self.shape) + return imagecodecs.delta_decode( + buf, axis=self.axis, dist=self.dist, out=out + ) + + +class Float24(Codec): + """Float24 codec for numcodecs.""" + + codec_id = 'imagecodecs_float24' + + def __init__(self, byteorder=None, rounding=None): + self.byteorder = byteorder + self.rounding = rounding + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.float24_encode( + buf, byteorder=self.byteorder, rounding=self.rounding + ) + + def decode(self, buf, out=None): + return imagecodecs.float24_decode( + buf, byteorder=self.byteorder, out=out + ) + + +class FloatPred(Codec): + """Floating Point Predictor codec for numcodecs.""" + + codec_id = 'imagecodecs_floatpred' + + def __init__(self, shape, dtype, axis=-1, dist=1): + self.shape = tuple(shape) + self.dtype = numpy.dtype(dtype).str + self.axis = axis + self.dist = dist + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + assert buf.shape == self.shape + assert buf.dtype == self.dtype + return imagecodecs.floatpred_encode( + buf, axis=self.axis, dist=self.dist + ).tobytes() + + def decode(self, buf, out=None): + if not isinstance(buf, numpy.ndarray): + buf = numpy.frombuffer(buf, dtype=self.dtype).reshape(*self.shape) + return imagecodecs.floatpred_decode( + buf, axis=self.axis, dist=self.dist, out=out + ) + + +class Gif(Codec): + """GIF codec for numcodecs.""" + + codec_id = 'imagecodecs_gif' + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.gif_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.gif_decode(buf, asrgb=False, out=out) + + +class Heif(Codec): + """HEIF codec for numcodecs.""" + + codec_id = 'imagecodecs_heif' + + def __init__( + self, + level=None, + bitspersample=None, + photometric=None, + compression=None, + numthreads=None, + index=None, + ): + self.level = level + self.bitspersample = bitspersample + self.photometric = photometric + self.compression = compression + self.numthreads = numthreads + self.index = index + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.heif_encode( + buf, + level=self.level, + bitspersample=self.bitspersample, + photometric=self.photometric, + compression=self.compression, + numthreads=self.numthreads, + ) + + def decode(self, buf, out=None): + return imagecodecs.heif_decode( + buf, + index=self.index, + photometric=self.photometric, + numthreads=self.numthreads, + out=out, + ) + + +class Jetraw(Codec): + """Jetraw codec for numcodecs.""" + + codec_id = 'imagecodecs_jetraw' + + def __init__( + self, + shape, + identifier, + parameters=None, + verbosity=None, + errorbound=None, + ): + self.shape = shape + self.identifier = identifier + self.errorbound = errorbound + imagecodecs.jetraw_init(parameters, verbosity) + + def encode(self, buf): + return imagecodecs.jetraw_encode( + buf, identifier=self.identifier, errorbound=self.errorbound + ) + + def decode(self, buf, out=None): + if out is None: + out = numpy.empty(self.shape, numpy.uint16) + return imagecodecs.jetraw_decode(buf, out=out) + + +class Jpeg(Codec): + """JPEG codec for numcodecs.""" + + codec_id = 'imagecodecs_jpeg' + + def __init__( + self, + bitspersample=None, + tables=None, + header=None, + colorspace_data=None, + colorspace_jpeg=None, + level=None, + subsampling=None, + optimize=None, + smoothing=None, + ): + self.tables = tables + self.header = header + self.bitspersample = bitspersample + self.colorspace_data = colorspace_data + self.colorspace_jpeg = colorspace_jpeg + self.level = level + self.subsampling = subsampling + self.optimize = optimize + self.smoothing = smoothing + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.jpeg_encode( + buf, + level=self.level, + colorspace=self.colorspace_data, + outcolorspace=self.colorspace_jpeg, + subsampling=self.subsampling, + optimize=self.optimize, + smoothing=self.smoothing, + ) + + def decode(self, buf, out=None): + out_shape = None + if out is not None: + out_shape = out.shape + out = protective_squeeze(out) + img = imagecodecs.jpeg_decode( + buf, + bitspersample=self.bitspersample, + tables=self.tables, + header=self.header, + colorspace=self.colorspace_jpeg, + outcolorspace=self.colorspace_data, + out=out, + ) + if out_shape is not None: + img = img.reshape(out_shape) + return img + + def get_config(self): + """Return dictionary holding configuration parameters.""" + config = dict(id=self.codec_id) + for key in self.__dict__: + if not key.startswith('_'): + value = getattr(self, key) + if value is not None and key in ('header', 'tables'): + import base64 + + value = base64.b64encode(value).decode() + config[key] = value + return config + + @classmethod + def from_config(cls, config): + """Instantiate codec from configuration object.""" + for key in ('header', 'tables'): + value = config.get(key, None) + if value is not None and isinstance(value, str): + import base64 + + config[key] = base64.b64decode(value.encode()) + return cls(**config) + + +class Jpeg2k(Codec): + """JPEG 2000 codec for numcodecs.""" + + codec_id = 'imagecodecs_jpeg2k' + + def __init__( + self, + level=None, + codecformat=None, + colorspace=None, + tile=None, + reversible=None, + bitspersample=None, + resolutions=None, + numthreads=None, + verbose=0, + ): + self.level = level + self.codecformat = codecformat + self.colorspace = colorspace + self.tile = None if tile is None else tuple(tile) + self.reversible = reversible + self.bitspersample = bitspersample + self.resolutions = resolutions + self.numthreads = numthreads + self.verbose = verbose + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.jpeg2k_encode( + buf, + level=self.level, + codecformat=self.codecformat, + colorspace=self.colorspace, + tile=self.tile, + reversible=self.reversible, + bitspersample=self.bitspersample, + resolutions=self.resolutions, + numthreads=self.numthreads, + verbose=self.verbose, + ) + + def decode(self, buf, out=None): + return imagecodecs.jpeg2k_decode( + buf, verbose=self.verbose, numthreads=self.numthreads, out=out + ) + + +class JpegLs(Codec): + """JPEG LS codec for numcodecs.""" + + codec_id = 'imagecodecs_jpegls' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.jpegls_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.jpegls_decode(buf, out=out) + + +class JpegXl(Codec): + """JPEG XL codec for numcodecs.""" + + codec_id = 'imagecodecs_jpegxl' + + def __init__( + self, + # encode + level=None, + effort=None, + distance=None, + lossless=None, + decodingspeed=None, + photometric=None, + planar=None, + usecontainer=None, + # decode + index=None, + keeporientation=None, + # both + numthreads=None, + ): + """ + Return JPEG XL image from numpy array. + Float must be in nominal range 0..1. + + Currently L, LA, RGB, RGBA images are supported in contig mode. + Extra channels are only supported for grayscale images in planar mode. + + Parameters + ---------- + level : Default to None, i.e. not overwriting lossess and decodingspeed options. + When < 0: Use lossless compression + When in [0,1,2,3,4]: Sets the decoding speed tier for the provided options. + Minimum is 0 (slowest to decode, best quality/density), and maximum + is 4 (fastest to decode, at the cost of some quality/density). + effort : Default to 3. + Sets encoder effort/speed level without affecting decoding speed. + Valid values are, from faster to slower speed: 1:lightning 2:thunder + 3:falcon 4:cheetah 5:hare 6:wombat 7:squirrel 8:kitten 9:tortoise. + Speed: lightning, thunder, falcon, cheetah, hare, wombat, squirrel, kitten, tortoise + control the encoder effort in ascending order. + This also affects memory usage: using lower effort will typically reduce memory + consumption during encoding. + lightning and thunder are fast modes useful for lossless mode (modular). + falcon disables all of the following tools. + cheetah enables coefficient reordering, context clustering, and heuristics for selecting DCT sizes and quantization steps. + hare enables Gaborish filtering, chroma from luma, and an initial estimate of quantization steps. + wombat enables error diffusion quantization and full DCT size selection heuristics. + squirrel (default) enables dots, patches, and spline detection, and full context clustering. + kitten optimizes the adaptive quantization for a psychovisual metric. + tortoise enables a more thorough adaptive quantization search. + distance : Default to 1.0 + Sets the distance level for lossy compression: target max butteraugli distance, + lower = higher quality. Range: 0 .. 15. 0.0 = mathematically lossless + (however, use JxlEncoderSetFrameLossless instead to use true lossless, + as setting distance to 0 alone is not the only requirement). + 1.0 = visually lossless. Recommended range: 0.5 .. 3.0. + lossess : Default to False. + Use lossess encoding. + decodingspeed : Default to 0. + Duplicate to level. [0,4] + photometric : Return JxlColorSpace value. + Default logic is quite complicated but works most of the time. + Accepted value: + int: [-1,3] + str: ['RGB', + 'WHITEISZERO', 'MINISWHITE', + 'BLACKISZERO', 'MINISBLACK', 'GRAY', + 'XYB', 'KNOWN'] + planar : Enable multi-channel mode. + Default to false. + usecontainer : + Forces the encoder to use the box-based container format (BMFF) + even when not necessary. + When using JxlEncoderUseBoxes, JxlEncoderStoreJPEGMetadata or + JxlEncoderSetCodestreamLevel with level 10, the encoder will + automatically also use the container format, it is not necessary + to use JxlEncoderUseContainer for those use cases. + By default this setting is disabled. + index : Selectively decode frames for animation. + Default to 0, decode all frames. + When set to > 0, decode that frame index only. + keeporientation : + Enables or disables preserving of as-in-bitstream pixeldata orientation. + Some images are encoded with an Orientation tag indicating that the + decoder must perform a rotation and/or mirroring to the encoded image data. + + If skip_reorientation is JXL_FALSE (the default): the decoder will apply + the transformation from the orientation setting, hence rendering the image + according to its specified intent. When producing a JxlBasicInfo, the decoder + will always set the orientation field to JXL_ORIENT_IDENTITY (matching the + returned pixel data) and also align xsize and ysize so that they correspond + to the width and the height of the returned pixel data. + + If skip_reorientation is JXL_TRUE: the decoder will skip applying the + transformation from the orientation setting, returning the image in + the as-in-bitstream pixeldata orientation. This may be faster to decode + since the decoder doesnt have to apply the transformation, but can + cause wrong display of the image if the orientation tag is not correctly + taken into account by the user. + + By default, this option is disabled, and the returned pixel data is + re-oriented according to the images Orientation setting. + threads : Default to 1. + If <= 0, use all cores. + If > 32, clipped to 32. + """ + + self.level = level + self.effort = effort + self.distance = distance + self.lossless = bool(lossless) + self.decodingspeed = decodingspeed + self.photometric = photometric + self.planar = planar + self.usecontainer = usecontainer + self.index = index + self.keeporientation = keeporientation + self.numthreads = numthreads + + def encode(self, buf): + # TODO: only squeeze all but last dim + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.jpegxl_encode( + buf, + level=self.level, + effort=self.effort, + distance=self.distance, + lossless=self.lossless, + decodingspeed=self.decodingspeed, + photometric=self.photometric, + planar=self.planar, + usecontainer=self.usecontainer, + numthreads=self.numthreads, + ) + + def decode(self, buf, out=None): + return imagecodecs.jpegxl_decode( + buf, + index=self.index, + keeporientation=self.keeporientation, + numthreads=self.numthreads, + out=out, + ) + + +class JpegXr(Codec): + """JPEG XR codec for numcodecs.""" + + codec_id = 'imagecodecs_jpegxr' + + def __init__( + self, + level=None, + photometric=None, + hasalpha=None, + resolution=None, + fp2int=None, + ): + self.level = level + self.photometric = photometric + self.hasalpha = hasalpha + self.resolution = resolution + self.fp2int = fp2int + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.jpegxr_encode( + buf, + level=self.level, + photometric=self.photometric, + hasalpha=self.hasalpha, + resolution=self.resolution, + ) + + def decode(self, buf, out=None): + return imagecodecs.jpegxr_decode(buf, fp2int=self.fp2int, out=out) + + +class Lerc(Codec): + """LERC codec for numcodecs.""" + + codec_id = 'imagecodecs_lerc' + + def __init__(self, level=None, version=None, planar=None): + self.level = level + self.version = version + self.planar = bool(planar) + # TODO: support mask? + # self.mask = None + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.lerc_encode( + buf, + level=self.level, + version=self.version, + planar=self.planar, + ) + + def decode(self, buf, out=None): + return imagecodecs.lerc_decode(buf, out=out) + + +class Ljpeg(Codec): + """LJPEG codec for numcodecs.""" + + codec_id = 'imagecodecs_ljpeg' + + def __init__(self, bitspersample=None): + self.bitspersample = bitspersample + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.ljpeg_encode(buf, bitspersample=self.bitspersample) + + def decode(self, buf, out=None): + return imagecodecs.ljpeg_decode(buf, out=out) + + +class Lz4(Codec): + """LZ4 codec for numcodecs.""" + + codec_id = 'imagecodecs_lz4' + + def __init__(self, level=None, hc=False, header=True): + self.level = level + self.hc = hc + self.header = bool(header) + + def encode(self, buf): + return imagecodecs.lz4_encode( + buf, level=self.level, hc=self.hc, header=self.header + ) + + def decode(self, buf, out=None): + return imagecodecs.lz4_decode(buf, header=self.header, out=_flat(out)) + + +class Lz4f(Codec): + """LZ4F codec for numcodecs.""" + + codec_id = 'imagecodecs_lz4f' + + def __init__( + self, + level=None, + blocksizeid=False, + contentchecksum=None, + blockchecksum=None, + ): + self.level = level + self.blocksizeid = blocksizeid + self.contentchecksum = contentchecksum + self.blockchecksum = blockchecksum + + def encode(self, buf): + return imagecodecs.lz4f_encode( + buf, + level=self.level, + blocksizeid=self.blocksizeid, + contentchecksum=self.contentchecksum, + blockchecksum=self.blockchecksum, + ) + + def decode(self, buf, out=None): + return imagecodecs.lz4f_decode(buf, out=_flat(out)) + + +class Lzf(Codec): + """LZF codec for numcodecs.""" + + codec_id = 'imagecodecs_lzf' + + def __init__(self, header=True): + self.header = bool(header) + + def encode(self, buf): + return imagecodecs.lzf_encode(buf, header=self.header) + + def decode(self, buf, out=None): + return imagecodecs.lzf_decode(buf, header=self.header, out=_flat(out)) + + +class Lzma(Codec): + """LZMA codec for numcodecs.""" + + codec_id = 'imagecodecs_lzma' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + return imagecodecs.lzma_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.lzma_decode(buf, out=_flat(out)) + + +class Lzw(Codec): + """LZW codec for numcodecs.""" + + codec_id = 'imagecodecs_lzw' + + def encode(self, buf): + return imagecodecs.lzw_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.lzw_decode(buf, out=_flat(out)) + + +class PackBits(Codec): + """PackBits codec for numcodecs.""" + + codec_id = 'imagecodecs_packbits' + + def __init__(self, axis=None): + self.axis = axis + + def encode(self, buf): + if not isinstance(buf, (bytes, bytearray)): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.packbits_encode(buf, axis=self.axis) + + def decode(self, buf, out=None): + return imagecodecs.packbits_decode(buf, out=_flat(out)) + + +class Pglz(Codec): + """PGLZ codec for numcodecs.""" + + codec_id = 'imagecodecs_pglz' + + def __init__(self, header=True, strategy=None): + self.header = bool(header) + self.strategy = strategy + + def encode(self, buf): + return imagecodecs.pglz_encode( + buf, strategy=self.strategy, header=self.header + ) + + def decode(self, buf, out=None): + return imagecodecs.pglz_decode(buf, header=self.header, out=_flat(out)) + + +class Png(Codec): + """PNG codec for numcodecs.""" + + codec_id = 'imagecodecs_png' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.png_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.png_decode(buf, out=out) + + +class Qoi(Codec): + """QOI codec for numcodecs.""" + + codec_id = 'imagecodecs_qoi' + + def __init__(self): + pass + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.qoi_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.qoi_decode(buf, out=out) + + +class Rgbe(Codec): + """RGBE codec for numcodecs.""" + + codec_id = 'imagecodecs_rgbe' + + def __init__(self, header=False, shape=None, rle=None): + if not header and shape is None: + raise ValueError('must specify data shape if no header') + if shape and shape[-1] != 3: + raise ValueError('invalid shape') + self.shape = shape + self.header = bool(header) + self.rle = None if rle is None else bool(rle) + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.rgbe_encode(buf, header=self.header, rle=self.rle) + + def decode(self, buf, out=None): + if out is None and not self.header: + out = numpy.empty(self.shape, numpy.float32) + return imagecodecs.rgbe_decode( + buf, header=self.header, rle=self.rle, out=out + ) + + +class Rcomp(Codec): + """Rcomp codec for numcodecs.""" + + codec_id = 'imagecodecs_rcomp' + + def __init__(self, shape, dtype, nblock=None): + self.shape = tuple(shape) + self.dtype = numpy.dtype(dtype).str + self.nblock = nblock + + def encode(self, buf): + return imagecodecs.rcomp_encode(buf, nblock=self.nblock) + + def decode(self, buf, out=None): + return imagecodecs.rcomp_decode( + buf, + shape=self.shape, + dtype=self.dtype, + nblock=self.nblock, + out=out, + ) + + +class Snappy(Codec): + """Snappy codec for numcodecs.""" + + codec_id = 'imagecodecs_snappy' + + def encode(self, buf): + return imagecodecs.snappy_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.snappy_decode(buf, out=_flat(out)) + + +class Spng(Codec): + """SPNG codec for numcodecs.""" + + codec_id = 'imagecodecs_spng' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.spng_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.spng_decode(buf, out=out) + + +class Tiff(Codec): + """TIFF codec for numcodecs.""" + + codec_id = 'imagecodecs_tiff' + + def __init__(self, index=None, asrgb=None, verbose=0): + self.index = index + self.asrgb = bool(asrgb) + self.verbose = verbose + + def encode(self, buf): + # TODO: not implemented + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.tiff_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.tiff_decode( + buf, + index=self.index, + asrgb=self.asrgb, + verbose=self.verbose, + out=out, + ) + + +class Webp(Codec): + """WebP codec for numcodecs.""" + + codec_id = 'imagecodecs_webp' + + def __init__(self, level=None, lossless=None, method=None, hasalpha=None): + self.level = level + self.hasalpha = bool(hasalpha) + self.method = method + self.lossless = lossless + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + return imagecodecs.webp_encode( + buf, level=self.level, lossless=self.lossless, method=self.method + ) + + def decode(self, buf, out=None): + return imagecodecs.webp_decode(buf, hasalpha=self.hasalpha, out=out) + + +class Xor(Codec): + """XOR codec for numcodecs.""" + + codec_id = 'imagecodecs_xor' + + def __init__(self, shape=None, dtype=None, axis=-1): + self.shape = None if shape is None else tuple(shape) + self.dtype = None if dtype is None else numpy.dtype(dtype).str + self.axis = axis + + def encode(self, buf): + if self.shape is not None or self.dtype is not None: + buf = protective_squeeze(numpy.asarray(buf)) + assert buf.shape == self.shape + assert buf.dtype == self.dtype + return imagecodecs.xor_encode(buf, axis=self.axis).tobytes() + + def decode(self, buf, out=None): + if self.shape is not None or self.dtype is not None: + buf = numpy.frombuffer(buf, dtype=self.dtype).reshape(*self.shape) + return imagecodecs.xor_decode(buf, axis=self.axis, out=_flat(out)) + + +class Zfp(Codec): + """ZFP codec for numcodecs.""" + + codec_id = 'imagecodecs_zfp' + + def __init__( + self, + shape=None, + dtype=None, + strides=None, + level=None, + mode=None, + execution=None, + numthreads=None, + chunksize=None, + header=True, + ): + if header: + self.shape = None + self.dtype = None + self.strides = None + elif shape is None or dtype is None: + raise ValueError('invalid shape or dtype') + else: + self.shape = tuple(shape) + self.dtype = numpy.dtype(dtype).str + self.strides = None if strides is None else tuple(strides) + self.level = level + self.mode = mode + self.execution = execution + self.numthreads = numthreads + self.chunksize = chunksize + self.header = bool(header) + + def encode(self, buf): + buf = protective_squeeze(numpy.asarray(buf)) + if not self.header: + assert buf.shape == self.shape + assert buf.dtype == self.dtype + return imagecodecs.zfp_encode( + buf, + level=self.level, + mode=self.mode, + execution=self.execution, + header=self.header, + numthreads=self.numthreads, + chunksize=self.chunksize, + ) + + def decode(self, buf, out=None): + if self.header: + return imagecodecs.zfp_decode(buf, out=out) + return imagecodecs.zfp_decode( + buf, + shape=self.shape, + dtype=numpy.dtype(self.dtype), + strides=self.strides, + numthreads=self.numthreads, + out=out, + ) + + +class Zlib(Codec): + """Zlib codec for numcodecs.""" + + codec_id = 'imagecodecs_zlib' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + return imagecodecs.zlib_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.zlib_decode(buf, out=_flat(out)) + + +class Zlibng(Codec): + """Zlibng codec for numcodecs.""" + + codec_id = 'imagecodecs_zlibng' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + return imagecodecs.zlibng_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.zlibng_decode(buf, out=_flat(out)) + + +class Zopfli(Codec): + """Zopfli codec for numcodecs.""" + + codec_id = 'imagecodecs_zopfli' + + def encode(self, buf): + return imagecodecs.zopfli_encode(buf) + + def decode(self, buf, out=None): + return imagecodecs.zopfli_decode(buf, out=_flat(out)) + + +class Zstd(Codec): + """ZStandard codec for numcodecs.""" + + codec_id = 'imagecodecs_zstd' + + def __init__(self, level=None): + self.level = level + + def encode(self, buf): + return imagecodecs.zstd_encode(buf, level=self.level) + + def decode(self, buf, out=None): + return imagecodecs.zstd_decode(buf, out=_flat(out)) + + +def _flat(out): + """Return numpy array as contiguous view of bytes if possible.""" + if out is None: + return None + view = memoryview(out) + if view.readonly or not view.contiguous: + return None + return view.cast('B') + + +def register_codecs(codecs=None, force=False, verbose=True): + """Register codecs in this module with numcodecs.""" + for name, cls in globals().items(): + if not hasattr(cls, 'codec_id') or name == 'Codec': + continue + if codecs is not None and cls.codec_id not in codecs: + continue + try: + try: + get_codec({'id': cls.codec_id}) + except TypeError: + # registered, but failed + pass + except ValueError: + # not registered yet + pass + else: + if not force: + if verbose: + log_warning( + f'numcodec {cls.codec_id!r} already registered' + ) + continue + if verbose: + log_warning(f'replacing registered numcodec {cls.codec_id!r}') + register_codec(cls) + + +def log_warning(msg, *args, **kwargs): + """Log message with level WARNING.""" + import logging + + logging.getLogger(__name__).warning(msg, *args, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ast_builder.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ast_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..a59064b7ee78542af16e0e3700b9a34d8e25dcc5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ast_builder.py @@ -0,0 +1,112 @@ +import ring +import numpy as np + +import dgl +import networkx as nx +from sklearn.preprocessing import OneHotEncoder + +edge_types = {k:v for (v, k) in enumerate(["self", "arg", "arg1", "arg2"])} + +""" +A class that can take an LTL formula and generate the Abstract Syntax Tree (AST) of it. This +code can generate trees in either Networkx or DGL formats. And uses caching to remember recently +generated trees. +""" +class ASTBuilder(object): + def __init__(self, propositions): + super(ASTBuilder, self).__init__() + + self.props = propositions + + terminals = list(set(list(['True', 'False'] + self.props))) + ## Pad terminals with dummy propositions to get a fixed encoding size + for i in range(15 - len(terminals)): + terminals.append("dummy_"+str(i)) + + self._enc = OneHotEncoder(handle_unknown='ignore', dtype=int) + self._enc.fit([['next'], ['until'], ['and'], ['or'], ['eventually'], + ['always'], ['not']] + np.array(terminals).reshape((-1, 1)).tolist()) + + # To make the caching work. + def __ring_key__(self): + return "ASTBuilder" + + @ring.lru(maxsize=30000) + def __call__(self, formula, dim_is_root=1, library="dgl"): + nxg = self._to_graph(formula) + # nx.set_node_attributes(nxg, 0., "is_root") + # nxg.nodes[0]["is_root"] = 1. + nx.set_node_attributes(nxg, np.zeros(dim_is_root, dtype="float32"), "is_root") + nxg.nodes[0]["is_root"] = np.ones(dim_is_root, dtype="float32") + if (library == "networkx"): return nxg + + # convert the Networkx graph to dgl graph and pass the 'feat' attribute + g = dgl.from_networkx(nxg, node_attrs=["feat", "is_root"], edge_attrs=["type"], device='cuda') + # g = dgl.DGLGraph() + # g.from_networkx(nxg, node_attrs=["feat", "is_root"], edge_attrs=["type"]) # dgl does not support string attributes (i.e., token) + return g + + def _one_hot(self, token): + return self._enc.transform([[token]])[0][0].toarray() + + + def _get_edge_type(self, operator, parameter_num=None): + operator = operator.lower() + if (operator in ["next", "until", "and", "or"]): + # Uncomment to make "and" and "or" permutation invariant + # parameter_num = 1 if operator in ["and", "or"] else operator + + return edge_types[operator + f"_{parameter_num}"] + + return edge_types[operator] + + # A helper function that recursively builds up the AST of the LTL formula + @ring.lru(maxsize=60000) # Caching the formula->tree pairs in a Last Recently Used fashion + def _to_graph(self, formula, shift=0): + head = formula[0] + rest = formula[1:] + nxg = nx.DiGraph() + + if head in ["until", "and", "or"]: + nxg.add_node(shift, feat=self._one_hot(head), token=head) + nxg.add_edge(shift, shift, type=self._get_edge_type("self")) + + l = self._to_graph(rest[0], shift+1) # build the left subtree + nxg = nx.compose(nxg, l) # combine the left subtree with the current tree + nxg.add_edge(shift+1, shift, type=self._get_edge_type("arg1")) # connect the current node to the root of the left subtree + + index = nxg.number_of_nodes() + r = self._to_graph(rest[1], shift+index) # build the left subtree + nxg = nx.compose(nxg, r) # combine the left subtree with the current tree + nxg.add_edge(shift+index, shift, type=self._get_edge_type("arg2")) + # if head in ["next", "until"]: + # nxg.add_edge(1, index, type=self.ASSYM_EDGE) # impose order on the operands of an assymetric operator + + return nxg + + if head in ["next", "eventually", "always", "not"]: + nxg.add_node(shift, feat=self._one_hot(head), token=head) + nxg.add_edge(shift, shift, type=self._get_edge_type("self")) + + l = self._to_graph(rest[0], shift+1) # build the left subtree + nxg = nx.compose(nxg, l) # combine the left subtree with the current tree + nxg.add_edge(shift+1, shift, type=self._get_edge_type("arg")) # connect the current node to the root of the left subtree + + return nxg + + if formula in ["True", "False"]: + nxg.add_node(shift, feat=self.vocab._one_hot(formula), token=formula) + nxg.add_edge(shift, shift, type=self._get_edge_type("self")) + + return nxg + + if formula in self.props: + nxg.add_node(shift, feat=self._one_hot(formula.replace("'",'')), token=formula) + nxg.add_edge(shift, shift, type=self._get_edge_type("self")) + + return nxg + + + assert False, "Format error in ast_builder.ASTBuilder._to_graph()" + + return None diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/checkpoint_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/checkpoint_util.py new file mode 100644 index 0000000000000000000000000000000000000000..fbb08371366eb9baa4d32e65616c5e388efe9dbd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/checkpoint_util.py @@ -0,0 +1,59 @@ +from typing import Optional, Dict +import os + +class TopKCheckpointManager: + def __init__(self, + save_dir, + monitor_key: str, + mode='min', + k=1, + format_str='epoch={epoch:03d}-train_loss={train_loss:.3f}.ckpt' + ): + assert mode in ['max', 'min'] + assert k >= 0 + + self.save_dir = save_dir + self.monitor_key = monitor_key + self.mode = mode + self.k = k + self.format_str = format_str + self.path_value_map = dict() + + def get_ckpt_path(self, data: Dict[str, float]) -> Optional[str]: + if self.k == 0: + return None + + value = data[self.monitor_key] + ckpt_path = os.path.join( + self.save_dir, self.format_str.format(**data)) + + if len(self.path_value_map) < self.k: + # under-capacity + self.path_value_map[ckpt_path] = value + return ckpt_path + + # at capacity + sorted_map = sorted(self.path_value_map.items(), key=lambda x: x[1]) + min_path, min_value = sorted_map[0] + max_path, max_value = sorted_map[-1] + + delete_path = None + if self.mode == 'max': + if value > min_value: + delete_path = min_path + else: + if value < max_value: + delete_path = max_path + + if delete_path is None: + return None + else: + del self.path_value_map[delete_path] + self.path_value_map[ckpt_path] = value + + if not os.path.exists(self.save_dir): + os.mkdir(self.save_dir) + + if os.path.exists(delete_path): + os.remove(delete_path) + return ckpt_path diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/cv2_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/cv2_util.py new file mode 100644 index 0000000000000000000000000000000000000000..1d749bcecffb276fcbef4d743853ff587638e366 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/cv2_util.py @@ -0,0 +1,150 @@ +from typing import Tuple +import math +import cv2 +import numpy as np + +def draw_reticle(img, u, v, label_color): + """ + Draws a reticle (cross-hair) on the image at the given position on top of + the original image. + @param img (In/Out) uint8 3 channel image + @param u X coordinate (width) + @param v Y coordinate (height) + @param label_color tuple of 3 ints for RGB color used for drawing. + """ + # Cast to int. + u = int(u) + v = int(v) + + white = (255, 255, 255) + cv2.circle(img, (u, v), 10, label_color, 1) + cv2.circle(img, (u, v), 11, white, 1) + cv2.circle(img, (u, v), 12, label_color, 1) + cv2.line(img, (u, v + 1), (u, v + 3), white, 1) + cv2.line(img, (u + 1, v), (u + 3, v), white, 1) + cv2.line(img, (u, v - 1), (u, v - 3), white, 1) + cv2.line(img, (u - 1, v), (u - 3, v), white, 1) + + +def draw_text( + img, + *, + text, + uv_top_left, + color=(255, 255, 255), + fontScale=0.5, + thickness=1, + fontFace=cv2.FONT_HERSHEY_SIMPLEX, + outline_color=(0, 0, 0), + line_spacing=1.5, +): + """ + Draws multiline with an outline. + """ + assert isinstance(text, str) + + uv_top_left = np.array(uv_top_left, dtype=float) + assert uv_top_left.shape == (2,) + + for line in text.splitlines(): + (w, h), _ = cv2.getTextSize( + text=line, + fontFace=fontFace, + fontScale=fontScale, + thickness=thickness, + ) + uv_bottom_left_i = uv_top_left + [0, h] + org = tuple(uv_bottom_left_i.astype(int)) + + if outline_color is not None: + cv2.putText( + img, + text=line, + org=org, + fontFace=fontFace, + fontScale=fontScale, + color=outline_color, + thickness=thickness * 3, + lineType=cv2.LINE_AA, + ) + cv2.putText( + img, + text=line, + org=org, + fontFace=fontFace, + fontScale=fontScale, + color=color, + thickness=thickness, + lineType=cv2.LINE_AA, + ) + + uv_top_left += [0, h * line_spacing] + + +def get_image_transform( + input_res: Tuple[int,int]=(1280,720), + output_res: Tuple[int,int]=(640,480), + bgr_to_rgb: bool=False): + + iw, ih = input_res + ow, oh = output_res + rw, rh = None, None + interp_method = cv2.INTER_AREA + + if (iw/ih) >= (ow/oh): + # input is wider + rh = oh + rw = math.ceil(rh / ih * iw) + if oh > ih: + interp_method = cv2.INTER_LINEAR + else: + rw = ow + rh = math.ceil(rw / iw * ih) + if ow > iw: + interp_method = cv2.INTER_LINEAR + + w_slice_start = (rw - ow) // 2 + w_slice = slice(w_slice_start, w_slice_start + ow) + h_slice_start = (rh - oh) // 2 + h_slice = slice(h_slice_start, h_slice_start + oh) + c_slice = slice(None) + if bgr_to_rgb: + c_slice = slice(None, None, -1) + + def transform(img: np.ndarray): + assert img.shape == ((ih,iw,3)) + # resize + img = cv2.resize(img, (rw, rh), interpolation=interp_method) + # crop + img = img[h_slice, w_slice, c_slice] + return img + return transform + +def optimal_row_cols( + n_cameras, + in_wh_ratio, + max_resolution=(1920, 1080) + ): + out_w, out_h = max_resolution + out_wh_ratio = out_w / out_h + + n_rows = np.arange(n_cameras,dtype=np.int64) + 1 + n_cols = np.ceil(n_cameras / n_rows).astype(np.int64) + cat_wh_ratio = in_wh_ratio * (n_cols / n_rows) + ratio_diff = np.abs(out_wh_ratio - cat_wh_ratio) + best_idx = np.argmin(ratio_diff) + best_n_row = n_rows[best_idx] + best_n_col = n_cols[best_idx] + best_cat_wh_ratio = cat_wh_ratio[best_idx] + + rw, rh = None, None + if best_cat_wh_ratio >= out_wh_ratio: + # cat is wider + rw = math.floor(out_w / best_n_col) + rh = math.floor(rw / in_wh_ratio) + else: + rh = math.floor(out_h / best_n_row) + rw = math.floor(rh * in_wh_ratio) + + # crop_resolution = (rw, rh) + return rw, rh, best_n_col, best_n_row diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/env_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/env_util.py new file mode 100644 index 0000000000000000000000000000000000000000..b703a2862d1e7eb489d979aaf150a426f39528e6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/env_util.py @@ -0,0 +1,115 @@ +import cv2 +import numpy as np +import torch as th +from typing import Union + + +def render_env_video(env, states, actions=None): + observations = states + imgs = list() + for i in range(len(observations)): + state = observations[i] + env.set_state(state) + if i == 0: + env.set_state(state) + img = env.render() + # draw action + if actions is not None: + action = actions[i] + coord = (action / 512 * 96).astype(np.int32) + cv2.drawMarker(img, coord, + color=(255,0,0), markerType=cv2.MARKER_CROSS, + markerSize=8, thickness=1) + imgs.append(img) + imgs = np.array(imgs) + return imgs + + +def rand_init_state(cfg): + """ + Randomly initialize the environment state. + """ + # seed = cfg.init_states_seed + seed = cfg.training.seed + n_init_states = cfg.n_init_states + rng:np.random.Generator = np.random.default_rng(seed) + init_states = [] + for _ in range(n_init_states): + # Randomly generate a state within the environment bounds + # Assuming the environment has bounds of 500x500 + # and the robot is initialized at a random position + # with a random orientation. + init_states.append( + np.array([ + rng.integers(50, 450), rng.integers(50, 450), + rng.integers(100, 400), rng.integers(100, 400), + rng.random() * 2 * np.pi - np.pi + ]) + ) + + return init_states + + + +def regroup_trajectories( + trajectories: Union[th.Tensor, np.ndarray], + tup_size: int, + n_chunks: int = 1, + ): + """ + Regroups trajectories, preserving the tuple member dimension in the output. + + The output shape will be (tup_size, B, ...), where B is the total + number of tuples across all chunks. + + Args: + trajectories: The input tensor or array of shape (N, ...). + tup_size: The number of trajectories in each tuple. This will be the + first dimension of the output array. + n_chunks: The number of chunks to divide the data into. + + Returns: + The regrouped tensor or array of shape (tup_size, B, ...). + """ + shape = trajectories.shape + N = shape[0] + remaining_shape = shape[1:] + + # --- Input Validation --- + if N <= 0: + raise ValueError("The input trajectories must have a positive number of samples.") + if tup_size <= 0 or n_chunks <= 0: + raise ValueError("'tup_size' and 'n_chunks' must be positive integers.") + if N % (n_chunks * tup_size) != 0: + raise ValueError( + f"Total batch size N ({N}) must be divisible by " + f"(n_chunks * tup_size) ({n_chunks * tup_size})." + ) + + # 1. Calculate the number of tuples within each chunk. + chunk_tup_B = N // (n_chunks * tup_size) + + # 2. Reshape into the logical 3D structure. + logical_shape = (n_chunks, tup_size, chunk_tup_B, *remaining_shape) + grouped_traj = trajectories.reshape(logical_shape) + + # 3. Permute to bring the 'tup_size' dimension to the front. + permute_axes = (1, 0, 2) + tuple(range(3, grouped_traj.ndim)) + if isinstance(grouped_traj, np.ndarray): + grouped_traj = grouped_traj.transpose(permute_axes) + else: # assume torch.Tensor + grouped_traj = grouped_traj.permute(*permute_axes) + # At this point, the shape is (tup_size, n_chunks, chunk_tup_B, ...) + + # 4. Reshape to merge the chunk dimensions while keeping the tup_size dimension. + # The '-1' automatically calculates B = n_chunks * chunk_tup_B. + final_shape = (tup_size, -1, *remaining_shape) + regrouped_traj = grouped_traj.reshape(final_shape) + + # --- Sanity check --- + B = n_chunks * chunk_tup_B + assert regrouped_traj.shape == (tup_size, B, *remaining_shape), \ + "The final shape of the regrouped array is incorrect." + + return regrouped_traj + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/eval_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/eval_util.py new file mode 100644 index 0000000000000000000000000000000000000000..a7e13c141843a05eeaafdddb9ba223c2e5f55d6f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/eval_util.py @@ -0,0 +1,284 @@ +import numpy as np +from typing import Union, Literal + +def collision_detect( + trajs: np.ndarray, + safe_dist: float = 10.0, + norm_order: Union[int, str] = 2, +): + """ + Check collision for batched trajectories and return a mask. + + Args: + traj (np.ndarray): A batch of trajectories with shape (..., batch_size, player, horizon, D), + where D is the dimension of the state space. + safe_dist (float): The safe distance threshold for collision detection. + norm_order (Union[int, str]): The order of the norm to use for distance calculation. + Can be a positive integer or 'inf' + + Returns: + np.ndarray: A boolean array of shape (..., batch_size, horizon) indicating whether + each trajectory is in collision. + """ + + # Ensure traj is a numpy array + trajs = np.asarray(trajs) + + assert trajs.ndim >= 3, "Trajectory must have at least 3 dimensions (player, horizon and state)." + # Check if the last dimension is at least 2 (for x and y coordinates) + if trajs.shape[-1] < 1: + raise ValueError("Trajectory must have at least 1 dimensions for collision detection.") + assert trajs.shape[-3] == 2, "Only support 2 players for now. " + + # Calculate the distance from the origin (0, 0) for each point in the trajectory + distances = np.linalg.norm(trajs[..., 0, :, :2] - trajs[..., 1, :, :2], ord=norm_order, axis=-1) + + # Check if any distance exceeds the safe distance threshold + collide = distances < safe_dist + collision_masks = np.stack((collide, collide), axis=-2) + + # Sanity check + assert collision_masks.shape == trajs.shape[:-1], \ + f"Expected collision mask shape {trajs.shape[:-1]}, got {collision_masks.shape}" + + return collision_masks + + +def calculate_per_segment_safety(safe_masks: np.ndarray, calc_horizon: int) -> np.ndarray: + """ + Calculates the safe rate for each segment and each batch from a 4D safety mask. + + The function divides the time horizon (H) into segments of length `calc_horizon` + and calculates whether ALL timesteps in each segment are safe (returns True only + if all timesteps in the segment are safe, False otherwise). It does NOT average + over the batch (N) or other dimensions. + + Args: + safe_masks (np.ndarray): A boolean array of shape (..., H) where + True indicates a safe state. + calc_horizon (int): The desired length of each segment along the H dimension. + + Returns: + np.ndarray: An array of shape (..., num_segments) where each value is + True if ALL timesteps in that segment are safe, False otherwise. + """ + assert calc_horizon > 0, "`calc_horizon` must be a positive integer." + + # Get the total number of steps in the horizon from the last dimension. + h_dim = safe_masks.shape[-1] + + # 1. Define the indices where the array will be split along the horizon axis. + split_indices = range(calc_horizon, h_dim, calc_horizon) + + # 2. Split the safe_masks array into a list of segments along the H dimension (axis=-1). + segments = np.split(safe_masks, split_indices, axis=-1) + + # 3. Calculate the safe rate for each segment, averaging ONLY over the horizon axis (-1). + # This preserves the I, P, and N dimensions. + # The result is a list of arrays, where each array has the shape (..., ). + segment_rates_list = [np.all(s.astype(bool), axis=-1) for s in segments] + + # 4. Stack the list of (...,) arrays into a single array. + # The new dimension, axis=-1, represents the segments. + # The final shape is (..., num_segments). + per_segment_batch_rates = np.stack(segment_rates_list, axis=-1) + + return per_segment_batch_rates + + +def _pairwise_distance_matrix( + t1: np.ndarray, + t2: np.ndarray, + norm_ord=Union[float, Literal['fro', 'nuc'], None] + ) -> np.ndarray: + """Computes the pairwise distance matrix, normalized by trajectory length. + + Args: + t1 (np.ndarray): First batch of trajectories. Shape (..., H, d). + t2 (np.ndarray): Second batch of trajectories. Must have the same shape as t1. + norm_ord (Union[float, Literal['fro', 'nuc'], None]): The order of the norm to use for distance calculation. + Can be a positive integer or 'fro' for Frobenius norm, 'nuc' for nuclear norm, or None for L2 norm. + + Returns: + np.ndarray: A distance matrix of shape (..., H, H) where each entry (i, j) is the distance + between the i-th point in t1 and the j-th point in t2. + Raises: + ValueError: If input shapes are not identical or have fewer than 2 dimensions. + """ + t1_expanded = t1[..., :, np.newaxis, :] + t2_expanded = t2[..., np.newaxis, :, :] + diff = t1_expanded - t2_expanded + # dist_matrix = np.sqrt(np.sum(diff**2, axis=-1)) + dist_matrix = np.linalg.norm(diff, ord=norm_ord, axis=-1) + return dist_matrix + + +def frechet_distance(t1: np.ndarray, t2: np.ndarray, norm_ord=2) -> np.ndarray: + """ + Computes the discrete Fréchet distance between two batches of trajectories. + + The Fréchet distance is the minimum required "leash length" to connect two + points traversing their respective curves. It is a robust measure of + trajectory shape similarity. This implementation is vectorized across + any number of leading batch dimensions. + + Args: + t1 (np.ndarray): First batch of trajectories. Shape (..., H, d), where + ... is any number of batch dimensions, H is the horizon, and d is + the spatial dimension. + t2 (np.ndarray): Second batch of trajectories. Must have the same shape + as t1. + + Returns: + np.ndarray: A scalar distance for each pair in the batch. + Shape (...). + + Raises: + ValueError: If input shapes are not identical or have fewer than 2 dimensions. + """ + # --- Input Validation --- + if t1.shape != t2.shape: + raise ValueError(f"Input trajectory shapes must be identical, but got " + f"{t1.shape} and {t2.shape}") + if t1.ndim < 2: + raise ValueError(f"Input trajectories must have at least 2 dimensions " + f"(H, d), but got {t1.ndim}") + + # --- Initialization --- + H = t1.shape[-2] + batch_shape = t1.shape[:-2] + + # Calculate the pairwise distance matrix between all points + cost_matrix = _pairwise_distance_matrix(t1, t2, norm_ord=norm_ord) + + # Initialize the dynamic programming matrix for Fréchet distance + frechet_matrix = np.full(cost_matrix.shape, -1.0, dtype=np.float64) + + # --- Dynamic Programming Recurrence --- + # The loops over i and j are necessary for the DP algorithm. + # However, all operations inside the loops are vectorized over the batch dimensions. + + # Initialize the first cell + frechet_matrix[..., 0, 0] = cost_matrix[..., 0, 0] + + # Fill the first column + for i in range(1, H): + frechet_matrix[..., i, 0] = np.maximum( + frechet_matrix[..., i-1, 0], cost_matrix[..., i, 0] + ) + + # Fill the first row + for j in range(1, H): + frechet_matrix[..., 0, j] = np.maximum( + frechet_matrix[..., 0, j-1], cost_matrix[..., 0, j] + ) + + # Fill the rest of the matrix + for i in range(1, H): + for j in range(1, H): + min_prev = np.minimum( + frechet_matrix[..., i-1, j], + np.minimum(frechet_matrix[..., i, j-1], frechet_matrix[..., i-1, j-1]) + ) + frechet_matrix[..., i, j] = np.maximum(cost_matrix[..., i, j], min_prev) + + # The Fréchet distance is the value in the top-right cell + return frechet_matrix[..., H-1, H-1] + + +def dtw_distance(t1: np.ndarray, t2: np.ndarray, dist_norm_ord=2, norm_type='path') -> np.ndarray: + """ + Computes the Dynamic Time Warping (DTW) distance between two batches of trajectories. + + DTW finds the optimal non-linear alignment between two time series, + minimizing the cumulative distance. It is useful for comparing trajectories + with similar shapes but different speeds or temporal alignments. This + implementation is vectorized across any number of leading batch dimensions. + + Args: + t1 (np.ndarray): First batch of trajectories. Shape (..., H, d), where + `...` is any number of batch dimensions, H is the horizon, and d is + the spatial dimension. + t2 (np.ndarray): Second batch of trajectories. Must have the same shape + as t1. + dist_norm_ord (int or str): The order of the norm to use for distance calculation. + Can be a positive integer or 'fro' for Frobenius norm, 'nuc' for nuclear norm, or None for L2 norm. + norm_type (str): The type of normalization to apply to the DTW distance. + Can be 'raw' (no normalization), 'path' (normalized by path length), or 'symmetric' + (normalized by the sum of the lengths of both trajectories). + + Returns: + np.ndarray: A scalar distance for each pair in the batch. + Shape (...). + + Raises: + ValueError: If input shapes are not identical or have fewer than 2 dimensions. + """ + # --- Input Validation --- + if t1.shape != t2.shape: + raise ValueError(f"Input trajectory shapes must be identical, but got " + f"{t1.shape} and {t2.shape}") + if t1.ndim < 2: + raise ValueError(f"Input trajectories must have at least 2 dimensions " + f"(H, d), but got {t1.ndim}") + + assert norm_type in ['raw', 'path', 'symmetric'], \ + f"Invalid norm_type '{norm_type}'. Must be one of 'raw', 'path', or 'symmetric'." + + # --- Initialization --- + H = t1.shape[-2] + batch_shape = t1.shape[:-2] + + cost_matrix = _pairwise_distance_matrix(t1, t2, norm_ord=dist_norm_ord) + dtw_matrix = np.full(cost_matrix.shape, np.inf, dtype=np.float64) + path_len_matrix = np.zeros(cost_matrix.shape, dtype=np.int32) + + dtw_matrix[..., 0, 0] = cost_matrix[..., 0, 0] + path_len_matrix[..., 0, 0] = 1 + + for i in range(1, H): + dtw_matrix[..., i, 0] = cost_matrix[..., i, 0] + dtw_matrix[..., i-1, 0] + path_len_matrix[..., i, 0] = 1 + path_len_matrix[..., i-1, 0] + for j in range(1, H): + dtw_matrix[..., 0, j] = cost_matrix[..., 0, j] + dtw_matrix[..., 0, j-1] + path_len_matrix[..., 0, j] = 1 + path_len_matrix[..., 0, j-1] + + for i in range(1, H): + for j in range(1, H): + prev_costs_stack = np.stack( + [dtw_matrix[..., i-1, j-1], dtw_matrix[..., i-1, j], dtw_matrix[..., i, j-1]], + axis=0 + ) + prev_path_lens_stack = np.stack( + [path_len_matrix[..., i-1, j-1], path_len_matrix[..., i-1, j], path_len_matrix[..., i, j-1]], + axis=0 + ) + + # 1. Find the index of the minimum cost neighbor ONCE. + min_indices = np.argmin(prev_costs_stack, axis=0) + + # 2. Use these indices to gather the minimum costs. + min_costs = np.take_along_axis( + prev_costs_stack, min_indices[np.newaxis, ...], axis=0 + ).squeeze(axis=0) + + # 3. Use the same indices to gather the corresponding path lengths. + chosen_prev_len = np.take_along_axis( + prev_path_lens_stack, min_indices[np.newaxis, ...], axis=0 + ).squeeze(axis=0) + + # 4. Update the matrices. + dtw_matrix[..., i, j] = cost_matrix[..., i, j] + min_costs + path_len_matrix[..., i, j] = 1 + chosen_prev_len + + raw_score = dtw_matrix[..., -1, -1] + + if norm_type == 'raw': + return raw_score + elif norm_type == 'path': + path_len = path_len_matrix[..., -1, -1] + return np.divide(raw_score, path_len, out=np.zeros_like(raw_score), where=path_len!=0) + elif norm_type == 'symmetric': + return raw_score / (H + t2.shape[-2]) + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/json_logger.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/json_logger.py new file mode 100644 index 0000000000000000000000000000000000000000..ee9fb4af04d341cf49f59065917b71c4c3ce8d31 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/json_logger.py @@ -0,0 +1,117 @@ +from typing import Optional, Callable, Any, Sequence +import os +import copy +import json +import numbers +import pandas as pd + + +def read_json_log(path: str, + required_keys: Sequence[str]=tuple(), + **kwargs) -> pd.DataFrame: + """ + Read json-per-line file, with potentially incomplete lines. + kwargs passed to pd.read_json + """ + lines = list() + with open(path, 'r') as f: + while True: + # one json per line + line = f.readline() + if len(line) == 0: + # EOF + break + elif not line.endswith('\n'): + # incomplete line + break + is_relevant = False + for k in required_keys: + if k in line: + is_relevant = True + break + if is_relevant: + lines.append(line) + if len(lines) < 1: + return pd.DataFrame() + json_buf = f'[{",".join([line for line in (line.strip() for line in lines) if line])}]' + df = pd.read_json(json_buf, **kwargs) + return df + +class JsonLogger: + def __init__(self, path: str, + filter_fn: Optional[Callable[[str,Any],bool]]=None): + if filter_fn is None: + filter_fn = lambda k,v: isinstance(v, numbers.Number) + + # default to append mode + self.path = path + self.filter_fn = filter_fn + self.file = None + self.last_log = None + + def start(self): + # use line buffering + try: + self.file = file = open(self.path, 'r+', buffering=1) + except FileNotFoundError: + self.file = file = open(self.path, 'w+', buffering=1) + + # Move the pointer (similar to a cursor in a text editor) to the end of the file + pos = file.seek(0, os.SEEK_END) + + # Read each character in the file one at a time from the last + # character going backwards, searching for a newline character + # If we find a new line, exit the search + while pos > 0 and file.read(1) != "\n": + pos -= 1 + file.seek(pos, os.SEEK_SET) + # now the file pointer is at one past the last '\n' + # and pos is at the last '\n'. + last_line_end = file.tell() + + # find the start of second last line + pos = max(0, pos-1) + file.seek(pos, os.SEEK_SET) + while pos > 0 and file.read(1) != "\n": + pos -= 1 + file.seek(pos, os.SEEK_SET) + # now the file pointer is at one past the second last '\n' + last_line_start = file.tell() + + if last_line_start < last_line_end: + # has last line of json + last_line = file.readline() + self.last_log = json.loads(last_line) + + # remove the last incomplete line + file.seek(last_line_end) + file.truncate() + + def stop(self): + self.file.close() + self.file = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + def log(self, data: dict): + filtered_data = dict( + filter(lambda x: self.filter_fn(*x), data.items())) + # save current as last log + self.last_log = filtered_data + for k, v in filtered_data.items(): + if isinstance(v, numbers.Integral): + filtered_data[k] = int(v) + elif isinstance(v, numbers.Number): + filtered_data[k] = float(v) + buf = json.dumps(filtered_data) + # ensure one line per json + buf = buf.replace('\n','') + '\n' + self.file.write(buf) + + def get_last_log(self): + return copy.deepcopy(self.last_log) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ltl_parser.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ltl_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..2ee0d0560c7ae2fde73e694a368e9c66e07a21ea --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/ltl_parser.py @@ -0,0 +1,175 @@ +from typing import Any, List +import pdb + +class LTLParseError(Exception): + """Custom exception for LTL parsing errors.""" + pass + +class LTLParser: + def __init__(self, propositions:List[str]): + self.props = list(set(list(propositions+['True', 'False', '1', '0']))) + self.operators = {'&': 'and', '|': 'or', '~': 'not', '!': 'not', 'X': 'next', 'U': 'until', 'F': 'eventually', 'G': 'always'} + self.operators = {'&': 'and', '|': 'or', '~': 'not', '!': 'not', 'X': 'next', 'U': 'until', 'G': 'always'} + self.precedence = {'~': 3, '!': 3, 'X': 3, 'F': 3, 'G': 3, '&': 2, '|': 2, 'U': 1} + + + def __call__(self, *args, **kwargs): + return self.parse(*args, **kwargs) + + + def reverse_infix(self, infix): + """Reverses the infix expression for conversion.""" + reversed_infix = [] + for token in reversed(infix): + if token == '(': + reversed_infix.append(')') + elif token == ')': + reversed_infix.append('(') + else: + reversed_infix.append(token) + #end for + return reversed_infix + + def reversed_infix_to_postfix(self, reversed_infix): + """Converts a reversed infix expression to a postfix expression.""" + stack = [] + postfix = [] + for token in reversed_infix: + if token in self.props: + postfix.append(token) + elif token in self.operators: + while stack and self.precedence.get(stack[-1], 0) >= self.precedence.get(token, 0): + postfix.append(stack.pop()) + stack.append(token) + elif token == '(': + stack.append(token) + elif token == ')': + while stack and stack[-1] != '(': + postfix.append(stack.pop()) + stack.pop() # Pop '(' from stack + #end for + while stack: + postfix.append(stack.pop()) + #end while + + return postfix + + + def tokenize(self, rule_str): + """Converts the rule string into a list of tokens.""" + tokens = [] + i = 0 + while i < len(rule_str): + if rule_str[i] in self.operators: + tokens.append(rule_str[i]) + i += 1 + elif rule_str[i] == '(' or rule_str[i] == ')': + tokens.append(rule_str[i]) + i += 1 + elif rule_str[i].isspace(): + i += 1 + else: + # Propositions + start = i + while i < len(rule_str) and rule_str[i].isalnum(): + i += 1 + token = rule_str[start:i] + if token in self.props: + tokens.append(token) + else: + raise LTLParseError(f"Undeclared proposition \'{token}\'!") + #end while + + return tokens + + + def postfix_to_tuple(self, postfix_expression): + """Converts a postfix expression to a nested tuple.""" + stack = [] + # pdb.set_trace() + for token in postfix_expression: + if token in self.operators: + # Convert operator symbols to names + token = self.operators[token] + if token in ['and', 'or', 'until']: + # These operators require two operands + if len(stack) < 2: + raise LTLParseError("Insufficient operands for binary operator") + operand1 = stack.pop() + operand2 = stack.pop() + stack.append((token, operand1, operand2)) + elif token in ['not', 'next', 'eventually', 'always']: + # These operators require one operand + if not stack: + raise LTLParseError("Insufficient operands for unary operator") + operand = stack.pop() + stack.append((token, operand)) + else: + # Propositions + stack.append(token) + + if len(stack) != 1: + raise LTLParseError("Invalid expression") + return stack.pop() + + + def parse(self, rule_str): + infix = self.tokenize(rule_str) + reversed_infix = self.reverse_infix(infix) + postfix = self.reversed_infix_to_postfix(reversed_infix) + # prefix = list(reversed(postfix)) + tup = self.postfix_to_tuple(postfix) + + return tup + + +def test_single(): + props = ['p1', 'p2', 'p3'] + parser = LTLParser(props) + + ltls = ["(~p2 U p1)"] + # rule_str = 'G ( (p1 & p2) U ~p3 )' + # # rule_str = "(p1 & p2)" + + try: + tup = parser.parse(ltls[0]) + except LTLParseError as e: + tup = str(e) + + print(tup) + + +def test(): + from tqdm import tqdm + # file_path = 'LTL_dataset/3_10/formula.txt' + file_path = 'LTL_dataset/6_20/formula.txt' + file_path = 'datasets/ltls.txt' + with open(file_path, 'r') as file: + formulas = file.readlines() + + print(f"Read in total {len(formulas)} rules.") + props = ['p0', 'p1', 'p2'] + parser = LTLParser(props) + + success = 0 + fail = 0 + for idx, rule_str in enumerate(tqdm(formulas)): + try: + tup = parser.parse(rule_str) + print(tup) + except LTLParseError as e: + err_msg = str(e) + fail += 1 + print(f"Case {idx} test failed!") + print(f"rule_str = {rule_str}") + print(f"Exception encountered: {err_msg}\n") + continue + success += 1 + # print(f'{idx} success: rule = {rule_str}') + + print(f"Success rate = {success/len(formulas)}") + + +if __name__ == '__main__': + # test_single() + test() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/nested_dict_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/nested_dict_util.py new file mode 100644 index 0000000000000000000000000000000000000000..b186f707f818a156d82b5b3e3d9837f1de6b9aa5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/nested_dict_util.py @@ -0,0 +1,32 @@ +import functools + +def nested_dict_map(f, x): + """ + Map f over all leaf of nested dict x + """ + + if not isinstance(x, dict): + return f(x) + y = dict() + for key, value in x.items(): + y[key] = nested_dict_map(f, value) + return y + +def nested_dict_reduce(f, x): + """ + Map f over all values of nested dict x, and reduce to a single value + """ + if not isinstance(x, dict): + return x + + reduced_values = list() + for value in x.values(): + reduced_values.append(nested_dict_reduce(f, value)) + y = functools.reduce(f, reduced_values) + return y + + +def nested_dict_check(f, x): + bool_dict = nested_dict_map(f, x) + result = nested_dict_reduce(lambda x, y: x and y, bool_dict) + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/normalize_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/normalize_util.py new file mode 100644 index 0000000000000000000000000000000000000000..d6fc7b6680fe553d718c22f4b2607aca046d14a8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/normalize_util.py @@ -0,0 +1,223 @@ +from diffusion_policy.model.common.normalizer import SingleFieldLinearNormalizer +from diffusion_policy.common.pytorch_util import dict_apply, dict_apply_reduce, dict_apply_split +import numpy as np + + +def get_range_normalizer_from_stat(stat, output_max=1, output_min=-1, range_eps=1e-7): + # -1, 1 normalization + input_max = stat['max'] + input_min = stat['min'] + input_range = input_max - input_min + ignore_dim = input_range < range_eps + input_range[ignore_dim] = output_max - output_min + scale = (output_max - output_min) / input_range + offset = output_min - scale * input_min + offset[ignore_dim] = (output_max + output_min) / 2 - input_min[ignore_dim] + + return SingleFieldLinearNormalizer.create_manual( + scale=scale, + offset=offset, + input_stats_dict=stat + ) + +def get_image_range_normalizer(): + scale = np.array([2], dtype=np.float32) + offset = np.array([-1], dtype=np.float32) + stat = { + 'min': np.array([0], dtype=np.float32), + 'max': np.array([1], dtype=np.float32), + 'mean': np.array([0.5], dtype=np.float32), + 'std': np.array([np.sqrt(1/12)], dtype=np.float32) + } + return SingleFieldLinearNormalizer.create_manual( + scale=scale, + offset=offset, + input_stats_dict=stat + ) + +def get_identity_normalizer_from_stat(stat): + scale = np.ones_like(stat['min']) + offset = np.zeros_like(stat['min']) + return SingleFieldLinearNormalizer.create_manual( + scale=scale, + offset=offset, + input_stats_dict=stat + ) + +def robomimic_abs_action_normalizer_from_stat(stat, rotation_transformer): + result = dict_apply_split( + stat, lambda x: { + 'pos': x[...,:3], + 'rot': x[...,3:6], + 'gripper': x[...,6:] + }) + + def get_pos_param_info(stat, output_max=1, output_min=-1, range_eps=1e-7): + # -1, 1 normalization + input_max = stat['max'] + input_min = stat['min'] + input_range = input_max - input_min + ignore_dim = input_range < range_eps + input_range[ignore_dim] = output_max - output_min + scale = (output_max - output_min) / input_range + offset = output_min - scale * input_min + offset[ignore_dim] = (output_max + output_min) / 2 - input_min[ignore_dim] + + return {'scale': scale, 'offset': offset}, stat + + def get_rot_param_info(stat): + example = rotation_transformer.forward(stat['mean']) + scale = np.ones_like(example) + offset = np.zeros_like(example) + info = { + 'max': np.ones_like(example), + 'min': np.full_like(example, -1), + 'mean': np.zeros_like(example), + 'std': np.ones_like(example) + } + return {'scale': scale, 'offset': offset}, info + + def get_gripper_param_info(stat): + example = stat['max'] + scale = np.ones_like(example) + offset = np.zeros_like(example) + info = { + 'max': np.ones_like(example), + 'min': np.full_like(example, -1), + 'mean': np.zeros_like(example), + 'std': np.ones_like(example) + } + return {'scale': scale, 'offset': offset}, info + + pos_param, pos_info = get_pos_param_info(result['pos']) + rot_param, rot_info = get_rot_param_info(result['rot']) + gripper_param, gripper_info = get_gripper_param_info(result['gripper']) + + param = dict_apply_reduce( + [pos_param, rot_param, gripper_param], + lambda x: np.concatenate(x,axis=-1)) + info = dict_apply_reduce( + [pos_info, rot_info, gripper_info], + lambda x: np.concatenate(x,axis=-1)) + + return SingleFieldLinearNormalizer.create_manual( + scale=param['scale'], + offset=param['offset'], + input_stats_dict=info + ) + + +def robomimic_abs_action_only_normalizer_from_stat(stat): + result = dict_apply_split( + stat, lambda x: { + 'pos': x[...,:3], + 'other': x[...,3:] + }) + + def get_pos_param_info(stat, output_max=1, output_min=-1, range_eps=1e-7): + # -1, 1 normalization + input_max = stat['max'] + input_min = stat['min'] + input_range = input_max - input_min + ignore_dim = input_range < range_eps + input_range[ignore_dim] = output_max - output_min + scale = (output_max - output_min) / input_range + offset = output_min - scale * input_min + offset[ignore_dim] = (output_max + output_min) / 2 - input_min[ignore_dim] + + return {'scale': scale, 'offset': offset}, stat + + + def get_other_param_info(stat): + example = stat['max'] + scale = np.ones_like(example) + offset = np.zeros_like(example) + info = { + 'max': np.ones_like(example), + 'min': np.full_like(example, -1), + 'mean': np.zeros_like(example), + 'std': np.ones_like(example) + } + return {'scale': scale, 'offset': offset}, info + + pos_param, pos_info = get_pos_param_info(result['pos']) + other_param, other_info = get_other_param_info(result['other']) + + param = dict_apply_reduce( + [pos_param, other_param], + lambda x: np.concatenate(x,axis=-1)) + info = dict_apply_reduce( + [pos_info, other_info], + lambda x: np.concatenate(x,axis=-1)) + + return SingleFieldLinearNormalizer.create_manual( + scale=param['scale'], + offset=param['offset'], + input_stats_dict=info + ) + + +def robomimic_abs_action_only_dual_arm_normalizer_from_stat(stat): + Da = stat['max'].shape[-1] + Dah = Da // 2 + result = dict_apply_split( + stat, lambda x: { + 'pos0': x[...,:3], + 'other0': x[...,3:Dah], + 'pos1': x[...,Dah:Dah+3], + 'other1': x[...,Dah+3:] + }) + + def get_pos_param_info(stat, output_max=1, output_min=-1, range_eps=1e-7): + # -1, 1 normalization + input_max = stat['max'] + input_min = stat['min'] + input_range = input_max - input_min + ignore_dim = input_range < range_eps + input_range[ignore_dim] = output_max - output_min + scale = (output_max - output_min) / input_range + offset = output_min - scale * input_min + offset[ignore_dim] = (output_max + output_min) / 2 - input_min[ignore_dim] + + return {'scale': scale, 'offset': offset}, stat + + + def get_other_param_info(stat): + example = stat['max'] + scale = np.ones_like(example) + offset = np.zeros_like(example) + info = { + 'max': np.ones_like(example), + 'min': np.full_like(example, -1), + 'mean': np.zeros_like(example), + 'std': np.ones_like(example) + } + return {'scale': scale, 'offset': offset}, info + + pos0_param, pos0_info = get_pos_param_info(result['pos0']) + pos1_param, pos1_info = get_pos_param_info(result['pos1']) + other0_param, other0_info = get_other_param_info(result['other0']) + other1_param, other1_info = get_other_param_info(result['other1']) + + param = dict_apply_reduce( + [pos0_param, other0_param, pos1_param, other1_param], + lambda x: np.concatenate(x,axis=-1)) + info = dict_apply_reduce( + [pos0_info, other0_info, pos1_info, other1_info], + lambda x: np.concatenate(x,axis=-1)) + + return SingleFieldLinearNormalizer.create_manual( + scale=param['scale'], + offset=param['offset'], + input_stats_dict=info + ) + + +def array_to_stats(arr: np.ndarray): + stat = { + 'min': np.min(arr, axis=0), + 'max': np.max(arr, axis=0), + 'mean': np.mean(arr, axis=0), + 'std': np.std(arr, axis=0) + } + return stat diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pose_trajectory_interpolator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pose_trajectory_interpolator.py new file mode 100644 index 0000000000000000000000000000000000000000..3e37397e28d9feefd2263819058dc1090a82a1d7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pose_trajectory_interpolator.py @@ -0,0 +1,208 @@ +from typing import Union +import numbers +import numpy as np +import scipy.interpolate as si +import scipy.spatial.transform as st + +def rotation_distance(a: st.Rotation, b: st.Rotation) -> float: + return (b * a.inv()).magnitude() + +def pose_distance(start_pose, end_pose): + start_pose = np.array(start_pose) + end_pose = np.array(end_pose) + start_pos = start_pose[:3] + end_pos = end_pose[:3] + start_rot = st.Rotation.from_rotvec(start_pose[3:]) + end_rot = st.Rotation.from_rotvec(end_pose[3:]) + pos_dist = np.linalg.norm(end_pos - start_pos) + rot_dist = rotation_distance(start_rot, end_rot) + return pos_dist, rot_dist + +class PoseTrajectoryInterpolator: + def __init__(self, times: np.ndarray, poses: np.ndarray): + assert len(times) >= 1 + assert len(poses) == len(times) + if not isinstance(times, np.ndarray): + times = np.array(times) + if not isinstance(poses, np.ndarray): + poses = np.array(poses) + + if len(times) == 1: + # special treatment for single step interpolation + self.single_step = True + self._times = times + self._poses = poses + else: + self.single_step = False + assert np.all(times[1:] >= times[:-1]) + + pos = poses[:,:3] + rot = st.Rotation.from_rotvec(poses[:,3:]) + + self.pos_interp = si.interp1d(times, pos, + axis=0, assume_sorted=True) + self.rot_interp = st.Slerp(times, rot) + + @property + def times(self) -> np.ndarray: + if self.single_step: + return self._times + else: + return self.pos_interp.x + + @property + def poses(self) -> np.ndarray: + if self.single_step: + return self._poses + else: + n = len(self.times) + poses = np.zeros((n, 6)) + poses[:,:3] = self.pos_interp.y + poses[:,3:] = self.rot_interp(self.times).as_rotvec() + return poses + + def trim(self, + start_t: float, end_t: float + ) -> "PoseTrajectoryInterpolator": + assert start_t <= end_t + times = self.times + should_keep = (start_t < times) & (times < end_t) + keep_times = times[should_keep] + all_times = np.concatenate([[start_t], keep_times, [end_t]]) + # remove duplicates, Slerp requires strictly increasing x + all_times = np.unique(all_times) + # interpolate + all_poses = self(all_times) + return PoseTrajectoryInterpolator(times=all_times, poses=all_poses) + + def drive_to_waypoint(self, + pose, time, curr_time, + max_pos_speed=np.inf, + max_rot_speed=np.inf + ) -> "PoseTrajectoryInterpolator": + assert(max_pos_speed > 0) + assert(max_rot_speed > 0) + time = max(time, curr_time) + + curr_pose = self(curr_time) + pos_dist, rot_dist = pose_distance(curr_pose, pose) + pos_min_duration = pos_dist / max_pos_speed + rot_min_duration = rot_dist / max_rot_speed + duration = time - curr_time + duration = max(duration, max(pos_min_duration, rot_min_duration)) + assert duration >= 0 + last_waypoint_time = curr_time + duration + + # insert new pose + trimmed_interp = self.trim(curr_time, curr_time) + times = np.append(trimmed_interp.times, [last_waypoint_time], axis=0) + poses = np.append(trimmed_interp.poses, [pose], axis=0) + + # create new interpolator + final_interp = PoseTrajectoryInterpolator(times, poses) + return final_interp + + def schedule_waypoint(self, + pose, time, + max_pos_speed=np.inf, + max_rot_speed=np.inf, + curr_time=None, + last_waypoint_time=None + ) -> "PoseTrajectoryInterpolator": + assert(max_pos_speed > 0) + assert(max_rot_speed > 0) + if last_waypoint_time is not None: + assert curr_time is not None + + # trim current interpolator to between curr_time and last_waypoint_time + start_time = self.times[0] + end_time = self.times[-1] + assert start_time <= end_time + + if curr_time is not None: + if time <= curr_time: + # if insert time is earlier than current time + # no effect should be done to the interpolator + return self + # now, curr_time < time + start_time = max(curr_time, start_time) + + if last_waypoint_time is not None: + # if last_waypoint_time is earlier than start_time + # use start_time + if time <= last_waypoint_time: + end_time = curr_time + else: + end_time = max(last_waypoint_time, curr_time) + else: + end_time = curr_time + + end_time = min(end_time, time) + start_time = min(start_time, end_time) + # end time should be the latest of all times except time + # after this we can assume order (proven by zhenjia, due to the 2 min operations) + + # Constraints: + # start_time <= end_time <= time (proven by zhenjia) + # curr_time <= start_time (proven by zhenjia) + # curr_time <= time (proven by zhenjia) + + # time can't change + # last_waypoint_time can't change + # curr_time can't change + assert start_time <= end_time + assert end_time <= time + if last_waypoint_time is not None: + if time <= last_waypoint_time: + assert end_time == curr_time + else: + assert end_time == max(last_waypoint_time, curr_time) + + if curr_time is not None: + assert curr_time <= start_time + assert curr_time <= time + + trimmed_interp = self.trim(start_time, end_time) + # after this, all waypoints in trimmed_interp is within start_time and end_time + # and is earlier than time + + # determine speed + duration = time - end_time + end_pose = trimmed_interp(end_time) + pos_dist, rot_dist = pose_distance(pose, end_pose) + pos_min_duration = pos_dist / max_pos_speed + rot_min_duration = rot_dist / max_rot_speed + duration = max(duration, max(pos_min_duration, rot_min_duration)) + assert duration >= 0 + last_waypoint_time = end_time + duration + + # insert new pose + times = np.append(trimmed_interp.times, [last_waypoint_time], axis=0) + poses = np.append(trimmed_interp.poses, [pose], axis=0) + + # create new interpolator + final_interp = PoseTrajectoryInterpolator(times, poses) + return final_interp + + + def __call__(self, t: Union[numbers.Number, np.ndarray]) -> np.ndarray: + is_single = False + if isinstance(t, numbers.Number): + is_single = True + t = np.array([t]) + + pose = np.zeros((len(t), 6)) + if self.single_step: + pose[:] = self._poses[0] + else: + start_time = self.times[0] + end_time = self.times[-1] + t = np.clip(t, start_time, end_time) + + pose = np.zeros((len(t), 6)) + pose[:,:3] = self.pos_interp(t) + pose[:,3:] = self.rot_interp(t).as_rotvec() + + if is_single: + pose = pose[0] + return pose diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/precise_sleep.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/precise_sleep.py new file mode 100644 index 0000000000000000000000000000000000000000..83ef62c5d3f42313508a3408451c6ede02bdc41a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/precise_sleep.py @@ -0,0 +1,25 @@ +import time + +def precise_sleep(dt: float, slack_time: float=0.001, time_func=time.monotonic): + """ + Use hybrid of time.sleep and spinning to minimize jitter. + Sleep dt - slack_time seconds first, then spin for the rest. + """ + t_start = time_func() + if dt > slack_time: + time.sleep(dt - slack_time) + t_end = t_start + dt + while time_func() < t_end: + pass + return + +def precise_wait(t_end: float, slack_time: float=0.001, time_func=time.monotonic): + t_start = time_func() + t_wait = t_end - t_start + if t_wait > 0: + t_sleep = t_wait - slack_time + if t_sleep > 0: + time.sleep(t_sleep) + while time_func() < t_end: + pass + return diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_override.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_override.py new file mode 100644 index 0000000000000000000000000000000000000000..2439020a13e01ad48f3677919157bb7e49e50569 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_override.py @@ -0,0 +1,248 @@ +# ---------------------------------------------------------------------------- +# pymunk +# Copyright (c) 2007-2016 Victor Blomqvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ---------------------------------------------------------------------------- + +"""This submodule contains helper functions to help with quick prototyping +using pymunk together with pygame. + +Intended to help with debugging and prototyping, not for actual production use +in a full application. The methods contained in this module is opinionated +about your coordinate system and not in any way optimized. +""" + +__docformat__ = "reStructuredText" + +__all__ = [ + "DrawOptions", + "get_mouse_pos", + "to_pygame", + "from_pygame", + "lighten", + "positive_y_is_up", +] + +from typing import List, Sequence, Tuple + +import pygame + +import numpy as np + +import pymunk +from pymunk.space_debug_draw_options import SpaceDebugColor +from pymunk.vec2d import Vec2d + +positive_y_is_up: bool = False +"""Make increasing values of y point upwards. + +When True:: + + y + ^ + | . (3, 3) + | + | . (2, 2) + | + +------ > x + +When False:: + + +------ > x + | + | . (2, 2) + | + | . (3, 3) + v + y + +""" + + +class DrawOptions(pymunk.SpaceDebugDrawOptions): + def __init__(self, surface: pygame.Surface) -> None: + """Draw a pymunk.Space on a pygame.Surface object. + + Typical usage:: + + >>> import pymunk + >>> surface = pygame.Surface((10,10)) + >>> space = pymunk.Space() + >>> options = pymunk.pygame_util.DrawOptions(surface) + >>> space.debug_draw(options) + + You can control the color of a shape by setting shape.color to the color + you want it drawn in:: + + >>> c = pymunk.Circle(None, 10) + >>> c.color = pygame.Color("pink") + + See pygame_util.demo.py for a full example + + Since pygame uses a coordinate system where y points down (in contrast + to many other cases), you either have to make the physics simulation + with Pymunk also behave in that way, or flip everything when you draw. + + The easiest is probably to just make the simulation behave the same + way as Pygame does. In that way all coordinates used are in the same + orientation and easy to reason about:: + + >>> space = pymunk.Space() + >>> space.gravity = (0, -1000) + >>> body = pymunk.Body() + >>> body.position = (0, 0) # will be positioned in the top left corner + >>> space.debug_draw(options) + + To flip the drawing its possible to set the module property + :py:data:`positive_y_is_up` to True. Then the pygame drawing will flip + the simulation upside down before drawing:: + + >>> positive_y_is_up = True + >>> body = pymunk.Body() + >>> body.position = (0, 0) + >>> # Body will be position in bottom left corner + + :Parameters: + surface : pygame.Surface + Surface that the objects will be drawn on + """ + self.surface = surface + super(DrawOptions, self).__init__() + + def draw_circle( + self, + pos: Vec2d, + angle: float, + radius: float, + outline_color: SpaceDebugColor, + fill_color: SpaceDebugColor, + ) -> None: + p = to_pygame(pos, self.surface) + + pygame.draw.circle(self.surface, fill_color.as_int(), p, round(radius), 0) + pygame.draw.circle(self.surface, light_color(fill_color).as_int(), p, round(radius-4), 0) + + circle_edge = pos + Vec2d(radius, 0).rotated(angle) + p2 = to_pygame(circle_edge, self.surface) + line_r = 2 if radius > 20 else 1 + # pygame.draw.lines(self.surface, outline_color.as_int(), False, [p, p2], line_r) + + def draw_segment(self, a: Vec2d, b: Vec2d, color: SpaceDebugColor) -> None: + p1 = to_pygame(a, self.surface) + p2 = to_pygame(b, self.surface) + + pygame.draw.aalines(self.surface, color.as_int(), False, [p1, p2]) + + def draw_fat_segment( + self, + a: Tuple[float, float], + b: Tuple[float, float], + radius: float, + outline_color: SpaceDebugColor, + fill_color: SpaceDebugColor, + ) -> None: + p1 = to_pygame(a, self.surface) + p2 = to_pygame(b, self.surface) + + r = round(max(1, radius * 2)) + pygame.draw.lines(self.surface, fill_color.as_int(), False, [p1, p2], r) + if r > 2: + orthog = [abs(p2[1] - p1[1]), abs(p2[0] - p1[0])] + if orthog[0] == 0 and orthog[1] == 0: + return + scale = radius / (orthog[0] * orthog[0] + orthog[1] * orthog[1]) ** 0.5 + orthog[0] = round(orthog[0] * scale) + orthog[1] = round(orthog[1] * scale) + points = [ + (p1[0] - orthog[0], p1[1] - orthog[1]), + (p1[0] + orthog[0], p1[1] + orthog[1]), + (p2[0] + orthog[0], p2[1] + orthog[1]), + (p2[0] - orthog[0], p2[1] - orthog[1]), + ] + pygame.draw.polygon(self.surface, fill_color.as_int(), points) + pygame.draw.circle( + self.surface, + fill_color.as_int(), + (round(p1[0]), round(p1[1])), + round(radius), + ) + pygame.draw.circle( + self.surface, + fill_color.as_int(), + (round(p2[0]), round(p2[1])), + round(radius), + ) + + def draw_polygon( + self, + verts: Sequence[Tuple[float, float]], + radius: float, + outline_color: SpaceDebugColor, + fill_color: SpaceDebugColor, + ) -> None: + ps = [to_pygame(v, self.surface) for v in verts] + ps += [ps[0]] + + radius = 2 + pygame.draw.polygon(self.surface, light_color(fill_color).as_int(), ps) + + if radius > 0: + for i in range(len(verts)): + a = verts[i] + b = verts[(i + 1) % len(verts)] + self.draw_fat_segment(a, b, radius, fill_color, fill_color) + + def draw_dot( + self, size: float, pos: Tuple[float, float], color: SpaceDebugColor + ) -> None: + p = to_pygame(pos, self.surface) + pygame.draw.circle(self.surface, color.as_int(), p, round(size), 0) + + +def get_mouse_pos(surface: pygame.Surface) -> Tuple[int, int]: + """Get position of the mouse pointer in pymunk coordinates.""" + p = pygame.mouse.get_pos() + return from_pygame(p, surface) + + +def to_pygame(p: Tuple[float, float], surface: pygame.Surface) -> Tuple[int, int]: + """Convenience method to convert pymunk coordinates to pygame surface + local coordinates. + + Note that in case positive_y_is_up is False, this function won't actually do + anything except converting the point to integers. + """ + if positive_y_is_up: + return round(p[0]), surface.get_height() - round(p[1]) + else: + return round(p[0]), round(p[1]) + + +def from_pygame(p: Tuple[float, float], surface: pygame.Surface) -> Tuple[int, int]: + """Convenience method to convert pygame surface local coordinates to + pymunk coordinates + """ + return to_pygame(p, surface) + + +def light_color(color: SpaceDebugColor): + color = np.minimum(1.2 * np.float32([color.r, color.g, color.b, color.a]), np.float32([255])) + color = SpaceDebugColor(r=color[0], g=color[1], b=color[2], a=color[3]) + return color diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_util.py new file mode 100644 index 0000000000000000000000000000000000000000..42459948573a52fa28ab28c56bcc95b43182cb24 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pymunk_util.py @@ -0,0 +1,52 @@ +import pygame +import pymunk +import pymunk.pygame_util +import numpy as np + +COLLTYPE_DEFAULT = 0 +COLLTYPE_MOUSE = 1 +COLLTYPE_BALL = 2 + +def get_body_type(static=False): + body_type = pymunk.Body.DYNAMIC + if static: + body_type = pymunk.Body.STATIC + return body_type + + +def create_rectangle(space, + pos_x,pos_y,width,height, + density=3,static=False): + body = pymunk.Body(body_type=get_body_type(static)) + body.position = (pos_x,pos_y) + shape = pymunk.Poly.create_box(body,(width,height)) + shape.density = density + space.add(body,shape) + return body, shape + + +def create_rectangle_bb(space, + left, bottom, right, top, + **kwargs): + pos_x = (left + right) / 2 + pos_y = (top + bottom) / 2 + height = top - bottom + width = right - left + return create_rectangle(space, pos_x, pos_y, width, height, **kwargs) + +def create_circle(space, pos_x, pos_y, radius, density=3, static=False): + body = pymunk.Body(body_type=get_body_type(static)) + body.position = (pos_x, pos_y) + shape = pymunk.Circle(body, radius=radius) + shape.density = density + shape.collision_type = COLLTYPE_BALL + space.add(body, shape) + return body, shape + +def get_body_state(body): + state = np.zeros(6, dtype=np.float32) + state[:2] = body.position + state[2] = body.angle + state[3:5] = body.velocity + state[5] = body.angular_velocity + return state diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pytorch_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pytorch_util.py new file mode 100644 index 0000000000000000000000000000000000000000..a9262d48568c20c2e2c2e762545c09079d1eed8c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/pytorch_util.py @@ -0,0 +1,82 @@ +from typing import Dict, Callable, List +import collections +import torch +import torch.nn as nn + +def dict_apply( + x: Dict[str, torch.Tensor], + func: Callable[[torch.Tensor], torch.Tensor] + ) -> Dict[str, torch.Tensor]: + result = dict() + for key, value in x.items(): + if isinstance(value, dict): + result[key] = dict_apply(value, func) + else: + result[key] = func(value) + return result + +def pad_remaining_dims(x, target): + assert x.shape == target.shape[:len(x.shape)] + return x.reshape(x.shape + (1,)*(len(target.shape) - len(x.shape))) + +def dict_apply_split( + x: Dict[str, torch.Tensor], + split_func: Callable[[torch.Tensor], Dict[str, torch.Tensor]] + ) -> Dict[str, torch.Tensor]: + results = collections.defaultdict(dict) + for key, value in x.items(): + result = split_func(value) + for k, v in result.items(): + results[k][key] = v + return results + +def dict_apply_reduce( + x: List[Dict[str, torch.Tensor]], + reduce_func: Callable[[List[torch.Tensor]], torch.Tensor] + ) -> Dict[str, torch.Tensor]: + result = dict() + for key in x[0].keys(): + result[key] = reduce_func([x_[key] for x_ in x]) + return result + + +def replace_submodules( + root_module: nn.Module, + predicate: Callable[[nn.Module], bool], + func: Callable[[nn.Module], nn.Module]) -> nn.Module: + """ + predicate: Return true if the module is to be replaced. + func: Return new module to use. + """ + if predicate(root_module): + return func(root_module) + + bn_list = [k.split('.') for k, m + in root_module.named_modules(remove_duplicate=True) + if predicate(m)] + for *parent, k in bn_list: + parent_module = root_module + if len(parent) > 0: + parent_module = root_module.get_submodule('.'.join(parent)) + if isinstance(parent_module, nn.Sequential): + src_module = parent_module[int(k)] + else: + src_module = getattr(parent_module, k) + tgt_module = func(src_module) + if isinstance(parent_module, nn.Sequential): + parent_module[int(k)] = tgt_module + else: + setattr(parent_module, k, tgt_module) + # verify that all BN are replaced + bn_list = [k.split('.') for k, m + in root_module.named_modules(remove_duplicate=True) + if predicate(m)] + assert len(bn_list) == 0 + return root_module + +def optimizer_to(optimizer, device): + for state in optimizer.state.values(): + for k, v in state.items(): + if isinstance(v, torch.Tensor): + state[k] = v.to(device=device) + return optimizer diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/replay_buffer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/replay_buffer.py new file mode 100644 index 0000000000000000000000000000000000000000..022a704ed44bb420887ee353dbcf3d3c57f50534 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/replay_buffer.py @@ -0,0 +1,588 @@ +from typing import Union, Dict, Optional +import os +import math +import numbers +import zarr +import numcodecs +import numpy as np +from functools import cached_property + +def check_chunks_compatible(chunks: tuple, shape: tuple): + assert len(shape) == len(chunks) + for c in chunks: + assert isinstance(c, numbers.Integral) + assert c > 0 + +def rechunk_recompress_array(group, name, + chunks=None, chunk_length=None, + compressor=None, tmp_key='_temp'): + old_arr = group[name] + if chunks is None: + if chunk_length is not None: + chunks = (chunk_length,) + old_arr.chunks[1:] + else: + chunks = old_arr.chunks + check_chunks_compatible(chunks, old_arr.shape) + + if compressor is None: + compressor = old_arr.compressor + + if (chunks == old_arr.chunks) and (compressor == old_arr.compressor): + # no change + return old_arr + + # rechunk recompress + group.move(name, tmp_key) + old_arr = group[tmp_key] + n_copied, n_skipped, n_bytes_copied = zarr.copy( + source=old_arr, + dest=group, + name=name, + chunks=chunks, + compressor=compressor, + ) + del group[tmp_key] + arr = group[name] + return arr + +def get_optimal_chunks(shape, dtype, + target_chunk_bytes=2e6, + max_chunk_length=None): + """ + Common shapes + T,D + T,N,D + T,H,W,C + T,N,H,W,C + """ + itemsize = np.dtype(dtype).itemsize + # reversed + rshape = list(shape[::-1]) + if max_chunk_length is not None: + rshape[-1] = int(max_chunk_length) + split_idx = len(shape)-1 + for i in range(len(shape)-1): + this_chunk_bytes = itemsize * np.prod(rshape[:i]) + next_chunk_bytes = itemsize * np.prod(rshape[:i+1]) + if this_chunk_bytes <= target_chunk_bytes \ + and next_chunk_bytes > target_chunk_bytes: + split_idx = i + + rchunks = rshape[:split_idx] + item_chunk_bytes = itemsize * np.prod(rshape[:split_idx]) + this_max_chunk_length = rshape[split_idx] + next_chunk_length = min(this_max_chunk_length, math.ceil( + target_chunk_bytes / item_chunk_bytes)) + rchunks.append(next_chunk_length) + len_diff = len(shape) - len(rchunks) + rchunks.extend([1] * len_diff) + chunks = tuple(rchunks[::-1]) + # print(np.prod(chunks) * itemsize / target_chunk_bytes) + return chunks + + +class ReplayBuffer: + """ + Zarr-based temporal datastructure. + Assumes first dimension to be time. Only chunk in time dimension. + """ + def __init__(self, + root: Union[zarr.Group, + Dict[str,dict]]): + """ + Dummy constructor. Use copy_from* and create_from* class methods instead. + """ + assert('data' in root) + assert('meta' in root) + assert('episode_ends' in root['meta']) + for key, value in root['data'].items(): + assert(value.shape[0] == root['meta']['episode_ends'][-1]) + self.root = root + + # ============= create constructors =============== + @classmethod + def create_empty_zarr(cls, storage=None, root=None): + if root is None: + if storage is None: + storage = zarr.MemoryStore() + root = zarr.group(store=storage) + data = root.require_group('data', overwrite=False) + meta = root.require_group('meta', overwrite=False) + if 'episode_ends' not in meta: + episode_ends = meta.zeros('episode_ends', shape=(0,), dtype=np.int64, + compressor=None, overwrite=False) + return cls(root=root) + + @classmethod + def create_empty_numpy(cls): + root = { + 'data': dict(), + 'meta': { + 'episode_ends': np.zeros((0,), dtype=np.int64) + } + } + return cls(root=root) + + @classmethod + def create_from_group(cls, group, **kwargs): + if 'data' not in group: + # create from stratch + buffer = cls.create_empty_zarr(root=group, **kwargs) + else: + # already exist + buffer = cls(root=group, **kwargs) + return buffer + + @classmethod + def create_from_path(cls, zarr_path, mode='r', **kwargs): + """ + Open a on-disk zarr directly (for dataset larger than memory). + Slower. + """ + group = zarr.open(os.path.expanduser(zarr_path), mode) + return cls.create_from_group(group, **kwargs) + + # ============= copy constructors =============== + @classmethod + def copy_from_store(cls, src_store, store=None, keys=None, + chunks: Dict[str,tuple]=dict(), + compressors: Union[dict, str, numcodecs.abc.Codec]=dict(), + if_exists='replace', + **kwargs): + """ + Load to memory. + """ + src_root = zarr.group(src_store) + root = None + if store is None: + # numpy backend + meta = dict() + for key, value in src_root['meta'].items(): + if len(value.shape) == 0: + meta[key] = np.array(value) + else: + meta[key] = value[:] + + if keys is None: + keys = src_root['data'].keys() + data = dict() + for key in keys: + arr = src_root['data'][key] + data[key] = arr[:] + + root = { + 'meta': meta, + 'data': data + } + else: + root = zarr.group(store=store) + # copy without recompression + n_copied, n_skipped, n_bytes_copied = zarr.copy_store(source=src_store, dest=store, + source_path='/meta', dest_path='/meta', if_exists=if_exists) + data_group = root.create_group('data', overwrite=True) + if keys is None: + keys = src_root['data'].keys() + for key in keys: + value = src_root['data'][key] + cks = cls._resolve_array_chunks( + chunks=chunks, key=key, array=value) + cpr = cls._resolve_array_compressor( + compressors=compressors, key=key, array=value) + if cks == value.chunks and cpr == value.compressor: + # copy without recompression + this_path = '/data/' + key + n_copied, n_skipped, n_bytes_copied = zarr.copy_store( + source=src_store, dest=store, + source_path=this_path, dest_path=this_path, + if_exists=if_exists + ) + else: + # copy with recompression + n_copied, n_skipped, n_bytes_copied = zarr.copy( + source=value, dest=data_group, name=key, + chunks=cks, compressor=cpr, if_exists=if_exists + ) + buffer = cls(root=root) + return buffer + + @classmethod + def copy_from_path(cls, zarr_path, backend=None, store=None, keys=None, + chunks: Dict[str,tuple]=dict(), + compressors: Union[dict, str, numcodecs.abc.Codec]=dict(), + if_exists='replace', + **kwargs): + """ + Copy a on-disk zarr to in-memory compressed. + Recommended + """ + if backend == 'numpy': + print('backend argument is deprecated!') + store = None + group = zarr.open(os.path.expanduser(zarr_path), 'r') + return cls.copy_from_store(src_store=group.store, store=store, + keys=keys, chunks=chunks, compressors=compressors, + if_exists=if_exists, **kwargs) + + # ============= save methods =============== + def save_to_store(self, store, + chunks: Optional[Dict[str,tuple]]=dict(), + compressors: Union[str, numcodecs.abc.Codec, dict]=dict(), + if_exists='replace', + **kwargs): + + root = zarr.group(store) + if self.backend == 'zarr': + # recompression free copy + n_copied, n_skipped, n_bytes_copied = zarr.copy_store( + source=self.root.store, dest=store, + source_path='/meta', dest_path='/meta', if_exists=if_exists) + else: + meta_group = root.create_group('meta', overwrite=True) + # save meta, no chunking + for key, value in self.root['meta'].items(): + _ = meta_group.array( + name=key, + data=value, + shape=value.shape, + chunks=value.shape) + + # save data, chunk + data_group = root.create_group('data', overwrite=True) + for key, value in self.root['data'].items(): + cks = self._resolve_array_chunks( + chunks=chunks, key=key, array=value) + cpr = self._resolve_array_compressor( + compressors=compressors, key=key, array=value) + if isinstance(value, zarr.Array): + if cks == value.chunks and cpr == value.compressor: + # copy without recompression + this_path = '/data/' + key + n_copied, n_skipped, n_bytes_copied = zarr.copy_store( + source=self.root.store, dest=store, + source_path=this_path, dest_path=this_path, if_exists=if_exists) + else: + # copy with recompression + n_copied, n_skipped, n_bytes_copied = zarr.copy( + source=value, dest=data_group, name=key, + chunks=cks, compressor=cpr, if_exists=if_exists + ) + else: + # numpy + _ = data_group.array( + name=key, + data=value, + chunks=cks, + compressor=cpr + ) + return store + + def save_to_path(self, zarr_path, + chunks: Optional[Dict[str,tuple]]=dict(), + compressors: Union[str, numcodecs.abc.Codec, dict]=dict(), + if_exists='replace', + **kwargs): + store = zarr.DirectoryStore(os.path.expanduser(zarr_path)) + return self.save_to_store(store, chunks=chunks, + compressors=compressors, if_exists=if_exists, **kwargs) + + @staticmethod + def resolve_compressor(compressor='default'): + if compressor == 'default': + compressor = numcodecs.Blosc(cname='lz4', clevel=5, + shuffle=numcodecs.Blosc.NOSHUFFLE) + elif compressor == 'disk': + compressor = numcodecs.Blosc('zstd', clevel=5, + shuffle=numcodecs.Blosc.BITSHUFFLE) + return compressor + + @classmethod + def _resolve_array_compressor(cls, + compressors: Union[dict, str, numcodecs.abc.Codec], key, array): + # allows compressor to be explicitly set to None + cpr = 'nil' + if isinstance(compressors, dict): + if key in compressors: + cpr = cls.resolve_compressor(compressors[key]) + elif isinstance(array, zarr.Array): + cpr = array.compressor + else: + cpr = cls.resolve_compressor(compressors) + # backup default + if cpr == 'nil': + cpr = cls.resolve_compressor('default') + return cpr + + @classmethod + def _resolve_array_chunks(cls, + chunks: Union[dict, tuple], key, array): + cks = None + if isinstance(chunks, dict): + if key in chunks: + cks = chunks[key] + elif isinstance(array, zarr.Array): + cks = array.chunks + elif isinstance(chunks, tuple): + cks = chunks + else: + raise TypeError(f"Unsupported chunks type {type(chunks)}") + # backup default + if cks is None: + cks = get_optimal_chunks(shape=array.shape, dtype=array.dtype) + # check + check_chunks_compatible(chunks=cks, shape=array.shape) + return cks + + # ============= properties ================= + @cached_property + def data(self): + return self.root['data'] + + @cached_property + def meta(self): + return self.root['meta'] + + def update_meta(self, data): + # sanitize data + np_data = dict() + for key, value in data.items(): + if isinstance(value, np.ndarray): + np_data[key] = value + else: + arr = np.array(value) + if arr.dtype == object: + raise TypeError(f"Invalid value type {type(value)}") + np_data[key] = arr + + meta_group = self.meta + if self.backend == 'zarr': + for key, value in np_data.items(): + _ = meta_group.array( + name=key, + data=value, + shape=value.shape, + chunks=value.shape, + overwrite=True) + else: + meta_group.update(np_data) + + return meta_group + + @property + def episode_ends(self): + return self.meta['episode_ends'] + + def get_episode_idxs(self): + import numba + numba.jit(nopython=True) + def _get_episode_idxs(episode_ends): + result = np.zeros((episode_ends[-1],), dtype=np.int64) + for i in range(len(episode_ends)): + start = 0 + if i > 0: + start = episode_ends[i-1] + end = episode_ends[i] + for idx in range(start, end): + result[idx] = i + return result + return _get_episode_idxs(self.episode_ends) + + + @property + def backend(self): + backend = 'numpy' + if isinstance(self.root, zarr.Group): + backend = 'zarr' + return backend + + # =========== dict-like API ============== + def __repr__(self) -> str: + if self.backend == 'zarr': + return str(self.root.tree()) + else: + return super().__repr__() + + def keys(self): + return self.data.keys() + + def values(self): + return self.data.values() + + def items(self): + return self.data.items() + + def __getitem__(self, key): + return self.data[key] + + def __contains__(self, key): + return key in self.data + + # =========== our API ============== + @property + def n_steps(self): + if len(self.episode_ends) == 0: + return 0 + return self.episode_ends[-1] + + @property + def n_episodes(self): + return len(self.episode_ends) + + @property + def chunk_size(self): + if self.backend == 'zarr': + return next(iter(self.data.arrays()))[-1].chunks[0] + return None + + @property + def episode_lengths(self): + ends = self.episode_ends[:] + ends = np.insert(ends, 0, 0) + lengths = np.diff(ends) + return lengths + + def add_episode(self, + data: Dict[str, np.ndarray], + chunks: Optional[Dict[str,tuple]]=dict(), + compressors: Union[str, numcodecs.abc.Codec, dict]=dict()): + assert(len(data) > 0) + is_zarr = (self.backend == 'zarr') + + curr_len = self.n_steps + episode_length = None + for key, value in data.items(): + assert(len(value.shape) >= 1) + if episode_length is None: + episode_length = len(value) + else: + assert(episode_length == len(value)) + new_len = curr_len + episode_length + + for key, value in data.items(): + new_shape = (new_len,) + value.shape[1:] + # create array + if key not in self.data: + if is_zarr: + cks = self._resolve_array_chunks( + chunks=chunks, key=key, array=value) + cpr = self._resolve_array_compressor( + compressors=compressors, key=key, array=value) + arr = self.data.zeros(name=key, + shape=new_shape, + chunks=cks, + dtype=value.dtype, + compressor=cpr) + else: + # copy data to prevent modify + arr = np.zeros(shape=new_shape, dtype=value.dtype) + self.data[key] = arr + else: + arr = self.data[key] + assert(value.shape[1:] == arr.shape[1:]) + # same method for both zarr and numpy + if is_zarr: + arr.resize(new_shape) + else: + arr.resize(new_shape, refcheck=False) + # copy data + arr[-value.shape[0]:] = value + + # append to episode ends + episode_ends = self.episode_ends + if is_zarr: + episode_ends.resize(episode_ends.shape[0] + 1) + else: + episode_ends.resize(episode_ends.shape[0] + 1, refcheck=False) + episode_ends[-1] = new_len + + # rechunk + if is_zarr: + if episode_ends.chunks[0] < episode_ends.shape[0]: + rechunk_recompress_array(self.meta, 'episode_ends', + chunk_length=int(episode_ends.shape[0] * 1.5)) + + def drop_episode(self): + is_zarr = (self.backend == 'zarr') + episode_ends = self.episode_ends[:].copy() + assert(len(episode_ends) > 0) + start_idx = 0 + if len(episode_ends) > 1: + start_idx = episode_ends[-2] + for key, value in self.data.items(): + new_shape = (start_idx,) + value.shape[1:] + if is_zarr: + value.resize(new_shape) + else: + value.resize(new_shape, refcheck=False) + if is_zarr: + self.episode_ends.resize(len(episode_ends)-1) + else: + self.episode_ends.resize(len(episode_ends)-1, refcheck=False) + + def pop_episode(self): + assert(self.n_episodes > 0) + episode = self.get_episode(self.n_episodes-1, copy=True) + self.drop_episode() + return episode + + def extend(self, data): + self.add_episode(data) + + def get_episode(self, idx, copy=False): + idx = list(range(len(self.episode_ends)))[idx] + start_idx = 0 + if idx > 0: + start_idx = self.episode_ends[idx-1] + end_idx = self.episode_ends[idx] + result = self.get_steps_slice(start_idx, end_idx, copy=copy) + return result + + def get_episode_slice(self, idx): + start_idx = 0 + if idx > 0: + start_idx = self.episode_ends[idx-1] + end_idx = self.episode_ends[idx] + return slice(start_idx, end_idx) + + def get_steps_slice(self, start, stop, step=None, copy=False): + _slice = slice(start, stop, step) + + result = dict() + for key, value in self.data.items(): + x = value[_slice] + if copy and isinstance(value, np.ndarray): + x = x.copy() + result[key] = x + return result + + # =========== chunking ============= + def get_chunks(self) -> dict: + assert self.backend == 'zarr' + chunks = dict() + for key, value in self.data.items(): + chunks[key] = value.chunks + return chunks + + def set_chunks(self, chunks: dict): + assert self.backend == 'zarr' + for key, value in chunks.items(): + if key in self.data: + arr = self.data[key] + if value != arr.chunks: + check_chunks_compatible(chunks=value, shape=arr.shape) + rechunk_recompress_array(self.data, key, chunks=value) + + def get_compressors(self) -> dict: + assert self.backend == 'zarr' + compressors = dict() + for key, value in self.data.items(): + compressors[key] = value.compressor + return compressors + + def set_compressors(self, compressors: dict): + assert self.backend == 'zarr' + for key, value in compressors.items(): + if key in self.data: + arr = self.data[key] + compressor = self.resolve_compressor(value) + if compressor != arr.compressor: + rechunk_recompress_array(self.data, key, compressor=compressor) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_config_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_config_util.py new file mode 100644 index 0000000000000000000000000000000000000000..85768bbbfef39dadb61f6bfce540f53c5202a9b5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_config_util.py @@ -0,0 +1,47 @@ +from omegaconf import OmegaConf +from robomimic.config import config_factory +import robomimic.scripts.generate_paper_configs as gpc +from robomimic.scripts.generate_paper_configs import ( + modify_config_for_default_image_exp, + modify_config_for_default_low_dim_exp, + modify_config_for_dataset, +) + +def get_robomimic_config( + algo_name='bc_rnn', + hdf5_type='low_dim', + task_name='square', + dataset_type='ph' + ): + base_dataset_dir = '/tmp/null' + filter_key = None + + # decide whether to use low-dim or image training defaults + modifier_for_obs = modify_config_for_default_image_exp + if hdf5_type in ["low_dim", "low_dim_sparse", "low_dim_dense"]: + modifier_for_obs = modify_config_for_default_low_dim_exp + + algo_config_name = "bc" if algo_name == "bc_rnn" else algo_name + config = config_factory(algo_name=algo_config_name) + # turn into default config for observation modalities (e.g.: low-dim or rgb) + config = modifier_for_obs(config) + # add in config based on the dataset + config = modify_config_for_dataset( + config=config, + task_name=task_name, + dataset_type=dataset_type, + hdf5_type=hdf5_type, + base_dataset_dir=base_dataset_dir, + filter_key=filter_key, + ) + # add in algo hypers based on dataset + algo_config_modifier = getattr(gpc, f'modify_{algo_name}_config_for_dataset') + config = algo_config_modifier( + config=config, + task_name=task_name, + dataset_type=dataset_type, + hdf5_type=hdf5_type, + ) + return config + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_util.py new file mode 100644 index 0000000000000000000000000000000000000000..24f25fbc570304aef9606455d00d784fd305a8bb --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/robomimic_util.py @@ -0,0 +1,177 @@ +import numpy as np +import copy + +import h5py +import robomimic.utils.obs_utils as ObsUtils +import robomimic.utils.file_utils as FileUtils +import robomimic.utils.env_utils as EnvUtils +from scipy.spatial.transform import Rotation + +from robomimic.config import config_factory + + +class RobomimicAbsoluteActionConverter: + def __init__(self, dataset_path, algo_name='bc'): + # default BC config + config = config_factory(algo_name=algo_name) + + # read config to set up metadata for observation modalities (e.g. detecting rgb observations) + # must ran before create dataset + ObsUtils.initialize_obs_utils_with_config(config) + + env_meta = FileUtils.get_env_metadata_from_dataset(dataset_path) + abs_env_meta = copy.deepcopy(env_meta) + abs_env_meta['env_kwargs']['controller_configs']['control_delta'] = False + + env = EnvUtils.create_env_from_metadata( + env_meta=env_meta, + render=False, + render_offscreen=False, + use_image_obs=False, + ) + assert len(env.env.robots) in (1, 2) + + abs_env = EnvUtils.create_env_from_metadata( + env_meta=abs_env_meta, + render=False, + render_offscreen=False, + use_image_obs=False, + ) + assert not abs_env.env.robots[0].controller.use_delta + + self.env = env + self.abs_env = abs_env + self.file = h5py.File(dataset_path, 'r') + + def __len__(self): + return len(self.file['data']) + + def convert_actions(self, + states: np.ndarray, + actions: np.ndarray) -> np.ndarray: + """ + Given state and delta action sequence + generate equivalent goal position and orientation for each step + keep the original gripper action intact. + """ + # in case of multi robot + # reshape (N,14) to (N,2,7) + # or (N,7) to (N,1,7) + stacked_actions = actions.reshape(*actions.shape[:-1],-1,7) + + env = self.env + # generate abs actions + action_goal_pos = np.zeros( + stacked_actions.shape[:-1]+(3,), + dtype=stacked_actions.dtype) + action_goal_ori = np.zeros( + stacked_actions.shape[:-1]+(3,), + dtype=stacked_actions.dtype) + action_gripper = stacked_actions[...,[-1]] + for i in range(len(states)): + _ = env.reset_to({'states': states[i]}) + + # taken from robot_env.py L#454 + for idx, robot in enumerate(env.env.robots): + # run controller goal generator + robot.control(stacked_actions[i,idx], policy_step=True) + + # read pos and ori from robots + controller = robot.controller + action_goal_pos[i,idx] = controller.goal_pos + action_goal_ori[i,idx] = Rotation.from_matrix( + controller.goal_ori).as_rotvec() + + stacked_abs_actions = np.concatenate([ + action_goal_pos, + action_goal_ori, + action_gripper + ], axis=-1) + abs_actions = stacked_abs_actions.reshape(actions.shape) + return abs_actions + + def convert_idx(self, idx): + file = self.file + demo = file[f'data/demo_{idx}'] + # input + states = demo['states'][:] + actions = demo['actions'][:] + + # generate abs actions + abs_actions = self.convert_actions(states, actions) + return abs_actions + + def convert_and_eval_idx(self, idx): + env = self.env + abs_env = self.abs_env + file = self.file + # first step have high error for some reason, not representative + eval_skip_steps = 1 + + demo = file[f'data/demo_{idx}'] + # input + states = demo['states'][:] + actions = demo['actions'][:] + + # generate abs actions + abs_actions = self.convert_actions(states, actions) + + # verify + robot0_eef_pos = demo['obs']['robot0_eef_pos'][:] + robot0_eef_quat = demo['obs']['robot0_eef_quat'][:] + + delta_error_info = self.evaluate_rollout_error( + env, states, actions, robot0_eef_pos, robot0_eef_quat, + metric_skip_steps=eval_skip_steps) + abs_error_info = self.evaluate_rollout_error( + abs_env, states, abs_actions, robot0_eef_pos, robot0_eef_quat, + metric_skip_steps=eval_skip_steps) + + info = { + 'delta_max_error': delta_error_info, + 'abs_max_error': abs_error_info + } + return abs_actions, info + + @staticmethod + def evaluate_rollout_error(env, + states, actions, + robot0_eef_pos, + robot0_eef_quat, + metric_skip_steps=1): + # first step have high error for some reason, not representative + + # evaluate abs actions + rollout_next_states = list() + rollout_next_eef_pos = list() + rollout_next_eef_quat = list() + obs = env.reset_to({'states': states[0]}) + for i in range(len(states)): + obs = env.reset_to({'states': states[i]}) + obs, reward, done, info = env.step(actions[i]) + obs = env.get_observation() + rollout_next_states.append(env.get_state()['states']) + rollout_next_eef_pos.append(obs['robot0_eef_pos']) + rollout_next_eef_quat.append(obs['robot0_eef_quat']) + rollout_next_states = np.array(rollout_next_states) + rollout_next_eef_pos = np.array(rollout_next_eef_pos) + rollout_next_eef_quat = np.array(rollout_next_eef_quat) + + next_state_diff = states[1:] - rollout_next_states[:-1] + max_next_state_diff = np.max(np.abs(next_state_diff[metric_skip_steps:])) + + next_eef_pos_diff = robot0_eef_pos[1:] - rollout_next_eef_pos[:-1] + next_eef_pos_dist = np.linalg.norm(next_eef_pos_diff, axis=-1) + max_next_eef_pos_dist = next_eef_pos_dist[metric_skip_steps:].max() + + next_eef_rot_diff = Rotation.from_quat(robot0_eef_quat[1:]) \ + * Rotation.from_quat(rollout_next_eef_quat[:-1]).inv() + next_eef_rot_dist = next_eef_rot_diff.magnitude() + max_next_eef_rot_dist = next_eef_rot_dist[metric_skip_steps:].max() + + info = { + 'state': max_next_state_diff, + 'pos': max_next_eef_pos_dist, + 'rot': max_next_eef_rot_dist + } + return info diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/sampler.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/sampler.py new file mode 100644 index 0000000000000000000000000000000000000000..4accaa093336095111cf39ebeb9a1393628104ac --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/sampler.py @@ -0,0 +1,153 @@ +from typing import Optional +import numpy as np +import numba +from diffusion_policy.common.replay_buffer import ReplayBuffer + + +@numba.jit(nopython=True) +def create_indices( + episode_ends:np.ndarray, sequence_length:int, + episode_mask: np.ndarray, + pad_before: int=0, pad_after: int=0, + debug:bool=True) -> np.ndarray: + episode_mask.shape == episode_ends.shape + pad_before = min(max(pad_before, 0), sequence_length-1) + pad_after = min(max(pad_after, 0), sequence_length-1) + + indices = list() + for i in range(len(episode_ends)): + if not episode_mask[i]: + # skip episode + continue + start_idx = 0 + if i > 0: + start_idx = episode_ends[i-1] + end_idx = episode_ends[i] + episode_length = end_idx - start_idx + + min_start = -pad_before + max_start = episode_length - sequence_length + pad_after + + # range stops one idx before end + for idx in range(min_start, max_start+1): + buffer_start_idx = max(idx, 0) + start_idx + buffer_end_idx = min(idx+sequence_length, episode_length) + start_idx + start_offset = buffer_start_idx - (idx+start_idx) + end_offset = (idx+sequence_length+start_idx) - buffer_end_idx + sample_start_idx = 0 + start_offset + sample_end_idx = sequence_length - end_offset + if debug: + assert(start_offset >= 0) + assert(end_offset >= 0) + assert (sample_end_idx - sample_start_idx) == (buffer_end_idx - buffer_start_idx) + indices.append([ + buffer_start_idx, buffer_end_idx, + sample_start_idx, sample_end_idx]) + indices = np.array(indices) + return indices + + +def get_val_mask(n_episodes, val_ratio, seed=0): + val_mask = np.zeros(n_episodes, dtype=bool) + if val_ratio <= 0: + return val_mask + + # have at least 1 episode for validation, and at least 1 episode for train + n_val = min(max(1, round(n_episodes * val_ratio)), n_episodes-1) + rng = np.random.default_rng(seed=seed) + val_idxs = rng.choice(n_episodes, size=n_val, replace=False) + val_mask[val_idxs] = True + return val_mask + + +def downsample_mask(mask, max_n, seed=0): + # subsample training data + train_mask = mask + if (max_n is not None) and (np.sum(train_mask) > max_n): + n_train = int(max_n) + curr_train_idxs = np.nonzero(train_mask)[0] + rng = np.random.default_rng(seed=seed) + train_idxs_idx = rng.choice(len(curr_train_idxs), size=n_train, replace=False) + train_idxs = curr_train_idxs[train_idxs_idx] + train_mask = np.zeros_like(train_mask) + train_mask[train_idxs] = True + assert np.sum(train_mask) == n_train + return train_mask + +class SequenceSampler: + def __init__(self, + replay_buffer: ReplayBuffer, + sequence_length:int, + pad_before:int=0, + pad_after:int=0, + keys=None, + key_first_k=dict(), + episode_mask: Optional[np.ndarray]=None, + ): + """ + key_first_k: dict str: int + Only take first k data from these keys (to improve perf) + """ + + super().__init__() + assert(sequence_length >= 1) + if keys is None: + keys = list(replay_buffer.keys()) + + episode_ends = replay_buffer.episode_ends[:] + if episode_mask is None: + episode_mask = np.ones(episode_ends.shape, dtype=bool) + + if np.any(episode_mask): + indices = create_indices(episode_ends, + sequence_length=sequence_length, + pad_before=pad_before, + pad_after=pad_after, + episode_mask=episode_mask + ) + else: + indices = np.zeros((0,4), dtype=np.int64) + + # (buffer_start_idx, buffer_end_idx, sample_start_idx, sample_end_idx) + self.indices = indices + self.keys = list(keys) # prevent OmegaConf list performance problem + self.sequence_length = sequence_length + self.replay_buffer = replay_buffer + self.key_first_k = key_first_k + + def __len__(self): + return len(self.indices) + + def sample_sequence(self, idx): + buffer_start_idx, buffer_end_idx, sample_start_idx, sample_end_idx \ + = self.indices[idx] + result = dict() + for key in self.keys: + input_arr = self.replay_buffer[key] + # performance optimization, avoid small allocation if possible + if key not in self.key_first_k: + sample = input_arr[buffer_start_idx:buffer_end_idx] + else: + # performance optimization, only load used obs steps + n_data = buffer_end_idx - buffer_start_idx + k_data = min(self.key_first_k[key], n_data) + # fill value with Nan to catch bugs + # the non-loaded region should never be used + sample = np.full((n_data,) + input_arr.shape[1:], + fill_value=np.nan, dtype=input_arr.dtype) + try: + sample[:k_data] = input_arr[buffer_start_idx:buffer_start_idx+k_data] + except Exception as e: + import pdb; pdb.set_trace() + data = sample + if (sample_start_idx > 0) or (sample_end_idx < self.sequence_length): + data = np.zeros( + shape=(self.sequence_length,) + input_arr.shape[1:], + dtype=input_arr.dtype) + if sample_start_idx > 0: + data[:sample_start_idx] = sample[0] + if sample_end_idx < self.sequence_length: + data[sample_end_idx:] = sample[-1] + data[sample_start_idx:sample_end_idx] = sample + result[key] = data + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/simple_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/simple_util.py new file mode 100644 index 0000000000000000000000000000000000000000..e328bbf5a42e5f0d387c6318e90fa68ceea45222 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/simple_util.py @@ -0,0 +1,72 @@ +import numpy as np + + +def create_sample_indices( + episode_ends:np.ndarray, sequence_length:int, + pad_before: int=0, pad_after: int=0): + indices = list() + for i in range(len(episode_ends)): + start_idx = 0 + if i > 0: + start_idx = episode_ends[i-1] + end_idx = episode_ends[i] + episode_length = end_idx - start_idx + + min_start = -pad_before + max_start = episode_length - sequence_length + pad_after + + # range stops one idx before end + for idx in range(min_start, max_start+1): + buffer_start_idx = max(idx, 0) + start_idx + buffer_end_idx = min(idx+sequence_length, episode_length) + start_idx + start_offset = buffer_start_idx - (idx+start_idx) + end_offset = (idx+sequence_length+start_idx) - buffer_end_idx + sample_start_idx = 0 + start_offset + sample_end_idx = sequence_length - end_offset + indices.append([ + buffer_start_idx, buffer_end_idx, + sample_start_idx, sample_end_idx]) + indices = np.array(indices) + return indices + + +def sample_sequence(train_data, sequence_length, + buffer_start_idx, buffer_end_idx, + sample_start_idx, sample_end_idx): + result = dict() + for key, input_arr in train_data.items(): + sample = input_arr[buffer_start_idx:buffer_end_idx] + data = sample + if (sample_start_idx > 0) or (sample_end_idx < sequence_length): + data = np.zeros( + shape=(sequence_length,) + input_arr.shape[1:], + dtype=input_arr.dtype) + if sample_start_idx > 0: + data[:sample_start_idx] = sample[0] + if sample_end_idx < sequence_length: + data[sample_end_idx:] = sample[-1] + data[sample_start_idx:sample_end_idx] = sample + result[key] = data + return result + +# normalize data +def get_data_stats(data): + data = data.reshape(-1,data.shape[-1]) + stats = { + 'min': np.min(data, axis=0), + 'max': np.max(data, axis=0) + } + return stats + +def normalize_data(data, stats): + # nomalize to [0,1] + ndata = (data - stats['min']) / (stats['max'] - stats['min']) + # normalize to [-1, 1] + ndata = ndata * 2 - 1 + return ndata + +def unnormalize_data(ndata, stats): + ndata = (ndata + 1) / 2 + data = ndata * (stats['max'] - stats['min']) + stats['min'] + return data + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/timestamp_accumulator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/timestamp_accumulator.py new file mode 100644 index 0000000000000000000000000000000000000000..2e726728861305b98522863e4bddbe4051eacaf3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/common/timestamp_accumulator.py @@ -0,0 +1,222 @@ +from typing import List, Tuple, Optional, Dict +import math +import numpy as np + + +def get_accumulate_timestamp_idxs( + timestamps: List[float], + start_time: float, + dt: float, + eps:float=1e-5, + next_global_idx: Optional[int]=0, + allow_negative=False + ) -> Tuple[List[int], List[int], int]: + """ + For each dt window, choose the first timestamp in the window. + Assumes timestamps sorted. One timestamp might be chosen multiple times due to dropped frames. + next_global_idx should start at 0 normally, and then use the returned next_global_idx. + However, when overwiting previous values are desired, set last_global_idx to None. + + Returns: + local_idxs: which index in the given timestamps array to chose from + global_idxs: the global index of each chosen timestamp + next_global_idx: used for next call. + """ + local_idxs = list() + global_idxs = list() + for local_idx, ts in enumerate(timestamps): + # add eps * dt to timestamps so that when ts == start_time + k * dt + # is always recorded as kth element (avoiding floating point errors) + global_idx = math.floor((ts - start_time) / dt + eps) + if (not allow_negative) and (global_idx < 0): + continue + if next_global_idx is None: + next_global_idx = global_idx + + n_repeats = max(0, global_idx - next_global_idx + 1) + for i in range(n_repeats): + local_idxs.append(local_idx) + global_idxs.append(next_global_idx + i) + next_global_idx += n_repeats + return local_idxs, global_idxs, next_global_idx + + +def align_timestamps( + timestamps: List[float], + target_global_idxs: List[int], + start_time: float, + dt: float, + eps:float=1e-5): + if isinstance(target_global_idxs, np.ndarray): + target_global_idxs = target_global_idxs.tolist() + assert len(target_global_idxs) > 0 + + local_idxs, global_idxs, _ = get_accumulate_timestamp_idxs( + timestamps=timestamps, + start_time=start_time, + dt=dt, + eps=eps, + next_global_idx=target_global_idxs[0], + allow_negative=True + ) + if len(global_idxs) > len(target_global_idxs): + # if more steps available, truncate + global_idxs = global_idxs[:len(target_global_idxs)] + local_idxs = local_idxs[:len(target_global_idxs)] + + if len(global_idxs) == 0: + import pdb; pdb.set_trace() + + for i in range(len(target_global_idxs) - len(global_idxs)): + # if missing, repeat + local_idxs.append(len(timestamps)-1) + global_idxs.append(global_idxs[-1] + 1) + assert global_idxs == target_global_idxs + assert len(local_idxs) == len(global_idxs) + return local_idxs + + +class TimestampObsAccumulator: + def __init__(self, + start_time: float, + dt: float, + eps: float=1e-5): + self.start_time = start_time + self.dt = dt + self.eps = eps + self.obs_buffer = dict() + self.timestamp_buffer = None + self.next_global_idx = 0 + + def __len__(self): + return self.next_global_idx + + @property + def data(self): + if self.timestamp_buffer is None: + return dict() + result = dict() + for key, value in self.obs_buffer.items(): + result[key] = value[:len(self)] + return result + + @property + def actual_timestamps(self): + if self.timestamp_buffer is None: + return np.array([]) + return self.timestamp_buffer[:len(self)] + + @property + def timestamps(self): + if self.timestamp_buffer is None: + return np.array([]) + return self.start_time + np.arange(len(self)) * self.dt + + def put(self, data: Dict[str, np.ndarray], timestamps: np.ndarray): + """ + data: + key: T,* + """ + + local_idxs, global_idxs, self.next_global_idx = get_accumulate_timestamp_idxs( + timestamps=timestamps, + start_time=self.start_time, + dt=self.dt, + eps=self.eps, + next_global_idx=self.next_global_idx + ) + + if len(global_idxs) > 0: + if self.timestamp_buffer is None: + # first allocation + self.obs_buffer = dict() + for key, value in data.items(): + self.obs_buffer[key] = np.zeros_like(value) + self.timestamp_buffer = np.zeros( + (len(timestamps),), dtype=np.float64) + + this_max_size = global_idxs[-1] + 1 + if this_max_size > len(self.timestamp_buffer): + # reallocate + new_size = max(this_max_size, len(self.timestamp_buffer) * 2) + for key in list(self.obs_buffer.keys()): + new_shape = (new_size,) + self.obs_buffer[key].shape[1:] + self.obs_buffer[key] = np.resize(self.obs_buffer[key], new_shape) + self.timestamp_buffer = np.resize(self.timestamp_buffer, (new_size)) + + # write data + for key, value in self.obs_buffer.items(): + value[global_idxs] = data[key][local_idxs] + self.timestamp_buffer[global_idxs] = timestamps[local_idxs] + + +class TimestampActionAccumulator: + def __init__(self, + start_time: float, + dt: float, + eps: float=1e-5): + """ + Different from Obs accumulator, the action accumulator + allows overwriting previous values. + """ + self.start_time = start_time + self.dt = dt + self.eps = eps + self.action_buffer = None + self.timestamp_buffer = None + self.size = 0 + + def __len__(self): + return self.size + + @property + def actions(self): + if self.action_buffer is None: + return np.array([]) + return self.action_buffer[:len(self)] + + @property + def actual_timestamps(self): + if self.timestamp_buffer is None: + return np.array([]) + return self.timestamp_buffer[:len(self)] + + @property + def timestamps(self): + if self.timestamp_buffer is None: + return np.array([]) + return self.start_time + np.arange(len(self)) * self.dt + + def put(self, actions: np.ndarray, timestamps: np.ndarray): + """ + Note: timestamps is the time when the action will be issued, + not when the action will be completed (target_timestamp) + """ + + local_idxs, global_idxs, _ = get_accumulate_timestamp_idxs( + timestamps=timestamps, + start_time=self.start_time, + dt=self.dt, + eps=self.eps, + # allows overwriting previous actions + next_global_idx=None + ) + + if len(global_idxs) > 0: + if self.timestamp_buffer is None: + # first allocation + self.action_buffer = np.zeros_like(actions) + self.timestamp_buffer = np.zeros((len(actions),), dtype=np.float64) + + this_max_size = global_idxs[-1] + 1 + if this_max_size > len(self.timestamp_buffer): + # reallocate + new_size = max(this_max_size, len(self.timestamp_buffer) * 2) + new_shape = (new_size,) + self.action_buffer.shape[1:] + self.action_buffer = np.resize(self.action_buffer, new_shape) + self.timestamp_buffer = np.resize(self.timestamp_buffer, (new_size,)) + + # potentially rewrite old data (as expected) + self.action_buffer[global_idxs] = actions[local_idxs] + self.timestamp_buffer[global_idxs] = timestamps[local_idxs] + self.size = max(self.size, this_max_size) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_cost_coupling.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_cost_coupling.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8396450a9b188dce2cef36275407365e7dc4ed34 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_cost_coupling.yaml @@ -0,0 +1,185 @@ +defaults: + - _self_ + - task: H16_pusht_lowdim_fix_init.yaml + +name: eval_pcdiff_cost_coupling +_target_: diffusion_policy.workspace.eval_pcdiff_cc_workspace.EvalPCDiffCCWorkspace + +obs_dim: ${task.obs_dim} +action_dim: ${task.action_dim} +keypoint_dim: ${task.keypoint_dim} +task_name: ${task.name} +exp_name: "eval-PushT-CC-H16-O2-A8" + +n_init_states: 10 +guider: 'coupling' +# guider: 'coupling_ps' +stp: 1 +scl: 0.03 +trial: 100 +# cost_func_key: "sum_log_l2" +cost_func_key: "dpp" + +diffusion_checkpoint: "data/pretrained/diffusion/diffusion_weights.ckpt" +n_diffusion_steps: 32 + + +output_dir: 'logs/tests/pusht_${guider}_output/H16O2A8D${policy.num_inference_steps}/${cost_func_key}/max_stp${task.env_runner.max_steps}/init_st${n_init_states}-${guider}-stp${stp}-scl${scl}-t${trial}' +log_pth: "${output_dir}/eval_runs.log" + +device: 'cuda:0' +seed: 42 + +horizon: 16 +n_obs_steps: 2 +n_action_steps: 8 +n_latency_steps: 0 +past_action_visible: False +keypoint_visible_rate: 1.0 +obs_as_local_cond: False +obs_as_global_cond: True +pred_action_steps_only: False + +policy: + _target_: diffusion_policy.policy.diffusion_unet_lowdim_cost_policy.DiffusionUnetLowdimCostPolicy + + guide: + _target_: diffusion_policy.model.guide.cost_guide.CostGuide + cost_func: null + cost_func_param: + alpha: 0.1 + # decay_rate: 10.0 + + n_guide_steps: ${stp} + grad_scale: ${scl} + t_stopgrad: 2 + threshold: 1.0e-8 + point_estimate: ${eval:'"${guider}".endswith("ps")'} + + model: + _target_: diffusion_policy.model.diffusion.conditional_unet1d.ConditionalUnet1D + input_dim: "${eval: ${task.action_dim} if ${obs_as_local_cond} or ${obs_as_global_cond} else ${task.obs_dim} + ${task.action_dim}}" + local_cond_dim: "${eval: ${task.obs_dim} if ${obs_as_local_cond} else None}" + global_cond_dim: "${eval: ${task.obs_dim}*${n_obs_steps} if ${obs_as_global_cond} else None}" + diffusion_step_embed_dim: 256 + down_dims: [256, 512, 1024] + kernel_size: 5 + n_groups: 8 + cond_predict_scale: True + + noise_scheduler: + _target_: diffusers.schedulers.scheduling_ddpm.DDPMScheduler + num_train_timesteps: ${n_diffusion_steps} # default 32 + beta_start: 0.0001 + beta_end: 0.02 + beta_schedule: squaredcos_cap_v2 + variance_type: fixed_small # Yilun's paper uses fixed_small_log instead, but easy to cause Nan + clip_sample: True # required when predict_epsilon=False + prediction_type: epsilon # or sample + + horizon: ${horizon} + obs_dim: ${obs_dim} + action_dim: ${action_dim} + n_action_steps: ${eval:'${n_action_steps}+${n_latency_steps}'} + n_obs_steps: ${n_obs_steps} + num_inference_steps: ${n_diffusion_steps} # default 32 + obs_as_local_cond: ${obs_as_local_cond} + obs_as_global_cond: ${obs_as_global_cond} + pred_action_steps_only: ${pred_action_steps_only} + oa_step_convention: True + + # scheduler.step params + # predict_epsilon: True + +ema: + _target_: diffusion_policy.model.diffusion.ema_model.EMAModel + update_after_step: 0 + inv_gamma: 1.0 + power: 0.75 + min_value: 0.0 + max_value: 0.9999 + +dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: True + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +val_dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: False + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +optimizer: + _target_: torch.optim.AdamW + lr: 1.0e-5 + betas: [0.95, 0.999] + eps: 1.0e-8 + weight_decay: 1.0e-6 + +training: + device: ${device} + seed: ${seed} + debug: False + resume: True + # optimization + lr_scheduler: cosine + lr_warmup_steps: 500 + num_epochs: 2000 + gradient_accumulate_every: 1 + use_ema: True + # training loop control + # in epochs + rollout_every: 5 + checkpoint_every: 10 + val_every: 1 + sample_every: 5 + # steps per epoch + max_train_steps: 30000 + max_val_steps: null + # misc + tqdm_interval_sec: 1.0 + +logging: + project: pcdiff_pusht + resume: True + mode: offline + # mode: online + name: LTL_value-${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + tags: ["${name}", "${task_name}", "${exp_name}"] + id: null + group: null + +checkpoint: + # topk: + # monitor_key: test_mean_score + # mode: max + # k: 10 + # format_str: 'epoch={epoch:04d}-test_mean_score={test_mean_score:.3f}.ckpt' + topk: + monitor_key: train_loss + mode: min + k: 10 + format_str: 'epoch={epoch:04d}-train_loss={train_loss:.5f}.ckpt' + save_last_ckpt: True + save_last_snapshot: False + +multi_run: + run_dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + wandb_name_base: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + +hydra: + job: + override_dirname: ${name} + run: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + sweep: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + subdir: ${hydra.job.num} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_fix_init_state.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_fix_init_state.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5728a856cc49f88b1a982cf39ff5a0f98d697d8b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_fix_init_state.yaml @@ -0,0 +1,166 @@ +defaults: + - _self_ + - task: H16_pusht_lowdim_fix_init + +name: eval_diffusion_policy_ltl +_target_: diffusion_policy.workspace.train_diffusion_unet_lowdim_workspace.TrainDiffusionUnetLowdimWorkspace + +obs_dim: ${task.obs_dim} +action_dim: ${task.action_dim} +keypoint_dim: ${task.keypoint_dim} +task_name: ${task.name} +exp_name: "eval-PushT-Fix_Init-H16-O2-A8" + +num_ltl: 1 +trial: 100 +ltl_dir: 'data/pusht/LTLs/ltls_no_4.txt' +constraint_type: 'ef_oa' + +# diffusion_checkpoint: 'data/pretrained/diffusion/dp-H16O2A8-Max-epoch=0184-test_mean_score=0.8459.ckpt' +diffusion_checkpoint: "data/pretrained/diffusion/dp-H16O2A8D32-Max-epoch=0068-test_mean_score=0.8814.ckpt" +n_diffusion_steps: 32 + +output_dir: 'logs/tests/pusht_baseline_output/H16O2A8D${policy.num_inference_steps}/max_stp${task.env_runner.max_steps}/init_st${n_init_states}-baseline-t${trial}' +log_pth: "${output_dir}/eval_runs.log" + + +n_init_states: 50 + +device: 'cuda:0' + +horizon: 16 +n_obs_steps: 2 +n_action_steps: 8 +n_latency_steps: 0 +past_action_visible: False +keypoint_visible_rate: 1.0 +obs_as_local_cond: False +obs_as_global_cond: True +pred_action_steps_only: False + +policy: + _target_: diffusion_policy.policy.diffusion_unet_lowdim_policy.DiffusionUnetLowdimPolicy + + model: + _target_: diffusion_policy.model.diffusion.conditional_unet1d.ConditionalUnet1D + input_dim: "${eval: ${task.action_dim} if ${obs_as_local_cond} or ${obs_as_global_cond} else ${task.obs_dim} + ${task.action_dim}}" + local_cond_dim: "${eval: ${task.obs_dim} if ${obs_as_local_cond} else None}" + global_cond_dim: "${eval: ${task.obs_dim}*${n_obs_steps} if ${obs_as_global_cond} else None}" + diffusion_step_embed_dim: 256 + down_dims: [256, 512, 1024] + kernel_size: 5 + n_groups: 8 + cond_predict_scale: True + + noise_scheduler: + _target_: diffusers.schedulers.scheduling_ddpm.DDPMScheduler + # num_train_timesteps: 32 + num_train_timesteps: ${n_diffusion_steps} # default: 32 + beta_start: 0.0001 + beta_end: 0.02 + beta_schedule: squaredcos_cap_v2 + variance_type: fixed_small # Yilun's paper uses fixed_small_log instead, but easy to cause Nan + clip_sample: True # required when predict_epsilon=False + prediction_type: epsilon # or sample + + horizon: ${horizon} + obs_dim: ${obs_dim} + action_dim: ${action_dim} + n_action_steps: ${eval:'${n_action_steps}+${n_latency_steps}'} + n_obs_steps: ${n_obs_steps} + num_inference_steps: ${n_diffusion_steps} # default: 32} + obs_as_local_cond: ${obs_as_local_cond} + obs_as_global_cond: ${obs_as_global_cond} + pred_action_steps_only: ${pred_action_steps_only} + oa_step_convention: True + + # scheduler.step params + # predict_epsilon: True + +ema: + _target_: diffusion_policy.model.diffusion.ema_model.EMAModel + update_after_step: 0 + inv_gamma: 1.0 + power: 0.75 + min_value: 0.0 + max_value: 0.9999 + +dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: True + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +val_dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: False + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +optimizer: + _target_: torch.optim.AdamW + lr: 1.0e-5 + betas: [0.95, 0.999] + eps: 1.0e-8 + weight_decay: 1.0e-6 + +training: + device: ${device} + seed: 42 + debug: False + resume: True + # optimization + lr_scheduler: cosine + lr_warmup_steps: 500 + num_epochs: 2000 + gradient_accumulate_every: 1 + use_ema: True + # training loop control + # in epochs + rollout_every: 5 + checkpoint_every: 10 + val_every: 1 + sample_every: 5 + # steps per epoch + max_train_steps: 30000 + max_val_steps: null + # misc + tqdm_interval_sec: 1.0 + +logging: + project: diffusion_policy_debug + resume: True + # mode: offline + mode: online + name: LTL_value-${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + tags: ["${name}", "${task_name}", "${exp_name}"] + id: null + group: null + +checkpoint: + topk: + monitor_key: train_loss + mode: min + k: 10 + format_str: 'epoch={epoch:04d}-train_loss={train_loss:.5f}.ckpt' + save_last_ckpt: True + save_last_snapshot: False + +multi_run: + run_dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + wandb_name_base: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + +hydra: + job: + override_dirname: ${name} + run: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + sweep: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + subdir: ${hydra.job.num} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_admm.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_admm.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b11260a5814bdb1ff3c44384803363eeaf067c22 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_admm.yaml @@ -0,0 +1,192 @@ +defaults: + - _self_ + - task: H16_pusht_lowdim_fix_init.yaml + +name: eval_pcdiff_projection_admm +_target_: diffusion_policy.workspace.eval_pcdiff_pj_workspace.EvalPCDiffPJWorkspace + +obs_dim: ${task.obs_dim} +action_dim: ${task.action_dim} +keypoint_dim: ${task.keypoint_dim} +task_name: ${task.name} +exp_name: "eval-PushT-PJ-H16-O2-A8" + +# v_max: 6.2 # 80% +v_max: 8.4 # 90% + +n_init_states: 10 +projector: "max_vel_admm" +# stp: 1 +# scl: 0.1 +trial: 100 +proj_profile_key: "max_vel" + +diffusion_checkpoint: "data/pretrained/diffusion/diffusion_weights.ckpt" +n_diffusion_steps: 32 + +output_dir: 'logs/tests/pusht_proj_output/H16O2A8D${policy.num_inference_steps}/projection/${proj_profile_key}-max_v${v_max}/max_stp${task.env_runner.max_steps}/init_st${n_init_states}-${projector}_${policy.projector.decomp}-max_v${v_max}-t${trial}' +log_pth: "${output_dir}/eval_runs.log" + +device: 'cuda:0' +seed: 42 + +horizon: 16 +n_obs_steps: 2 +n_action_steps: 8 +n_latency_steps: 0 +past_action_visible: False +keypoint_visible_rate: 1.0 +obs_as_local_cond: False +obs_as_global_cond: True +pred_action_steps_only: False + +policy: + _target_: diffusion_policy.policy.diffusion_unet_lowdim_proj_policy.DiffusionUnetLowdimProjPolicy + + projector: + _target_: diffusion_policy.model.projection.projection_operator.ADMMProjectionOperatorCUDAGraph + rho: 6.0 ## penalty for the augmented Lagrangian + max_iter: 200 + tol: 2e-4 ## tolerance for convergence + decomp: 'lu' + # decomp: 'cholesky' + convergence_check: True + # convergence_check: False + verbose: False + + project_params: + delta_t: 0.01 + vel_max: ${v_max} + + timeit: false + # timeit: true + + model: + _target_: diffusion_policy.model.diffusion.conditional_unet1d.ConditionalUnet1D + input_dim: "${eval: ${task.action_dim} if ${obs_as_local_cond} or ${obs_as_global_cond} else ${task.obs_dim} + ${task.action_dim}}" + local_cond_dim: "${eval: ${task.obs_dim} if ${obs_as_local_cond} else None}" + global_cond_dim: "${eval: ${task.obs_dim}*${n_obs_steps} if ${obs_as_global_cond} else None}" + diffusion_step_embed_dim: 256 + down_dims: [256, 512, 1024] + kernel_size: 5 + n_groups: 8 + cond_predict_scale: True + + noise_scheduler: + _target_: diffusers.schedulers.scheduling_ddpm.DDPMScheduler + # num_train_timesteps: 32 + num_train_timesteps: ${n_diffusion_steps} + beta_start: 0.0001 + beta_end: 0.02 + beta_schedule: squaredcos_cap_v2 + variance_type: fixed_small # Yilun's paper uses fixed_small_log instead, but easy to cause Nan + clip_sample: True # required when predict_epsilon=False + prediction_type: epsilon # or sample + + horizon: ${horizon} + obs_dim: ${obs_dim} + action_dim: ${action_dim} + n_action_steps: ${eval:'${n_action_steps}+${n_latency_steps}'} + n_obs_steps: ${n_obs_steps} + # num_inference_steps: 32 + num_inference_steps: ${n_diffusion_steps} + obs_as_local_cond: ${obs_as_local_cond} + obs_as_global_cond: ${obs_as_global_cond} + pred_action_steps_only: ${pred_action_steps_only} + oa_step_convention: True + + # scheduler.step params + # predict_epsilon: True + +ema: + _target_: diffusion_policy.model.diffusion.ema_model.EMAModel + update_after_step: 0 + inv_gamma: 1.0 + power: 0.75 + min_value: 0.0 + max_value: 0.9999 + +dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: True + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +val_dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: False + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +optimizer: + _target_: torch.optim.AdamW + lr: 1.0e-5 + betas: [0.95, 0.999] + eps: 1.0e-8 + weight_decay: 1.0e-6 + +training: + device: ${device} + seed: ${seed} + debug: False + resume: True + # optimization + lr_scheduler: cosine + lr_warmup_steps: 500 + num_epochs: 2000 + gradient_accumulate_every: 1 + use_ema: True + # training loop control + # in epochs + rollout_every: 5 + checkpoint_every: 10 + val_every: 1 + sample_every: 5 + # steps per epoch + max_train_steps: 30000 + max_val_steps: null + # misc + tqdm_interval_sec: 1.0 + +logging: + project: pcdiff_pusht + resume: True + mode: offline + # mode: online + name: LTL_value-${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + tags: ["${name}", "${task_name}", "${exp_name}"] + id: null + group: null + +checkpoint: + # topk: + # monitor_key: test_mean_score + # mode: max + # k: 10 + # format_str: 'epoch={epoch:04d}-test_mean_score={test_mean_score:.3f}.ckpt' + topk: + monitor_key: train_loss + mode: min + k: 10 + format_str: 'epoch={epoch:04d}-train_loss={train_loss:.5f}.ckpt' + save_last_ckpt: True + save_last_snapshot: False + +multi_run: + run_dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + wandb_name_base: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + +hydra: + job: + override_dirname: ${name} + run: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + sweep: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + subdir: ${hydra.job.num} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_coup.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_coup.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d20eb6c52db5eca2ed3521943f1d9555488a35be --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/eval_H16_pusht_proj_coup.yaml @@ -0,0 +1,209 @@ +defaults: + - _self_ + - task: H16_pusht_lowdim_fix_init.yaml + +name: eval_pcdiff_cost_coupling +_target_: diffusion_policy.workspace.eval_pcdiff_pcd_workspace.EvalPCDiffWorkspace + +obs_dim: ${task.obs_dim} +action_dim: ${task.action_dim} +keypoint_dim: ${task.keypoint_dim} +task_name: ${task.name} +exp_name: "eval-PushT-PCD-H16-O2-A8" + +n_init_states: 10 +trial: 20 + +## Projection +projector: "max_vel_admm" # ADMM projector +proj_profile_key: "max_vel" +v_max: 6.2 # 80% +# v_max: 8.4 # 90% +# v_max: 10.7 # 95% + +## Coupling +guider: 'coupling' +# guider: 'coupling_ps' +stp: 1 +scl: 2 +### Cost function +# cost_func_key: "sum_log_l2" +cost_func_key: "dpp" + +## Diffusion +diffusion_checkpoint: "data/pretrained/diffusion/diffusion_weights.ckpt" +n_diffusion_steps: 32 + +output_dir: 'logs/tests/pusht_pcd_output/H16O2A8D${policy.num_inference_steps}/${proj_profile_key}-max_v${v_max}-${cost_func_key}/max_stp${task.env_runner.max_steps}/init_st${n_init_states}-${projector}-${guider}-max_v${v_max}-stp${stp}-scl${scl}-t${trial}' +log_pth: "${output_dir}/eval_runs.log" + +device: 'cuda:0' +seed: 42 + +horizon: 16 +n_obs_steps: 2 +n_action_steps: 8 +n_latency_steps: 0 +past_action_visible: False +keypoint_visible_rate: 1.0 +obs_as_local_cond: False +obs_as_global_cond: True +pred_action_steps_only: False + +policy: + _target_: diffusion_policy.policy.diffusion_unet_lowdim_proj_coup_policy.DiffusionUnetLowdimProjCoupPolicy + _recursive_: true + + guide: + _target_: diffusion_policy.model.guide.cost_guide.CostGuide + cost_func: null + cost_func_param: + alpha: 0.1 + # decay_rate: 10.0 + + projector: # ADMM projector + _target_: diffusion_policy.model.projection.projection_operator.ADMMProjectionOperatorCUDAGraph + rho: 6.0 ## penalty for the augmented Lagrangian + max_iter: 250 + tol: 2e-4 ## tolerance for convergence + decomp: 'lu' + # decomp: 'cholesky' + convergence_check: True + # convergence_check: False + verbose: False + + + project_params: + delta_t: 0.01 + vel_max: ${v_max} + + n_guide_steps: ${stp} + grad_scale: ${scl} + t_stopgrad: 2 + threshold: 1.0e-8 + point_estimate: ${eval:'"${guider}".endswith("ps")'} + + model: + _target_: diffusion_policy.model.diffusion.conditional_unet1d.ConditionalUnet1D + input_dim: "${eval: ${task.action_dim} if ${obs_as_local_cond} or ${obs_as_global_cond} else ${task.obs_dim} + ${task.action_dim}}" + local_cond_dim: "${eval: ${task.obs_dim} if ${obs_as_local_cond} else None}" + global_cond_dim: "${eval: ${task.obs_dim}*${n_obs_steps} if ${obs_as_global_cond} else None}" + diffusion_step_embed_dim: 256 + down_dims: [256, 512, 1024] + kernel_size: 5 + n_groups: 8 + cond_predict_scale: True + + noise_scheduler: + _target_: diffusers.schedulers.scheduling_ddpm.DDPMScheduler + # num_train_timesteps: 32 + num_train_timesteps: ${n_diffusion_steps} + beta_start: 0.0001 + beta_end: 0.02 + beta_schedule: squaredcos_cap_v2 + variance_type: fixed_small # Yilun's paper uses fixed_small_log instead, but easy to cause Nan + clip_sample: True # required when predict_epsilon=False + prediction_type: epsilon # or sample + + horizon: ${horizon} + obs_dim: ${obs_dim} + action_dim: ${action_dim} + n_action_steps: ${eval:'${n_action_steps}+${n_latency_steps}'} + n_obs_steps: ${n_obs_steps} + num_inference_steps: ${n_diffusion_steps} + # num_inference_steps: 32 + obs_as_local_cond: ${obs_as_local_cond} + obs_as_global_cond: ${obs_as_global_cond} + pred_action_steps_only: ${pred_action_steps_only} + oa_step_convention: True + + # scheduler.step params + # predict_epsilon: True + +ema: + _target_: diffusion_policy.model.diffusion.ema_model.EMAModel + update_after_step: 0 + inv_gamma: 1.0 + power: 0.75 + min_value: 0.0 + max_value: 0.9999 + +dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: True + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +val_dataloader: + batch_size: 2048 + num_workers: 1 + shuffle: False + # accelerate cpu-gpu transfer + pin_memory: True + # don't kill worker process afte each epoch + persistent_workers: True + +optimizer: + _target_: torch.optim.AdamW + lr: 1.0e-5 + betas: [0.95, 0.999] + eps: 1.0e-8 + weight_decay: 1.0e-6 + +training: + device: ${device} + seed: ${seed} + debug: False + resume: True + # optimization + lr_scheduler: cosine + lr_warmup_steps: 500 + num_epochs: 2000 + gradient_accumulate_every: 1 + use_ema: True + # training loop control + # in epochs + rollout_every: 5 + checkpoint_every: 10 + val_every: 1 + sample_every: 5 + # steps per epoch + max_train_steps: 30000 + max_val_steps: null + # misc + tqdm_interval_sec: 1.0 + +logging: + project: pcdiff_pusht + resume: True + mode: offline + # mode: online + name: PCD-${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + tags: ["${name}", "${task_name}", "${exp_name}"] + id: null + group: null + +checkpoint: + topk: + monitor_key: train_loss + mode: min + k: 10 + format_str: 'epoch={epoch:04d}-train_loss={train_loss:.5f}.ckpt' + save_last_ckpt: True + save_last_snapshot: False + +multi_run: + run_dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + wandb_name_base: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + +hydra: + job: + override_dirname: ${name} + run: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + sweep: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + subdir: ${hydra.job.num} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/H16_pusht_lowdim_fix_init.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/H16_pusht_lowdim_fix_init.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f49a0e70f47beeca731817d8d73bbe9a5497487b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/H16_pusht_lowdim_fix_init.yaml @@ -0,0 +1,37 @@ +name: H16_pusht_lowdim_fix_init + +obs_dim: 20 # 9*2 keypoints + 2 state +action_dim: 2 +keypoint_dim: 2 + +env_runner: + _target_: diffusion_policy.env_runner.pusht_keypoints_fix_init_runner.PushTKeypointsFixInitRunner + keypoint_visible_rate: ${keypoint_visible_rate} + n_train: 0 + n_train_vis: 0 + train_start_seed: 0 + n_test: ${trial} + n_test_vis: 5 + legacy_test: False # Stick to FALSE! + test_start_seed: 100000 + max_steps: 360 + # max_steps: 32 # For debugging + n_obs_steps: ${n_obs_steps} + n_action_steps: ${n_action_steps} + n_latency_steps: ${n_latency_steps} + fps: 20 + agent_keypoints: False + past_action: ${past_action_visible} + # constraint_key: ${constraint_type} + n_envs: null + # n_envs: 200 + +# dataset: +# _target_: diffusion_policy.dataset.pusht_dataset.PushTLTLValueDataset +# zarr_path: data/pusht/pusht_cchi_v7_replay.zarr +# horizon: ${horizon} +# pad_before: ${eval:'${n_obs_steps}-1+${n_latency_steps}'} +# pad_after: ${eval:'${n_action_steps}-1'} +# seed: 42 +# val_ratio: 0.0 +# max_train_episodes: 90 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_full_state.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_full_state.yaml new file mode 100644 index 0000000000000000000000000000000000000000..096d533ac16713ddc33bd2cd634ce8e75c5f35b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_full_state.yaml @@ -0,0 +1,30 @@ +name: pusht_full_state + +obs_dim: 5 # agent pos (2) + block pos (2) + block rot (1) +action_dim: 2 +# keypoint_dim: 2 + +env_runner: + _target_: diffusion_policy.env_runner.pusht_full_state_runner.PushTFullStateRunner + n_train: 6 + n_train_vis: 2 + train_start_seed: 0 + n_test: 50 + n_test_vis: 4 + legacy_test: True + test_start_seed: 100000 + max_steps: 300 + n_obs_steps: ${n_obs_steps} + n_action_steps: ${n_action_steps} + n_latency_steps: ${n_latency_steps} + fps: 10 + past_action: ${past_action_visible} + n_envs: null + +dataset: + _target_: diffusion_policy.dataset.pusht_dataset.PushTStateDataset + zarr_path: data/pusht/pusht_cchi_v7_replay.zarr + horizon: ${horizon} + seed: 42 + val_ratio: 0.02 + max_train_episodes: 90 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_lowdim.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_lowdim.yaml new file mode 100644 index 0000000000000000000000000000000000000000..23c48f3224411134d27c93ed249170b7af575ab0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/task/pusht_lowdim.yaml @@ -0,0 +1,34 @@ +name: pusht_lowdim + +obs_dim: 20 # 9*2 keypoints + 2 state +action_dim: 2 +keypoint_dim: 2 + +env_runner: + _target_: diffusion_policy.env_runner.pusht_keypoints_runner.PushTKeypointsRunner + keypoint_visible_rate: ${keypoint_visible_rate} + n_train: 2 + n_train_vis: 2 + train_start_seed: 0 + n_test: 50 + n_test_vis: 5 + legacy_test: True + test_start_seed: 100000 + max_steps: 223 + n_obs_steps: ${n_obs_steps} + n_action_steps: ${n_action_steps} + n_latency_steps: ${n_latency_steps} + fps: 10 + agent_keypoints: False + past_action: ${past_action_visible} + n_envs: null + +dataset: + _target_: diffusion_policy.dataset.pusht_dataset.PushTLowdimDataset + zarr_path: 'data/pusht/merged_pusht_cchi_v7_replay-seed_42r_33rf.zarr' + horizon: ${horizon} + pad_before: ${eval:'${n_obs_steps}-1+${n_latency_steps}'} + pad_after: ${eval:'${n_action_steps}-1'} + seed: 42 + val_ratio: 0.25 + max_train_episodes: null diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/train_diffusion_unet_lowdim_workspace.yaml b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/train_diffusion_unet_lowdim_workspace.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7b4d35f484c7ee2024135260d7c256c2bb61817a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/config/train_diffusion_unet_lowdim_workspace.yaml @@ -0,0 +1,143 @@ +defaults: + - _self_ + - task: pusht_lowdim + +name: train_diffusion_unet_lowdim +_target_: diffusion_policy.workspace.train_diffusion_unet_lowdim_workspace.TrainDiffusionUnetLowdimWorkspace + +obs_dim: ${task.obs_dim} +action_dim: ${task.action_dim} +keypoint_dim: ${task.keypoint_dim} +task_name: ${task.name} +exp_name: "default" + +horizon: 16 +n_obs_steps: 2 +n_action_steps: 8 +n_latency_steps: 0 +past_action_visible: False +keypoint_visible_rate: 1.0 +obs_as_local_cond: False +obs_as_global_cond: True +pred_action_steps_only: False + +policy: + _target_: diffusion_policy.policy.diffusion_unet_lowdim_policy.DiffusionUnetLowdimPolicy + + model: + _target_: diffusion_policy.model.diffusion.conditional_unet1d.ConditionalUnet1D + input_dim: "${eval: ${task.action_dim} if ${obs_as_local_cond} or ${obs_as_global_cond} else ${task.obs_dim} + ${task.action_dim}}" + local_cond_dim: "${eval: ${task.obs_dim} if ${obs_as_local_cond} else None}" + global_cond_dim: "${eval: ${task.obs_dim}*${n_obs_steps} if ${obs_as_global_cond} else None}" + diffusion_step_embed_dim: 256 + down_dims: [256, 512, 1024] + kernel_size: 5 + n_groups: 8 + cond_predict_scale: True + + noise_scheduler: + _target_: diffusers.schedulers.scheduling_ddpm.DDPMScheduler + num_train_timesteps: 100 + beta_start: 0.0001 + beta_end: 0.02 + beta_schedule: squaredcos_cap_v2 + variance_type: fixed_small # Yilun's paper uses fixed_small_log instead, but easy to cause Nan + clip_sample: True # required when predict_epsilon=False + prediction_type: epsilon # or sample + + horizon: ${horizon} + obs_dim: ${obs_dim} + action_dim: ${action_dim} + n_action_steps: ${eval:'${n_action_steps}+${n_latency_steps}'} + n_obs_steps: ${n_obs_steps} + num_inference_steps: 100 + obs_as_local_cond: ${obs_as_local_cond} + obs_as_global_cond: ${obs_as_global_cond} + pred_action_steps_only: ${pred_action_steps_only} + oa_step_convention: True + + # scheduler.step params + # predict_epsilon: True + +ema: + _target_: diffusion_policy.model.diffusion.ema_model.EMAModel + update_after_step: 0 + inv_gamma: 1.0 + power: 0.75 + min_value: 0.0 + max_value: 0.9999 + +dataloader: + batch_size: 256 + num_workers: 1 + shuffle: True + pin_memory: True + persistent_workers: False + +val_dataloader: + batch_size: 256 + num_workers: 1 + shuffle: False + pin_memory: True + persistent_workers: False + +optimizer: + _target_: torch.optim.AdamW + lr: 1.0e-4 + betas: [0.95, 0.999] + eps: 1.0e-8 + weight_decay: 1.0e-6 + +training: + device: "cuda:0" + seed: 42 + debug: False + resume: True + # optimization + lr_scheduler: cosine + lr_warmup_steps: 500 + num_epochs: 5000 + gradient_accumulate_every: 1 + use_ema: True + # training loop control + # in epochs + rollout_every: 50 + checkpoint_every: 50 + val_every: 1 + sample_every: 5 + # steps per epoch + max_train_steps: null + max_val_steps: null + # misc + tqdm_interval_sec: 1.0 + +logging: + project: diffusion_policy_debug + resume: True + mode: online + name: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + tags: ["${name}", "${task_name}", "${exp_name}"] + id: null + group: null + +checkpoint: + topk: + monitor_key: test_mean_score + mode: max + k: 5 + format_str: 'epoch={epoch:04d}-test_mean_score={test_mean_score:.3f}.ckpt' + save_last_ckpt: True + save_last_snapshot: False + +multi_run: + run_dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + wandb_name_base: ${now:%Y.%m.%d-%H.%M.%S}_${name}_${task_name} + +hydra: + job: + override_dirname: ${name} + run: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + sweep: + dir: data/outputs/${now:%Y.%m.%d}/${now:%H.%M.%S}_${name}_${task_name} + subdir: ${hydra.job.num} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/pusht_constraints.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/pusht_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..48231909dd052a21b93b5014feed39dbe429da86 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/pusht_constraints.py @@ -0,0 +1,120 @@ +import numpy as np +import torch +import math +from typing import Union + +## [., ., block_x(col), block_y(row), angle] +OLD_STATE_MINS = torch.tensor([13.456424, 32.938293, 57.471767, 108.27995, 0.00021559125]) +OLD_STATE_MAXS = torch.tensor([496.14618, 510.9579, 439.9153, 485.6641, 6.2830877]) +STATE_MINS = torch.tensor([0.65232, 1.23263, 4.13197, 4.57473, 0.000018104653], dtype=torch.float64) +STATE_MAXS = torch.tensor([510.67751, 510.95900, 496.99951, 507.15730, 6.283179], dtype=torch.float64) +STATE_BOUNDS_MIN = torch.tensor([ 0, 0, 0, 0, 0], dtype=torch.float64) +STATE_BOUNDS_MAX = torch.tensor([512, 512, 512, 512, 2*math.pi], dtype=torch.float64) +GOAL_POSE = torch.tensor([256.00, 256.00, 0.79], dtype=torch.float64) +EPS = 1e-8 + +def normalize(s, norm): + return 2*(s-norm[0])/(norm[1]-norm[0])-1 + +def get_rectangle_boundry(center, norm, radius=0.5): + """ + center: (row, col) + norm: {'row': (min, max), + 'col': (min, max),} + """ + row, col = center[0], center[1] + if isinstance(radius, float): + radius = (radius, radius) + + def p_bottom(x): + return normalize(row+radius[0], norm['row']) - x[:,2] + + def p_up(x): + return x[:,2] - normalize(row-radius[0], norm['row']) + + def p_left(x): + return x[:,3] - normalize(col-radius[1], norm['col']) + + def p_right(x): + return normalize(col+radius[1], norm['col']) - x[:,3] + + return [p_bottom, p_up, p_left, p_right] + + +### constraints for agent +def agent_in_circles(center=[256,256], rad=64., normalized=False): + """ + Define and return a list of constraints for the block-in-circle test. + """ + assert isinstance(rad, float) + def in_circle(st:torch.tensor, normalized=normalized, + custom_normalizer=None): + """ + Args: + st (torch.tensor): (time_steps, feature, batch) + normalized (bool): whether the input is normalized already; + default is False. + """ + dim = slice(0, 2) + + if custom_normalizer is None: + normed_center = normalize(torch.tensor(center), + [STATE_BOUNDS_MIN[dim], STATE_BOUNDS_MAX[dim]] ) + ### radius only needs scaling + normed_rad = 2*torch.tensor([rad, rad]) / (STATE_BOUNDS_MAX[dim] - STATE_BOUNDS_MIN[dim]) + else: + normed_center = custom_normalizer.normalize(torch.tensor(center)) + normed_rad = custom_normalizer.normalize(torch.tensor([rad, rad])) + if not normalized: + ## normalization + normed_x = normalize(st[:, 0], + [STATE_BOUNDS_MIN[0], STATE_BOUNDS_MAX[0]] ) + normed_y = normalize(st[:, 1], + [STATE_BOUNDS_MIN[1], STATE_BOUNDS_MAX[1]] ) + #end if + else: + ## no need for built-in normalization + normed_x = st[:, 0] + normed_y = st[:, 1] + + dx_sqr = torch.square(normed_x - normed_center[0]) / torch.square(normed_rad[0]) + dy_sqr = torch.square(normed_y - normed_center[1]) / torch.square(normed_rad[1]) + + d = torch.tensor(1.) - torch.sqrt(dx_sqr + dy_sqr) + assert torch.le(d, torch.tensor(1.)).min()>0, "d should be less than 1!" + return d + + return [in_circle] + + +ef_params = [ + [(260, 255), 20.], + [(305, 200), 25.], + [(280, 330), 30.], + [(160, 320), 25.], +] + +ef_con_groups = [ + agent_in_circles(center=np.array(p[0]), rad=p[1]) for p in ef_params +] + + +constraint_param_dict = { + 'ef_oa': ef_params, + 'ef_tp': ef_params, +} + +constraint_dict = { + 'ef_oa': ef_con_groups, + 'ef_tp': ef_con_groups, +} + +### check completeness +for key in constraint_param_dict.keys(): + assert constraint_dict.get(key) is not None, \ + f"Param key \'{key}\' not found in constraint_dict" +#end for +# for key in constraint_dict.keys(): +# assert constraint_param_dict.get(key) is not None, \ +# f"Constraint key \'{key}\' not found in constraint_param_dict" +# #end for diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/velocity_constraints.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/velocity_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..44ef04ed6c2b73d37152905feff08b0f8fa60199 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/constraints/velocity_constraints.py @@ -0,0 +1,72 @@ +import numpy as np +import torch +import cvxpy as cp +from typing import Optional, Union, List, Dict, Tuple, Optional + + +def vel_proj_obj(var_dict, param_dict) -> cp.Expression: + """ + Objective function for velocity projection, which minimizes the squared difference + between the current position and the planned position. + Args: + var_dict (Dict[str, cp.Variable]): Dictionary containing the variable 'pos'. + param_dict (Dict[str, Union[cp.Parameter, np.ndarray]]): Dictionary containing + parameters including 'pos'. + Returns: + cp.Expression: The objective function to be minimized. + """ + assert 'pos' in var_dict, "Variable dictionary must contain 'pos'." + assert 'pos' in param_dict, "Parameter dictionary must contain 'pos'." + return cp.sum_squares(var_dict['pos'] - param_dict['pos']) + +def vel_proj_constraints(var_dict, param_dict) -> List[cp.Expression]: + """ + Constraints for velocity projection, ensuring that the position at each time step + is within the maximum velocity constraint. + Args: + var_dict (Dict[str, cp.Variable]): Dictionary containing the variable 'pos'. + param_dict (Dict[str, Union[cp.Parameter, np.ndarray]]): Dictionary containing + parameters including 'pos_init', 'vel_max', and 'delta_t'. + Returns: + List[cp.Expression]: List of constraints to be satisfied. + """ + assert 'pos_init' in param_dict, "Parameter dictionary must contain 'pos_init'." + + cons_ls = [] + ## Anchor to the initial position + cons_ls.append(cp.norm(var_dict['pos'][0] - param_dict['pos_init'], 2) <= param_dict['dx_max']) + ## Ensure the position at each time step is within the maximum velocity constraint + for t in range(1, var_dict['pos'].shape[0]): + cons_ls.append(cp.norm(var_dict['pos'][t] - var_dict['pos'][t-1], 2) <= param_dict['dx_max']) + + return cons_ls + +def max_vel_constraint_bundle(horizon: int, ndim: int): + """ + Creates a bundle of constraints for velocity projection in a trajectory optimization problem. + Args: + horizon (int): The number of time steps in the trajectory. + ndim (int): The dimensionality of the position space. + Returns: + Dict: A dictionary containing the variable definitions, objective function, and constraints. + """ + assert horizon > 0, "Horizon must be a positive integer." + assert ndim > 0, "Dimension must be a positive integer." + variables_def = {'pos': (horizon, ndim)} + parameters_def = { + 'pos_init': (ndim,), + 'pos': (horizon, ndim), + 'dx_max': () + } + + return { + "variables": variables_def, + "parameters": parameters_def, + "objective": vel_proj_obj, + "constraints": vel_proj_constraints + } + + +vel_constraint_registry = { + "max_vel": max_vel_constraint_bundle, +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/coupling_cost/cost_functions.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/coupling_cost/cost_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..7198afbd6daa72d00c935add4f2e1e8bdadf6d6e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/coupling_cost/cost_functions.py @@ -0,0 +1,111 @@ +import torch +import numpy as np +import math + + +def sum_log_l2_cost( + x: torch.Tensor, + y: torch.Tensor, + alpha: float, # A small constant to avoid singularity in log + **kwargs, +) -> torch.Tensor: + """ + Computes a summed, negative-logarithmic penalty on squared L2 distances + for batches of trajectories. At each time-step, the cost is + // -log( sum_d (x_hd - y_hd) + epsilon ) + -log( sum_d (x_hd - y_hd) + 1 ) + which strongly penalizes small distances and grows more gently as + distance increases. + + Args: + x (torch.Tensor): Tensor of shape (B, H, d). + y (torch.Tensor): Tensor of shape (B, H, d). Must match x's shape. + epsilon (float): Small constant added inside the log to avoid + singularity when distance is zero. + + Returns: + torch.Tensor: A tensor of shape (B,), where each element is + the total log-penalty cost for one trajectory pair. + """ + if x.shape != y.shape or x.dim() < 3: + raise ValueError("Expected x,y of shape (..., H, d).") + + # L2 norm per time-step -> (B, H) + # l2_sqr = ((x - y)**2).sum(dim=-1) + l2 = torch.linalg.norm(x - y, dim=-1) # L2 norm + + # -2 * log of clamped norm + # per_step = -0.5 * torch.log(torch.clamp(l2_sqr, epsilon)) + per_step = -torch.log(alpha + l2) + + # sum over time -> (B,) + return per_step.sum(dim=-1) + + +def dpp_cost(x: torch.Tensor, y: torch.Tensor, eps: float = 1e-7, **kwargs) -> torch.Tensor: + """ + Computes a diversity score based on the Determinantal Point Process (DPP) + for pairs of trajectories (x_i, y_i). The score is the log-determinant + of the kernel matrix formed by each pair. A higher score corresponds to + more diverse pairs of trajectories. + + This function handles tensors with 3 or more dimensions, applying the DPP + calculation to the last two dimensions while preserving all leading dimensions. + + The group size is fixed to 2, with the groups being (x_i, y_i) for each + item i in the batch dimensions. + + Args: + x (torch.Tensor): The first batch of trajectories, shape (..., H, d). + y (torch.Tensor): The second batch of trajectories, shape (..., H, d). + eps (float): A small epsilon for numerical stability, used to clamp + the determinant before taking the logarithm. + + Returns: + torch.Tensor: A tensor of costs with shape (...), where each element is the + log-determinant for the corresponding pair in the batch. + This cost should be minimized for diversity. + """ + if x.shape != y.shape: + raise ValueError("Input tensors x and y must have the same shape.") + if x.dim() < 3: + raise ValueError(f"Input tensors must be at least 3D (..., H, d). Got {x.dim()}-D.") + + # Flatten the last two dimensions (H, d) into a single feature vector. + # For an input of shape (..., H, d), this results in shape (..., H*d). + phi_x = torch.flatten(x, start_dim=-2) + phi_y = torch.flatten(y, start_dim=-2) + + # Stack to form groups of 2 for each item in the batch dimensions. + # phi will have a shape of (..., 2, H*d). + phi = torch.stack([phi_x, phi_y], dim=-2) + + # Normalize feature vectors along the feature dimension (the last dimension). + phi = torch.nn.functional.normalize(phi, p=2, dim=-1) + + # Compute the kernel matrix for each item. `torch.matmul` (@) handles + # batching over all leading dimensions. + # For phi of shape (..., 2, H*d), the resulting kernel matrix S + # will have a shape of (..., 2, 2). + S = phi @ phi.transpose(-1, -2) + assert S.shape[-1] == S.shape[-2] == 2, "Kernel matrix S should be of shape (..., 2, 2)." + + # Compute the determinant for each 2x2 kernel matrix. + # The result will have a shape of (...). + det = torch.linalg.det(S) + + # Clamp the determinant to avoid values. + det = torch.clamp(torch.abs(det), min=eps) + + # The cost is the log determinant. Minimizing this cost encourages + # diversity between the x and y trajectories. + cost = -torch.log(det) + + return cost + + +# Registry for cost functions +cost_registry = { + "sum_log_l2": sum_log_l2_cost, + "dpp": dpp_cost, +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/base_dataset.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/base_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..72a39e8928aa6fdcea15f0427f1f2d4f1339fc78 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/base_dataset.py @@ -0,0 +1,51 @@ +from typing import Dict + +import torch +import torch.nn +from diffusion_policy.model.common.normalizer import LinearNormalizer + +class BaseLowdimDataset(torch.utils.data.Dataset): + def get_validation_dataset(self) -> 'BaseLowdimDataset': + # return an empty dataset by default + return BaseLowdimDataset() + + def get_normalizer(self, **kwargs) -> LinearNormalizer: + raise NotImplementedError() + + def get_all_actions(self) -> torch.Tensor: + raise NotImplementedError() + + def __len__(self) -> int: + return 0 + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + """ + output: + obs: T, Do + action: T, Da + """ + raise NotImplementedError() + + +class BaseImageDataset(torch.utils.data.Dataset): + def get_validation_dataset(self) -> 'BaseLowdimDataset': + # return an empty dataset by default + return BaseImageDataset() + + def get_normalizer(self, **kwargs) -> LinearNormalizer: + raise NotImplementedError() + + def get_all_actions(self) -> torch.Tensor: + raise NotImplementedError() + + def __len__(self) -> int: + return 0 + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + """ + output: + obs: + key: T, * + action: T, Da + """ + raise NotImplementedError() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/pusht_dataset.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/pusht_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..ff0d569ff6cfd04b8cb1ebafaa7136abd7413ce0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dataset/pusht_dataset.py @@ -0,0 +1,502 @@ +from typing import Dict +import os +import sys +import torch +import numpy as np +import copy +import pdb +from torch._tensor import Tensor +import zarr +from tqdm import tqdm +from diffusion_policy.common.pytorch_util import dict_apply +from diffusion_policy.common.replay_buffer import ReplayBuffer +from diffusion_policy.common.sampler import ( + SequenceSampler, get_val_mask, downsample_mask) +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.common.simple_util import get_data_stats, \ + normalize_data, create_sample_indices, sample_sequence + +class PushTLowdimDataset(BaseLowdimDataset): + def __init__(self, + zarr_path, + horizon=1, + pad_before=0, + pad_after=0, + obs_key='keypoint', + state_key='state', + action_key='action', + seed=42, + val_ratio=0.0, + max_train_episodes=None + ): + super().__init__() + self.replay_buffer = ReplayBuffer.copy_from_path( + zarr_path, keys=[obs_key, state_key, action_key]) + + val_mask = get_val_mask( + n_episodes=self.replay_buffer.n_episodes, + val_ratio=val_ratio, + seed=seed) + self.val_mask = val_mask.copy() + train_mask = ~val_mask + train_mask = downsample_mask( + mask=train_mask, + max_n=max_train_episodes, + seed=seed) + + self.sampler = SequenceSampler( + replay_buffer=self.replay_buffer, + sequence_length=horizon, + pad_before=pad_before, + pad_after=pad_after, + episode_mask=train_mask + ) + self.obs_key = obs_key + self.state_key = state_key + self.action_key = action_key + self.train_mask = train_mask + self.horizon = horizon + self.pad_before = pad_before + self.pad_after = pad_after + + def get_validation_dataset(self): + val_set = copy.copy(self) + val_set.sampler = SequenceSampler( + replay_buffer=self.replay_buffer, + sequence_length=self.horizon, + pad_before=self.pad_before, + pad_after=self.pad_after, + episode_mask=self.val_mask + ) + val_set.train_mask = self.val_mask + val_set.val_mask = None + return val_set + + def get_normalizer(self, mode='limits', **kwargs): + data = self._sample_to_data(self.replay_buffer) + normalizer = LinearNormalizer() + normalizer.fit(data=data, last_n_dims=1, mode=mode, **kwargs) + return normalizer + + def get_all_actions(self) -> torch.Tensor: + return torch.from_numpy(self.replay_buffer[self.action_key]) + + def __len__(self) -> int: + return len(self.sampler) + + def _sample_to_data(self, sample): + keypoint = sample[self.obs_key] + state = sample[self.state_key] + agent_pos = state[:,:2] + obs = np.concatenate([ + keypoint.reshape(keypoint.shape[0], -1), + agent_pos], axis=-1) + + data = { + 'obs': obs, # T, D_o + 'action': sample[self.action_key], # T, D_a + } + return data + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + sample = self.sampler.sample_sequence(idx) + data = self._sample_to_data(sample) + + torch_data = dict_apply(data, torch.from_numpy) + return torch_data + + +class PushTStateDataset(torch.utils.data.Dataset): + def __init__(self, zarr_path, + pred_horizon, obs_horizon, action_horizon): + + # read from zarr dataset + dataset_root = zarr.open(zarr_path, 'r') + # All demonstration episodes are concatinated in the first dimension N + train_data = { + # (N, action_dim) + 'action': dataset_root['data']['action'][:], + # (N, obs_dim) + 'obs': dataset_root['data']['state'][:] + } + # Marks one-past the last index for each episode + episode_ends = dataset_root['meta']['episode_ends'][:] + + # compute start and end of each state-action sequence + # also handles padding + indices = create_sample_indices( + episode_ends=episode_ends, + sequence_length=pred_horizon, + # add padding such that each timestep in the dataset are seen + pad_before=obs_horizon-1, + pad_after=action_horizon-1) + + # compute statistics and normalized data to [-1,1] + stats = dict() + normalized_train_data = dict() + for key, data in train_data.items(): + stats[key] = get_data_stats(data) + normalized_train_data[key] = normalize_data(data, stats[key]) + + self.indices = indices + self.stats = stats + self.normalized_train_data = normalized_train_data + self.pred_horizon = pred_horizon + self.action_horizon = action_horizon + self.obs_horizon = obs_horizon + + def __len__(self): + # all possible segments of the dataset + return len(self.indices) + + def __getitem__(self, idx): + # get the start/end indices for this datapoint + buffer_start_idx, buffer_end_idx, \ + sample_start_idx, sample_end_idx = self.indices[idx] + + # get nomralized data using these indices + nsample = sample_sequence( + train_data=self.normalized_train_data, + sequence_length=self.pred_horizon, + buffer_start_idx=buffer_start_idx, + buffer_end_idx=buffer_end_idx, + sample_start_idx=sample_start_idx, + sample_end_idx=sample_end_idx + ) + + # discard unused observations + nsample['obs'] = nsample['obs'][:self.obs_horizon,:] + return nsample + + +class BasePushTValueDataset(PushTLowdimDataset): + def __init__(self, *args, + constraint_key=None, + name_value='', + filename_values = None, + normalize=False, + output=print, + **kwargs): + super().__init__(*args, **kwargs) + self.output = output + self._seed = kwargs.get('seed', 42) + + output(f"[ dataset/pusht_dataset.BasePushTValueDataset ] Created {len(self.sampler)} trajectories of length {self.horizon}.") + + ## Get values from file or calculate them + if not filename_values: + filename = 'datasets/' + '-'.join(self.env.name.split('-')[:-1] + ["H"+str(self.horizon), name_value, 'values.npy']) + else: + filename = filename_values + if os.path.isfile(filename): + self.all_values = np.load(filename) + self._get_value_bounds(self.all_values) + self.output(f"[ BasePushTValueDataset.__init__ ] Loaded generated values of \'{name_value}\' constraints (key={constraint_key}) from \'{filename}\'.") + else: + self.output(f"[ dataset/pusht_dataset.BasePushTValueDataset ] Cold storage values not found. Generating values of {name_value} constraints (key={constraint_key}) and save values in \'{filename}\'.") + self.all_values = self._get_values(constraint_key=constraint_key) + self._get_value_bounds(self.all_values) + np.save(filename, self.all_values) + self.output(f"[ dataset/pusht_dataset.BasePushTValueDataset ] Saved generated values of {name_value} constraints to \'{filename}\'") + + self.normed = False + if normalize: + self.unormed_all_values = self.all_values.copy() + self.all_values = self.normalize_value(self.all_values, preserve_sign=True) + self.normed = True + + + + def _sample_to_data(self, sample): + state = sample[self.state_key] + keypoint = sample[self.obs_key] + agent_pos = state[:,:2] + obs = np.concatenate([ + keypoint.reshape(keypoint.shape[0], -1), + agent_pos], axis=-1) + + data = { + 'obs': obs, # T, D_o + 'state': state, # T, D_s + 'action': sample[self.action_key], # T, D_a + } + + return data + + + def _get_values(self): + raise NotImplementedError("Method of calculating values is not implemented.") + + + def _get_bounds(self): + self.output('[ datasets/pusht_dataset.BasePushTValueDataset ] Getting value dataset bounds...', end=' ', flush=True) + vmin = np.inf + vmax = -np.inf + for i in range(len(self.indices)): + value = self.__getitem__(i).values.item() + vmin = min(value, vmin) + vmax = max(value, vmax) + self.output('✓') + return vmin, vmax + + + def _get_value_bounds(self, value): + """ + Args: + value: (time, feature) + """ + self.output('[ dataset/pusht_dataset.BasePushTValueDataset ] Getting value dataset bounds...', end=' ', flush=True) + self.val_min = np.inf * np.ones(value.shape[-1]) + self.val_max = -np.inf * np.ones(value.shape[-1]) + for i in range(value.shape[-1]): + self.val_min[i] = np.min(value[..., i]) + self.val_max[i] = np.max(value[..., i]) + + self.output(f"value_min: {self.val_min}; value_max: {self.val_max}") + self.output('✓') + + return self.val_min, self.val_max + + + def normalize_value(self, value, preserve_sign=True): + module = torch if isinstance(value, torch.Tensor) else np + if preserve_sign: + normed = value.copy() + for i in range(value.shape[-1]): + neg_vals_mask = value[..., i] < 0 + pos_vals_mask = ~neg_vals_mask + if self.val_max[i] > 0: + normed[pos_vals_mask, i] = (value[pos_vals_mask, i] - 0) / self.val_max[i] + if self.val_min[i] < 0: + normed[neg_vals_mask, i] = (0 - value[neg_vals_mask, i]) / self.val_min[i] + #end for + assert module.all(normed >= -1) and module.all(normed <= 1), \ + f"[ BasePushTValueDataset.normalize_value ]: Normalized value out of bounds!" + + if not preserve_sign: + ## [0, 1] + normed = (value - self.val_min) / (self.val_max - self.val_min) + ## [-1, 1] + normed = normed * 2 - 1 + + return normed + + +class PushTLTLValueDataset(BasePushTValueDataset): + ''' + adds a value field to the datapoints for training the value function of LTL constraints + ''' + def __init__(self, + *args, + constraint_key='', + value_name='ltl_no', + filename_ltls = 'data/pusht/LTLs/ltls_no.txt', + filename_values = None, + num_props=4, + ltl_val_ratio=0.0, + normalize = False, + **kwargs): + with open(filename_ltls) as file: + ltls=file.read() + ltls=ltls.split("\n")[0:-1] + + self.ltls = ltls + self.ltl_train_ratio = 1 - ltl_val_ratio + self.n_train_ltls = round(self.ltl_train_ratio * len(ltls)) + self.n_validation_ltls = len(ltls) - self.n_train_ltls + print(f"[ PushTLTLValueDataset.__init__ ]: Loaded {len(ltls)} LTLs from file \'{filename_ltls}\'. Training on {self.n_train_ltls} LTLs and validating on {self.n_validation_ltls} LTLs.") + super().__init__(*args, + constraint_key=constraint_key, + name_value=value_name, + filename_values=filename_values, + normalize=normalize, + **kwargs) + self.ltl_graphs = self._get_graphs(num_props=num_props) + + ## Construct value training set from all values + train_inds = self._get_trajectories_mask( + episode_ends=self.replay_buffer.episode_ends, + sequence_length=self.horizon, + episode_mask=self.train_mask, + pad_before=self.pad_before, + pad_after=self.pad_after + ) + assert train_inds.shape[0] == self.all_values.shape[0], \ + f"[ PushTLTLValueDataset.__init__ ]: Value set indices size ({train_inds.shape[0]}) does not match sampler size ({self.all_values.shape[0]})." + self.values = self.all_values[train_inds, :self.n_train_ltls] + + + def get_validation_dataset(self): + val_set = copy.copy(self) + val_set.sampler = SequenceSampler( + replay_buffer=self.replay_buffer, + sequence_length=self.horizon, + pad_before=self.pad_before, + pad_after=self.pad_after, + episode_mask=self.val_mask + ) + val_set.train_mask = self.val_mask + + inds = self._get_trajectories_mask( + episode_ends=self.replay_buffer.episode_ends, + sequence_length=self.horizon, + episode_mask=self.val_mask, + pad_before=self.pad_before, + pad_after=self.pad_after + ) + assert inds.shape[0] == self.all_values.shape[0], \ + f"[ PushTLTLValueDataset.get_validation_dataset ]: Value set indices size ({inds.shape[0]}) does not match value size ({self.all_values.shape[0]})." + + if self.n_validation_ltls > 0: + ## Validation using unseen LTLs and unseen trajectories + val_set.ltls = self.ltls[self.n_train_ltls:] + val_set.values = self.all_values[inds, self.n_train_ltls:] + val_set.ltl_graphs = self.ltl_graphs[self.n_train_ltls:] + val_set.n_train_ltls = self.n_validation_ltls + elif np.sum(val_set.train_mask) > 0: + ## Take all training LTLs with unseen trajectories for validation + val_set.values = self.all_values[inds, :] + else: + ## Validation set is empty + val_set.values = np.empty((0, self.all_values.shape[1])) + #end if + assert val_set.values.shape[0] == len(val_set.sampler), \ + f"[ PushTLTLValueDataset.get_validation_dataset ]: Value set size ({val_set.values.shape[0]}) does not match sampler size ({len(val_set.sampler)})." + + val_set.n_validation_ltls = None + + return val_set + + + def _get_trajectories_mask(self, + episode_ends:np.ndarray, sequence_length:int, + episode_mask: np.ndarray, + pad_before: int=0, pad_after: int=0, + debug:bool=True) -> np.ndarray: + episode_mask.shape == episode_ends.shape + pad_before = min(max(pad_before, 0), sequence_length-1) + pad_after = min(max(pad_after, 0), sequence_length-1) + + indices = list() + for i in range(len(episode_ends)): + start_idx = 0 + if i > 0: + start_idx = episode_ends[i-1] + end_idx = episode_ends[i] + episode_length = end_idx - start_idx + + min_start = -pad_before + max_start = episode_length - sequence_length + pad_after + + # range stops one idx before end + indices += [episode_mask[i]] * (max_start + 1 - min_start) + indices = np.array(indices, dtype=bool) + return indices + + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + idx_trj = idx // self.n_train_ltls + idx_ltl = idx % self.n_train_ltls + + batch = super().__getitem__(idx_trj) + + value = self.values[idx_trj, idx_ltl] + value = torch.tensor([value], dtype=torch.float32) + + + ltl_idx = torch.tensor(idx_ltl, dtype=torch.long) + + ltlvalue_batch = batch | {'value': value, 'ltl_idx': ltl_idx} + + return ltlvalue_batch + + + + def __len__(self) -> int: + return len(self.sampler) * self.n_train_ltls + + + def _get_values(self, constraint_key, batch_size=32768*4): + from diffusion_policy.dtl.dtl_cont_cons import DTL_Cont_Cons_Evaluator + from diffusion_policy.constraints.pusht_constraints import constraint_dict + # Raise the recursion limit to avoid problems when parsing formulas + sys.setrecursionlimit(10000) + # Setting TL_RECORD_TRACE asks DTL to record the evaluation trace. + # Using this we can find the conflicting part between logits and formula. + os.environ['TL_RECORD_TRACE'] = '1' + + props = ['p0', 'p1', 'p2', 'p3'] + evaluator = DTL_Cont_Cons_Evaluator(device='cuda') + # assignments = self._get_assignments(evaluator, batch_size=batch_size) + # sampler = BatchSampler(SequentialSampler(range(len(assignments))), batch_size=batch_size, drop_last=False) + obj_super = super() + class IterSuper(torch.utils.data.Dataset): + def __init__(self, obj_super): + super().__init__() + self.obj_super = obj_super + def __len__(self): + return self.obj_super.__len__() + def __getitem__(self, idx): + return self.obj_super.__getitem__(idx) + + iter_super_train = IterSuper(obj_super) + loader_super = torch.utils.data.DataLoader(iter_super_train, batch_size=batch_size) + + + values_train = np.full( + ( len(self.sampler), self.n_train_ltls ), + fill_value=np.nan, + dtype=np.float64 + ) + for idx_ltl in tqdm(range(self.n_train_ltls)): + ltl = self.ltls[idx_ltl] + ltl = ltl.replace('False', 'FALSE') + ltl = ltl.replace('True', 'TRUE') + evaluator.set_props_cons_and_ltl(constraint_dict[constraint_key], props, ltl) + self.output(f'[ datasets/pusht_dataset ] get values for {idx_ltl}-th LTL: {ltl} -> {evaluator.ltl}') + value = [] + + for batch in tqdm(loader_super): + # trjs = torch.tensor(batch.trajectories, dtype=torch.float32).cuda() + trjs = batch['state'].cuda() + # normed_trjs = normalizer.normalize(trjs) + # assignment = evaluator.get_assignments(normed_trjs) + assignment = evaluator.get_assignments(trjs) + # normed_val = self.normalize_value(evaluator.get_evaluations(assignment).cpu()) + # value.append(normed_val) + value.append(evaluator.get_evaluations(assignment).cpu()) + values_train[:, idx_ltl] = torch.cat(value).numpy() + #end for + + return values_train + + + def _get_graphs(self, num_props=4, prop_names=None): + from diffusion_policy.common.ast_builder import ASTBuilder + from diffusion_policy.common.ltl_parser import LTLParser + + if prop_names is None: + assert num_props is not None, \ + f"[ PushTLTLValueDataset._get_graphs ]: num_props must be specified if prop_names is not given." + # props = ['p0','p1','p2','p3'] + props = [f"p{i}" for i in range(num_props)] + else: + assert len(prop_names) == num_props, \ + f"[ PushTLTLValueDataset._get_graphs ]: Length of input proposition list (prop_names[{len(prop_names)}]) does not match the declared number (num_props={num_props})." + str2tup_converter = LTLParser(propositions=props) + tree_builder = ASTBuilder(propositions=props) + + formula_tups = [str2tup_converter(form_str) for form_str in self.ltls] + graphs = np.array([[tree_builder(tup).to('cuda')] for tup in formula_tups]) + + ltl_embed_output_dim = 32 + for i in range(graphs.shape[0]): + d = graphs[i][0].nodes[None].data['feat'].size() + root_weight = torch.ones((1, ltl_embed_output_dim)) + others_weight = torch.zeros((d[0]-1, ltl_embed_output_dim)) + weight = torch.cat([root_weight, others_weight]) + graphs[i][0].nodes[None].data['is_root'] = weight.cuda() + # end for + + return graphs diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/dtl_cont_cons.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/dtl_cont_cons.py new file mode 100644 index 0000000000000000000000000000000000000000..c6b48c375cc224c472231fd08e217e1a4f5c69cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/dtl_cont_cons.py @@ -0,0 +1,245 @@ +import numpy as np +import torch +import sys, os + +# sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from .tl import parser + +# Raise the recursion limit to avoid problems when parsing formulas +sys.setrecursionlimit(10000) +# Setting TL_RECORD_TRACE asks DTL to record the evaluation trace. +# Using this we can find the conflicting part between logits and formula. +os.environ['TL_RECORD_TRACE'] = '1' + + +class DTL_Cont_Cons_Evaluator(): + def __init__(self, device="cuda"): + self.device = device + self.rules = {} + self.rule_str = "" + # self.n_cons = 0 + self.prop_exp_list = [] + self.cons_func_list = [] + self.n_cons_func = 0 + self.cons_val = None + self.formula_evaluator = None + + def set_atomic_props(self, con_groups: list): + n_props = len(con_groups) + assert n_props > 0 + self.cons_func_list = self._agg_cons_sub(con_groups) + self.n_cons_func = len(self.cons_func_list) + + prop_exp_list = [] + n_prop_exp = 0 + + rules = {} + for i in range(n_props): + c_group = con_groups[i] + if len(c_group) < 1: + continue + + rule = "(" + flg_hd = True + for c in c_group: + n_prop_exp +=1 + if not flg_hd: + rule += " & " + flg_hd = False + prop_exp = "c"+str(n_prop_exp) + prop_exp_list.append(prop_exp) + rule += prop_exp + rule += ")" + rules["p"+str(i)] = rule + + self.rules = rules + self.prop_exp_list = prop_exp_list + self.ap_map = lambda x: prop_exp_list.index(x) + + def get_assignments(self, st: torch.tensor, + normalized:bool = False, + custom_normalizer = None) -> torch.tensor: + """ + calculate the assignments for a BATCH on every low level constraints + + Input + st: torch.tensor (batch, time_steps, feature) + normalized: bool (default: False) + custom_normalizer: a normalizer object (default: None) + + Return + assignment: torch.tensor (batch, num_constraints, time_steps) + """ + assert self.n_cons_func > 0, "No low level constraints assigned yet." + + vals = [None]*self.n_cons_func + for i in range(self.n_cons_func): + val = self.cons_func_list[i]( + torch.permute(st, (1, 2, 0)), + normalized=normalized, + custom_normalizer=custom_normalizer + ) + vals[i] = torch.transpose(val, 0, 1).unsqueeze(1) + return torch.cat(vals, dim=1) + + def set_ltl_formula(self, ltl): + self.ltl = ltl + for key, val in self.rules.items(): + self.ltl = self.ltl.replace(key, val) + self.formula_evaluator = parser.parse(self.ltl) + + def get_evaluations(self, assigns: torch.tensor, rho = 1e3) -> torch.tensor: + return self.formula_evaluator(assigns, ap_map=self.ap_map, rho=rho) + + + def generate_formula(self, neg_cons, pos_cons) -> str: + self.rule_str = "" + self.prop_exp_list = [] + cnt_prop = 0 + + # Generate negative constraints (something that should never happen) + neg_rule = "" + neg_prop_exp_list = [] + neg_rule, neg_prop_exp_list, cnt_prop = \ + self._gen_subform(neg_cons, negate=True, cnt_prop=cnt_prop) + + # Generate positive constraints (something that should always happen) + pos_rule = "" + pos_prop_exp_list = [] + pos_rule, pos_prop_exp_list, cnt_prop = \ + self._gen_subform(pos_cons, negate=False, cnt_prop=cnt_prop) + + # Merge both together + # self.n_cons = cnt_prop + self.prop_exp_list = neg_prop_exp_list + pos_prop_exp_list + self.rule_str = "(" \ + + (neg_rule if len(neg_rule)>0 else "") \ + + ((" & " + pos_rule) if len(pos_rule)>0 else "") \ + + ")" + + # Construct the evaluator for the rule + self.formula_evaluator = parser.parse(self.rule_str) + self.aggregate_constraint(neg_cons, pos_cons) + + return self.rule_str + + + def aggregate_constraint(self, neg_cons, pos_cons) -> list: + cons_flat = [] + neg_cons_flat = self._agg_cons_sub(neg_cons) + pos_cons_flat = self._agg_cons_sub(pos_cons) + cons_flat += neg_cons_flat + pos_cons_flat + + self.cons_func_list = cons_flat + return self.cons_func_list + + + def evaluate_constraints(self, st, cons_funcs: list = []) -> torch.tensor: + if len(cons_funcs) < 1: + cons_funcs = self.cons_func_list + self.cons_val = None + + for fn in cons_funcs: + # assuming every `fn` outputs a 1xT tensor, + # where T is the trajectory length. + # val = fn(st).unsqueeze(0) # promote dim + val = torch.vmap(fn)(st).unsqueeze(1) # vectorize fn for batched inputs + # print(f"val= {val}") + self.cons_val = val if self.cons_val is None else\ + torch.cat((self.cons_val, val), dim=1) + + return self.cons_val + + + def formula_evaluation(self, st:torch.tensor, rho = 1e3) -> torch.tensor: + self.evaluate_constraints(st) + return self._form_eval(rho=rho) + + + def _form_eval(self, + rule: str = "", + prop_vals: torch.tensor = None, + list_of_props: list = None, + rho: float = 1e3 + ) -> torch.tensor: + if len(rule) < 1: + rule = self.rule_str + evaluator = self.formula_evaluator + else: + evaluator = parser.parse(rule) + assert evaluator is not None, "No rule, nothing to evaluate." + + if prop_vals is None: + prop_vals = self.cons_val + if list_of_props is None: + list_of_props = self.prop_exp_list + ap_map = lambda x: list_of_props.index(x) + + # print(f"prop_vals = {prop_vals}") + tl_value = evaluator(prop_vals, ap_map=ap_map, rho=rho) + + return tl_value + + + def _gen_subform(self, con_groups: list, negate: bool = False, cnt_prop = 0) -> str: + rule = "" + prop_exp_list = [] + if len(con_groups) > 0: + rule = "G " + flg_hd_out = True + for c_group in con_groups: + if len(c_group) < 1: + continue + if not flg_hd_out: + rule += " & " + flg_hd_out = False + rule += "~(" if negate else "(" + flg_hd = True + for c in c_group: + cnt_prop +=1 + if not flg_hd: + rule += " & " + flg_hd = False + prop_exp = "c"+str(cnt_prop) + prop_exp_list.append(prop_exp) + rule += prop_exp + # print(cons_flat) + # cons_flat = c.eval()[None, :] if cons_flat is None else torch.concat((cons_flat, c.eval()[None, :]), dim=0) + #end for + rule += ")" + #end for + #end if + return rule, prop_exp_list, cnt_prop + + + def _agg_cons_sub(self, con_groups:list) -> list: + cons_flat = [] + assert type(con_groups) is list + if len(con_groups) > 0: + for c_group in con_groups: + assert type(c_group) is list + if len(c_group) < 1: + continue + for c in c_group: + cons_flat.append(c) + return cons_flat + + + def set_props_cons_and_ltl(self, con_groups, props, ltl): + """ + con_groups: [func0, func1, func2, func3] + each func should work on a 2-dim trajectory: Horizon x n_feature + props: ['p0', 'p1', 'p2', 'p3'] + ltl: '(G~p0)' + """ + assert len(con_groups) == len(props), "Number of constraint_groups and props do not match." + self.n_cons_func = len(con_groups) + self.cons_func_list = self._agg_cons_sub(con_groups) + self.ap_map = lambda x: props.index(x) + self.ltl = ltl + self.formula_evaluator = parser.parse(self.ltl) + + + def set_ltl_only(self, ltl): + self.ltl = ltl + self.formula_evaluator = parser.parse(self.ltl) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..06741b3d88e4ca881decb4ae7c2211ca10b48aa7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/requirements.txt @@ -0,0 +1,17 @@ +funcy==1.17 +ipdb==0.13.9 +lenses==0.5.0 +matplotlib +pandas==1.4.3 +parsimonious==0.9.0 +pptree==3.1 +PyYAML==6.0 +scikit_learn +scipy +seaborn==0.11.2 +tensorboardX==2.5.1 +torch_scatter +torchvision +tqdm +attrs +tensorboard diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/test/test_2.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/test/test_2.py new file mode 100644 index 0000000000000000000000000000000000000000..5df9906dcd0ae3a376437315a288607270e6aa51 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/test/test_2.py @@ -0,0 +1,60 @@ +import numpy as np +import torch +import sys, os + +# sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ..dtl_cont_cons import DTL_Cont_Cons_Evaluator + + +def test_2(): + st_np = np.array([ + np.array([ + [1., 2., 3., 4, 5], + [1, 2, 3, 4, 5], + ]).T, + np.array([ + [7., 8., 9., 0, 2.7], + [11, 20, -6.3, 88, -3.5], + ]).T, + ]) + st = torch.tensor(st_np.transpose((0,2,1)), dtype=torch.float, device="cuda", requires_grad=True) + # st = st.transpose(1,2) + print(f"state = {st}\n") + + evaluator = DTL_Cont_Cons_Evaluator() + + def f1(x): + return 3*x[0] - 1 + def f2(x): + return torch.square(x[0])+torch.square(x[1])-10 + def f3(x): + return 4*x[1] - 103 + def f4(x): + return torch.square(x[0])+torch.square(x[1])-0.99 + + neg_cons = [[f2,f3]] + pos_cons = [[f1], [f4]] + + print("Test generate_formula(): ") + rule = evaluator.generate_formula(neg_cons, pos_cons) + print(f"rule={rule}\n") + + print("Test aggregate_constraint(): ") + evaluator.aggregate_constraint(neg_cons, pos_cons) + print(f"cons_func_list = {evaluator.cons_func_list}\n") + + print("Test evaluate_constraints: ") + cons_vals = evaluator.evaluate_constraints(st) + print(f"cons_vals = {cons_vals}\n") + + print("Test formula_evaluation(): ") + f_val = evaluator.formula_evaluation(st) + print(f"formula_value = {f_val}\n") + + print("Test gradient backprop: ") + f_val.backward(torch.ones_like(f_val)) + print(f"st.grad = {st.grad.transpose(1,2)}") + + +if __name__=='__main__': + test_2() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/evaluator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/evaluator.py new file mode 100644 index 0000000000000000000000000000000000000000..0ee923eb8dbbfc07f7e137bb101f4fa663ca9869 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/evaluator.py @@ -0,0 +1,362 @@ +import os +from functools import singledispatch + +import torch + +from . import syntax as ast + + +def _ap_to_index(ap_name): + """For now only allow [a-z][0-9]+ format.""" + return int(ap_name[1:]) + + +def _agg_result(x, res): + return torch.ones_like(x) * res + + +def _mask_ninf(x, mask): + """ + Replace elements in tensor x based on mask. + If mask[i] == 0, then x[i] will be set to -infinity. + """ + x1 = (1 - mask).unsqueeze(1) * torch.inf + x1[x1.isnan()] = 0 + return x - x1 + + +def _mask_pinf(x, mask): + """ + Replace elements in tensor x based on mask. + If mask[i] == 0, then x[i] will be set to +infinity. + """ + x1 = (1 - mask).unsqueeze(1) * torch.inf + x1[x1.isnan()] = 0 + return x + x1 + + +# https://mathoverflow.net/questions/35191/a-differentiable-approximation-to-the-minimum-function +def _smax(x, rho=100., dim=0, mask=None): + if mask is not None and len(x.shape) > 2: + x = _mask_ninf(x, mask) + return torch.logsumexp(rho*x, dim=dim, keepdim=True) / rho + + +def _smin(x, rho=100., dim=0, mask=None): + if mask is not None and len(x.shape) > 2: + _mask_pinf(x, mask) + return -torch.logsumexp(rho*(-x), dim=dim, keepdim=True) / rho + + +def _cummax(x, dim=1, rho=100.0, mask=None): + if mask is not None and len(x.shape) > 2: + x = _mask_ninf(x, mask) + return torch.logcumsumexp(rho*x, dim=dim) / rho + + +def _cummin(x, dim=1, rho=100.0, mask=None): + if mask is not None and len(x.shape) > 2: + x = _mask_pinf(x, mask) + return -torch.logcumsumexp(rho*(-x), dim=dim) / rho + + +# Evaluation cache: a dict of {str: Tensor}. +# This assumes that any formula is identified by how it is written. +_SYM_CACHE = {} +_CACHE_PLACEHOLDER = 'PLACEHOLDER' + +def _register_cache(sym_name, value): + global _SYM_CACHE + _SYM_CACHE[sym_name] = value + + +def _clear_cache(): + global _SYM_CACHE + _SYM_CACHE = {} + + +def _record_value(phi, value, level): + if os.environ.get('TL_PRINT_TRACE', '0') == '1' or os.environ.get('TL_RECORD_TRACE', '0') == '1': + phi.value = value + else: + return + + +def pointwise_sat(phi, ap_map=None, rho=100): + _clear_cache() + + f = _eval_(phi, ap_map=ap_map, level=0, rho=rho) + + def _eval(x, t, *_, output_mask=None): + x = f(x, t, output_mask=output_mask) + if len(x.shape) == 2: + return x[0][0] + else: + return x[:, 0, 0] + + return _eval + + +@singledispatch +def _eval_(phi, ap_map=None, **kwargs): + """ + --- + ### Args: + - phi: the formula + - ap_map: a callable that maps an atomic proposition to an index + - rho: parameter used by _smax and _smin for smooth min() and max() + + ### Returns: + A callable with signature f(x, t), where + - x: is a numpy array of (N, L) where N is the number of proposition and L is the signal length + - t: the start time of evaluation + """ + raise NotImplementedError + + +@_eval_.register(ast.AtomicPred) +def _eval_ap(phi, *_, ap_map=None, level=0, rho=100): + """This is where dispatch stops.""" + + if phi.id not in _SYM_CACHE: + _register_cache(phi.id, _CACHE_PLACEHOLDER) + + """This is where dispatch stops.""" + def _eval(x, t, output_mask=None): + if ap_map is None: + idx = _ap_to_index(phi.id) + else: + idx = ap_map(phi.id) + + if len(x.shape) == 2: + value = x[idx:idx+1, t:] + else: + value = x[:, idx:idx+1, t:] + _record_value(phi, value, level) + _register_cache(phi.id, value) + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.Neg) +def _eval_neg(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + f = _eval_(phi.arg, ap_map=ap_map, level=level+1, rho=rho) + def _eval(x, t, output_mask=None): + x = f(x, t, output_mask=output_mask) + value = -x + _record_value(phi, value, level) + _register_cache(str(phi), value) + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.And) +def _eval_and(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + fs = [_eval_(arg, ap_map=ap_map, level=level+1, rho=rho) for arg in phi.args] + def _eval(x, t, output_mask=None): + xs = [] + if len(x.shape) == 2: + for f in fs: + xs.append(f(x,t,output_mask=output_mask)) + xs = [_smin(torch.cat(xs, dim=0), dim=0, rho=rho)] + else: + for f in fs: + xs.append(f(x,t,output_mask=output_mask)) + xs = [_smin(torch.cat(xs, dim=1), dim=1, rho=rho)] + + value = xs[0] + _record_value(phi, value, level) + _register_cache(str(phi), value) + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.Or) +def _eval_or(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + fs = [_eval_(arg, ap_map=ap_map, level=level+1, rho=rho) for arg in phi.args] + def _eval(x, t, output_mask=None): + xs = [] + if len(x.shape) == 2: + for f in fs: + xs.append(f(x,t,output_mask=output_mask)) + xs = [_smax(torch.cat(xs, dim=0), dim=0, rho=rho)] + else: + for f in fs: + xs.append(f(x,t,output_mask=output_mask)) + xs = [_smax(torch.cat(xs, dim=1), dim=1, rho=rho)] + + value = xs[0] + _record_value(phi, value, level) + _register_cache(str(phi), value) + + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + + +@_eval_.register(ast.G) +def _eval_g(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + f = _eval_(phi.arg, ap_map=ap_map, level=level+1, rho=rho) + def _eval(x, t, output_mask=None): + x = f(x, t, output_mask=output_mask) + if len(x.shape) == 2: + value = _smin(x, dim=1, rho=rho) + else: + value = _smin(x, dim=2, rho=rho, mask=output_mask) + value = _agg_result(x, value) + _record_value(phi, value, level) + _register_cache(str(phi), value) + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.WeakUntil) +def _eval_until(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + f1, f2 = _eval_(phi.arg1, ap_map=ap_map, level=level+1, rho=rho), _eval_(phi.arg2, ap_map=ap_map, level=level+1, rho=rho) + def _eval(x, t, output_mask=None): + x1, x2 = f1(x, t, output_mask=output_mask), f2(x, t, output_mask=output_mask) + + if len(x1.shape) == 2: + x2_cummax = _cummax(x2, dim=1, rho=rho) + value = _smin(_smax(torch.cat([x1, x2_cummax], dim=0), dim=0, rho=rho), dim=1, rho=rho) + else: + x2_cummax = _cummax(x2, dim=2, rho=rho, mask=output_mask) + value = _smin(_smax(torch.cat([x1, x2_cummax], dim=1), dim=1, rho=rho), dim=2, rho=rho, mask=output_mask) + value = _agg_result(x1, value) + + _record_value(phi, value, level) + _register_cache(str(phi), value) + + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.Since) +def _eval_since(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + f1, f2 = _eval_(phi.arg1, ap_map=ap_map, level=level+1, rho=rho), _eval_(phi.arg2, ap_map=ap_map, level=level+1, rho=rho) + def _eval(x, t, output_mask=None): + x1, x2 = f1(x, t, output_mask=output_mask), f2(x, t, output_mask=output_mask) + + if len(x1.shape) == 2: + x2_cummax = _cummax(x2, dim=1, rho=rho) + x2_mask = _cummin(x2_cummax * x2.sign(), dim=1, rho=rho) + value = _smin(_smax(torch.cat([x1, x2_mask], dim=0), dim=0, rho=rho), dim=1, rho=rho) + + else: + x2_cummax = _cummax(x2, dim=2, rho=rho, mask=output_mask) + x2_mask = _cummin(x2_cummax * x2.sign(), dim=2, rho=rho, mask=output_mask) + value = _smin(_smax(torch.cat([x1, x2_mask], dim=1), dim=1, rho=rho), dim=2, rho=rho, mask=output_mask) + value = _agg_result(x1, value) + + _record_value(phi, value, level) + _register_cache(str(phi), value) + + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(ast.Next) +def _eval_next(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + + f = _eval_(phi.arg, ap_map=ap_map, level=level+1, rho=rho) + def _eval(x, t, output_mask=None): + x_next = f(x, t, output_mask=output_mask)[..., 1:] + if len(x.shape) == 2: + value = torch.cat([x_next, torch.ones(size=(x.shpae[0], 1))], dim=1) + else: + value = torch.cat([x_next, torch.ones(size=(x.shape[0], x.shape[1], 1))], dim=2) + + _record_value(phi, value, level) + _register_cache(str(phi), value) + + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache + + +@_eval_.register(type(ast.BOT)) +def _eval_bot(phi, ap_map=None, level=0, rho=100): + + if str(phi) not in _SYM_CACHE: + _register_cache(str(phi), _CACHE_PLACEHOLDER) + def _eval(x, t, output_mask=None): + if len(x.shape) == 2: + value = torch.nn.Parameter(torch.zeros(1, x.shape[1]).to(x.device)) + else: + value = torch.nn.Parameter(torch.zeros(1, x.shape[1]).to(x.device)) + + _record_value(phi, value, level) + _register_cache(str(phi), value) + return value + return _eval + + else: + def _get_cache(x, t, output_mask=None): + return _SYM_CACHE[str(phi)] + return _get_cache diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/parser.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..97a0715472e730a7b059e05664288bf44ac0f033 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/parser.py @@ -0,0 +1,138 @@ +"""Defines a simplified temporal logic with the following operators: +neg (~), or (|), and (&), implies (->) +next (X), finally (F), globally (G), until (U), weak until (W), (, ) +Parsimonious is used as the backbone grammar parser. +""" + +import operator as op +from functools import partialmethod, reduce + +from parsimonious import Grammar, NodeVisitor +from . import syntax as ast +from . import sugar + +TL_GRAMMAR = Grammar(r''' +phi = (neg / paren_phi / implies_outer / iff_outer / and_outer / or_outer / next / weak_until / until / release / since / globally / finally / AP / bot / top) + +neg = "~" __ phi +paren_phi = "(" __ phi __ ")" + +and_outer = "(" __ and_inner __ ")" +and_inner = (phi __ "&" __ and_inner) / phi + +or_outer = "(" __ or_inner __ ")" +or_inner = (phi __ "|" __ or_inner) / phi + +implies_outer = "(" __ implies_inner __ ")" +implies_inner = (phi __ "->" __ implies_inner) / phi + +iff_outer = "(" __ iff_inner __ ")" +iff_inner = (phi __ "<->" __ iff_inner) / phi + +next = "X" __ phi +weak_until = "(" __ phi _ "W" _ phi __ ")" +until = "(" __ phi _ "U" _ phi __ ")" +release = "(" __ phi _ "R" _ phi __ ")" +since = "(" __ phi _ "S" _ phi __ ")" +globally = "G" __ phi +finally = "F" __ phi + +bot = "FALSE" +top = "TRUE" + +AP = ~r"[a-z_][a-z_\d]*" + +_ = ~r"\s"+ +__ = ~r"\s"* +EOL = "\n" +''') + +class TLVisitor(NodeVisitor): + def __init__(self): + super().__init__() + self.default_interval = ast.Interval(0.0, float('inf')) + + def binop_inner(self, _, children): + if not isinstance(children[0], list): + return children + + ((left, _, _, _, right),) = children + return [left] + right + + def binop_outer(self, _, children, *, binop): + return reduce(binop, children[2]) + + def visit_const_or_unbound(self, node, children): + child = children[0] + return ast.Param(child) if isinstance(child, str) else float(node.text) + + visit_and_inner = binop_inner + visit_iff_inner = binop_inner + visit_implies_inner = binop_inner + visit_or_inner = binop_inner + visit_xor_inner = binop_inner + + visit_and_outer = partialmethod(binop_outer, binop=op.and_) + visit_iff_outer = partialmethod(binop_outer, binop=sugar.iff) + visit_implies_outer = partialmethod(binop_outer, binop=sugar.implies) + visit_or_outer = partialmethod(binop_outer, binop=op.or_) + + def generic_visit(self, _, children): + return children + + def children_getter(self, _, children, i): + return children[i] + + visit_phi = partialmethod(children_getter, i=0) + visit_paren_phi = partialmethod(children_getter, i=2) + + def visit_bot(self, *_): + return ast.BOT + + def visit_top(self, *_): + return ~ast.BOT + + def get_text(self, node, _): + return node.text + + visit_op = get_text + + def unary_temp_op_visitor(self, _, children, op): + _, _, phi = children + lo, hi = self.default_interval + return op(phi, lo=lo, hi=hi) + + visit_finally = partialmethod(unary_temp_op_visitor, op=sugar.env) + visit_globally = partialmethod(unary_temp_op_visitor, op=sugar.alw) + + def visit_weak_until(self, _, children): + _, _, phi1, _, _, _, phi2, _, _ = children + return phi1.weak_until(phi2) + + def visit_until(self, _, children): + _, _, phi1, _, _, _, phi2, _, _ = children + return phi1.until(phi2) + + def visit_since(self, _, children): + _, _, phi1, _, _, _, phi2, _, _ = children + return phi1.since(phi2) + + def visit_release(self, _, children): + _, _, phi1, _, _, _, phi2, _, _ = children + return phi1.release(phi2) + + def visit_id(self, name, _): + return name.text + + def visit_AP(self, *args): + return ast.AtomicPred(self.visit_id(*args)) + + def visit_neg(self, _, children): + return ~children[2] + + def visit_next(self, _, children): + return ast.Next(children[2]) + + +def parse(input_expr): + return TLVisitor().visit(TL_GRAMMAR.parse(input_expr)) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/sugar.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/sugar.py new file mode 100644 index 0000000000000000000000000000000000000000..6493c407930316a04d4180d7f978780268ab19ad --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/sugar.py @@ -0,0 +1,39 @@ +from . import syntax as ast + + +def alw(phi, *, lo=0, hi=float('inf')): + return ast.G(ast.Interval(lo, hi), phi) + + +def env(phi, *, lo=0, hi=float('inf')): + return ~alw(~phi, lo=lo, hi=hi) + + +def implies(x, y): + return ~x | y + + +def xor(x, y): + return (x | y) & ~(x & y) + + +def iff(x, y): + return (x & y) | (~x & ~y) + + +def until(phi, psi): + return ast.WeakUntil(phi, psi) & env(psi) + + +def release(phi, psi): + return ~until(~phi, ~psi) + + +def timed_until(left, right, lo, hi): + assert 0 <= lo < hi + + expr = env(right, lo=lo, hi=hi) + expr &= alw(left, lo=0, hi=lo) + expr &= alw(until(left, right), lo=lo, hi=lo) + + return expr diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/syntax.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/syntax.py new file mode 100644 index 0000000000000000000000000000000000000000..ab2e71f63368e467565d4153c1f7ebad0b9801ea --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/dtl/tl/syntax.py @@ -0,0 +1,353 @@ +import os +from collections import deque +from typing import Union, NamedTuple, TypeVar + +import attr +import funcy as fn +from lenses import bind +from torch import AnyType +import numpy as np +np.set_printoptions(formatter={'float': lambda x: "{0:0.2f}".format(x)}, suppress=True) + +from . import sugar + + +def print_tree_horizontally(current_node, balanced_branches, name_getter, indent='', last='updown'): + # if current_node.value is None: import ipdb; ipdb.set_trace() + up, down = balanced_branches(current_node) + + """ Printing of "up" branch. """ + for child in up: + next_last = 'up' if up.index(child) == 0 else '' + next_indent = '{0}{1}{2}'.format(indent, ' ' if 'up' in last else '│', ' ' * len(name_getter(current_node))) + print_tree_horizontally(child, balanced_branches, name_getter, next_indent, next_last) + + """ Printing of current node. """ + if last == 'up': start_shape = '┌' + elif last == 'down': start_shape = '└' + elif last == 'updown': start_shape = ' ' + else: start_shape = '├' + + if up: end_shape = '┤' + elif down: end_shape = '┐' + else: end_shape = '' + + print('{0}{1}{2}{3}'.format(indent, start_shape, name_getter(current_node), end_shape)) + + """ Printing of "down" branch. """ + for child in down: + next_last = 'down' if down.index(child) is len(down) - 1 else '' + next_indent = '{0}{1}{2}'.format(indent, ' ' if 'down' in last else '│', ' ' * len(name_getter(current_node))) + print_tree_horizontally(child, balanced_branches, name_getter, next_indent, next_last) + + +def print_tree(current_node, childattr='children', name=lambda x: str(x), horizontal=True): + from pptree.pptree import print_tree_vertically + # if hasattr(current_node, nameattr): + # name = lambda node: getattr(node, nameattr) + # else: + # name = lambda node: str(node) + + children = lambda node: getattr(node, childattr) + nb_children = lambda node: sum(nb_children(child) for child in children(node)) + 1 + + def balanced_branches(current_node): + size_branch = {child: nb_children(child) for child in children(current_node)} + + """ Creation of balanced lists for "a" branch and "b" branch. """ + a = sorted(children(current_node), key=lambda node: nb_children(node)) + b = [] + while a and sum(size_branch[node] for node in b) < sum(size_branch[node] for node in a): + b.append(a.pop()) + + return a, b + + if horizontal: + print_tree_horizontally(current_node, balanced_branches, name) + + else: + print_tree_vertically(current_node, balanced_branches, name, children) + + +Node = TypeVar("Node") + + +def flatten_binary(phi, op, dropT, shortT): + def f(x): + return x.args if isinstance(x, op) else [x] + + args = [arg for arg in phi.args if arg != dropT] + + if any(arg is shortT for arg in args): + return shortT + elif not args: + return dropT + elif len(args) == 1: + return args[0] + else: + return op(tuple(fn.mapcat(f, phi.args))) + + +# The original _or definition has been replaced here. +# def _or(exp1, exp2): +# return ~(~exp1 & ~exp2) + + +def _or(exp1, exp2): + return flatten_binary(Or((exp1, exp2)), Or, TOP, BOT) + + +def _and(exp1, exp2): + return flatten_binary(And((exp1, exp2)), And, TOP, BOT) + + +def _neg(exp): + if isinstance(exp, Neg): + return exp.arg + return Neg(exp) + + +def _eval(exp, trace, time=False, *, ap_map=None, dt=0.1, quantitative=True, rho=1000, output_mask=None): + from . import evaluator as evaluator + result = evaluator.pointwise_sat(exp, ap_map=ap_map, rho=rho)(trace, time, quantitative, output_mask=output_mask) + if os.environ.get('TL_PRINT_TRACE', '0') == '1': + print_tree(exp, 'children', name=lambda x: f' {"" if x.value is not None else "Cached"} {str(x):.20s}... ', horizontal=True) + return result + + +def _timeshift(exp, t): + if exp == BOT: + return exp + + for _ in range(t): + exp = Next(exp) + return exp + + +def _walk(exp): + """Walk of the AST.""" + pop = deque.pop + children = deque([exp]) + while len(children) > 0: + node = pop(children) + yield node + children.extend(node.children) + + +def _params(exp): + def get_params(leaf): + if isinstance(leaf, ModalOp): + if isinstance(leaf.interval[0], Param): + yield leaf.interval[0] + if isinstance(leaf.interval[1], Param): + yield leaf.interval[1] + + return set(fn.mapcat(get_params, exp.walk())) + + +def _set_symbols(node, val): + children = tuple(_set_symbols(c, val) for c in node.children) + + if hasattr(node, 'interval'): + return node.evolve( + arg=children[0], + interval=_update_itvl(node.interval, val), + ) + elif isinstance(node, AtomicPred): + return val.get(node.id, node) + elif hasattr(node, 'args'): + return node.evolve(args=children) + elif hasattr(node, 'arg'): + return node.evolve(arg=children[0]) + return node + + +def _inline_context(exp, context): + phi, phi2 = exp, None + while phi2 != phi: + phi2, phi = phi, _set_symbols(phi, context) + + return phi + + +def _atomic_predicates(exp): + return set(bind(exp).Recur(AtomicPred).collect()) + + +class Param(NamedTuple): + name: str + + def __repr__(self): + return self.name + + +def ast_class(cls): + cls.__xor__ = sugar.xor + cls.__or__ = _or + cls.__and__ = _and + cls.__invert__ = _neg + cls.__call__ = _eval + cls.__rshift__ = _timeshift + cls.__getitem__ = _inline_context + cls.walk = _walk + cls.params = property(_params) + cls.atomic_predicates = property(_atomic_predicates) + cls.evolve = attr.evolve + cls.iff = sugar.iff + cls.implies = sugar.implies + # Avoid circular dependence. + cls.weak_until = lambda a, b: WeakUntil(a, b) + cls.since = lambda a, b: Since(a, b) + cls.until = sugar.until + cls.release = sugar.release + cls.timed_until = sugar.timed_until + cls.always = sugar.alw + cls.eventually = sugar.env + + if not hasattr(cls, "children"): + cls.children = property(lambda _: ()) + + return attr.s(frozen=False, auto_attribs=True, repr=False, slots=True, eq=False)(cls) + + +def _update_itvl(itvl, lookup): + def _update_param(p): + if not isinstance(p, Param) or p.name not in lookup: + return p + + val = lookup[p.name] + return val if isinstance(lookup, Param) else float(val) + + return Interval(*map(_update_param, itvl)) + + +@ast_class +class _Bot: + def __repr__(self): + return "FALSE" + + +@ast_class +class AtomicPred: + id: str + value: AnyType = None + + def __repr__(self): + return f"{self.id}" + + +class Interval(NamedTuple): + lower: Union[float, Param] + upper: Union[float, Param] + + def __repr__(self): + return f"[{self.lower},{self.upper}]" + + +@ast_class +class NaryOpMTL: + OP = "?" + args: Node # TODO: when 3.7 is more common replace with type union. + value: AnyType = None + + def __repr__(self): + return "(" + f" {self.OP} ".join(f"{x}" for x in self.args) + ")" + + @property + def children(self): + return tuple(self.args) + + +class And(NaryOpMTL): + OP = "&" + value: AnyType = None + + +class Or(NaryOpMTL): + OP = "|" + value: AnyType = None + + +@ast_class +class ModalOp: + OP = '?' + interval: Interval + arg: Node + value: AnyType = None + + def __repr__(self): + if self.interval.lower == 0 and self.interval.upper == float('inf'): + return f"{self.OP}{self.arg}" + return f"{self.OP}{self.interval}{self.arg}" + + @property + def children(self): + return (self.arg,) + + +class G(ModalOp): + OP = "G" + value: AnyType = None + + +@ast_class +class WeakUntil: + arg1: Node + arg2: Node + value: AnyType = None + + def __repr__(self): + return f"({self.arg1} W {self.arg2})" + + @property + def children(self): + return (self.arg1, self.arg2) + + +@ast_class +class Since: + arg1: Node + arg2: Node + value: AnyType = None + + def __repr__(self): + return f"({self.arg1} S {self.arg2})" + + @property + def children(self): + return (self.arg1, self.arg2) + + +@ast_class +class Neg: + arg: Node + value: AnyType = None + + def __repr__(self): + return f"~{self.arg}" + + @property + def children(self): + return (self.arg,) + + +@ast_class +class Next: + arg: Node + value: AnyType = None + + def __repr__(self): + return f"X {self.arg}" + + @property + def children(self): + return (self.arg,) + + +def type_pred(*args): + ast_types = set(args) + return lambda x: type(x) in ast_types + + +BOT = _Bot() +TOP = ~BOT diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/base_lowdim_runner.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/base_lowdim_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..45437ec88d4d5b6f711fa953a1b54166aac9b530 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/base_lowdim_runner.py @@ -0,0 +1,9 @@ +from typing import Dict +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy + +class BaseLowdimRunner: + def __init__(self, output_dir): + self.output_dir = output_dir + + def run(self, policy: BaseLowdimPolicy) -> Dict: + raise NotImplementedError() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_full_state_runner.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_full_state_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..1791a44c6d1850daad20b8b10418eaf1c9328289 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_full_state_runner.py @@ -0,0 +1,277 @@ +import wandb +import numpy as np +import torch +import collections +import pathlib +import tqdm +import dill +import math +import wandb.sdk.data_types.video as wv +from diffusion_policy.env.pusht.pusht_env import PushTEnv +from diffusion_policy.gym_util.async_vector_env import AsyncVectorEnv +# from diffusion_policy.gym_util.sync_vector_env import SyncVectorEnv +from diffusion_policy.gym_util.multistep_wrapper import MultiStepWrapper +from diffusion_policy.gym_util.video_recording_wrapper import VideoRecordingWrapper, VideoRecorder + +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.common.pytorch_util import dict_apply +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner + +class PushTFullStateRunner(BaseLowdimRunner): + def __init__(self, + output_dir, + n_train=10, + n_train_vis=3, + train_start_seed=0, + n_test=22, + n_test_vis=6, + legacy_test=False, + test_start_seed=10000, + max_steps=200, + n_obs_steps=8, + n_action_steps=8, + n_latency_steps=0, + fps=10, + crf=22, + past_action=False, + tqdm_interval_sec=5.0, + n_envs=None + ): + super().__init__(output_dir) + + if n_envs is None: + n_envs = n_train + n_test + + # handle latency step + # to mimic latency, we request n_latency_steps additional steps + # of past observations, and the discard the last n_latency_steps + env_n_obs_steps = n_obs_steps + n_latency_steps + env_n_action_steps = n_action_steps + + # assert n_obs_steps <= n_action_steps + + def env_fn(): + return MultiStepWrapper( + VideoRecordingWrapper( + PushTEnv( + legacy=legacy_test, + block_cog=None, damping=None, + render_action=True, + render_size=96, + reset_to_state=None + ), + video_recoder=VideoRecorder.create_h264( + fps=fps, + codec='h264', + input_pix_fmt='rgb24', + crf=crf, + thread_type='FRAME', + thread_count=1 + ), + file_path=None, + ), + n_obs_steps=env_n_obs_steps, + n_action_steps=env_n_action_steps, + max_episode_steps=max_steps + ) + + env_fns = [env_fn] * n_envs + env_seeds = list() + env_prefixs = list() + env_init_fn_dills = list() + # train + for i in range(n_train): + seed = train_start_seed + i + enable_render = i < n_train_vis + + def init_fn(env, seed=seed, enable_render=enable_render): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapper) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('train/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + # test + for i in range(n_test): + seed = test_start_seed + i + enable_render = i < n_test_vis + + def init_fn(env, seed=seed, enable_render=enable_render): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapper) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('test/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + env = AsyncVectorEnv(env_fns) + + # test env + # env.reset(seed=env_seeds) + # x = env.step(env.action_space.sample()) + # imgs = env.call('render') + # import pdb; pdb.set_trace() + + self.env = env + self.env_fns = env_fns + self.env_seeds = env_seeds + self.env_prefixs = env_prefixs + self.env_init_fn_dills = env_init_fn_dills + self.fps = fps + self.crf = crf + self.n_obs_steps = n_obs_steps + self.n_action_steps = n_action_steps + self.n_latency_steps = n_latency_steps + self.past_action = past_action + self.max_steps = max_steps + self.tqdm_interval_sec = tqdm_interval_sec + + # print all attributes and values + for attr in dir(self): + # Getting rid of dunder methods + if not attr.startswith("__"): + print(f"{attr}: {getattr(self, attr)}") + + + def run(self, policy: BaseLowdimPolicy): + device = policy.device + dtype = policy.dtype + + env = self.env + + # plan for rollout + n_envs = len(self.env_fns) + n_inits = len(self.env_init_fn_dills) + n_chunks = math.ceil(n_inits / n_envs) + + # allocate data + all_video_paths = [None] * n_inits + all_rewards = [None] * n_inits + + for chunk_idx in range(n_chunks): + start = chunk_idx * n_envs + end = min(n_inits, start + n_envs) + this_global_slice = slice(start, end) + this_n_active_envs = end - start + this_local_slice = slice(0,this_n_active_envs) + + this_init_fns = self.env_init_fn_dills[this_global_slice] + n_diff = n_envs - len(this_init_fns) + if n_diff > 0: + this_init_fns.extend([self.env_init_fn_dills[0]]*n_diff) + assert len(this_init_fns) == n_envs + + # init envs + env.call_each('run_dill_function', + args_list=[(x,) for x in this_init_fns]) + + # start rollout + obs = env.reset() + past_action = None + policy.reset() + + pbar = tqdm.tqdm(total=self.max_steps, desc=f"Eval PushTFullStateRunner {chunk_idx+1}/{n_chunks}", + leave=False, mininterval=self.tqdm_interval_sec) + done = False + while not done: + Do = obs.shape[-1] // 2 + # create obs dict + np_obs_dict = { + # handle n_latency_steps by discarding the last n_latency_steps + 'obs': obs[...,:self.n_obs_steps,:Do].astype(np.float32), + 'obs_mask': obs[...,:self.n_obs_steps,Do:] > 0.5 + } + if self.past_action and (past_action is not None): + # TODO: not tested + np_obs_dict['past_action'] = past_action[ + :,-(self.n_obs_steps-1):].astype(np.float32) + + # device transfer + obs_dict = dict_apply(np_obs_dict, + lambda x: torch.from_numpy(x).to( + device=device)) + + # run policy + with torch.no_grad(): + action_dict = policy.predict_action(obs_dict) + + # device_transfer + np_action_dict = dict_apply(action_dict, + lambda x: x.detach().to('cpu').numpy()) + + # handle latency_steps, we discard the first n_latency_steps actions + # to simulate latency + action = np_action_dict['action'][:,self.n_latency_steps:] + + # step env + obs, reward, done, info = env.step(action) + done = np.all(done) + past_action = action + + # update pbar + pbar.update(action.shape[1]) + pbar.close() + + # collect data for this round + all_video_paths[this_global_slice] = env.render()[this_local_slice] + all_rewards[this_global_slice] = env.call('get_attr', 'reward')[this_local_slice] + # import pdb; pdb.set_trace() + + # log + max_rewards = collections.defaultdict(list) + log_data = dict() + # results reported in the paper are generated using the commented out line below + # which will only report and average metrics from first n_envs initial condition and seeds + # fortunately this won't invalidate our conclusion since + # 1. This bug only affects the variance of metrics, not their mean + # 2. All baseline methods are evaluated using the same code + # to completely reproduce reported numbers, uncomment this line: + # for i in range(len(self.env_fns)): + # and comment out this line + for i in range(n_inits): + seed = self.env_seeds[i] + prefix = self.env_prefixs[i] + max_reward = np.max(all_rewards[i]) + max_rewards[prefix].append(max_reward) + log_data[prefix+f'sim_max_reward_{seed}'] = max_reward + + # visualize sim + video_path = all_video_paths[i] + if video_path is not None: + sim_video = wandb.Video(video_path) + log_data[prefix+f'sim_video_{seed}'] = sim_video + + # log aggregate metrics + for prefix, value in max_rewards.items(): + name = prefix+'mean_score' + value = np.mean(value) + log_data[name] = value + + return log_data diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_fix_init_runner.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_fix_init_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..332f3ac45e758e26cff8d7ec9491c9f2492c312e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_fix_init_runner.py @@ -0,0 +1,478 @@ +import wandb +import numpy as np +import torch +import collections +import pathlib +import tqdm +import dill +import math +import logging +import pdb +import wandb.sdk.data_types.video as wv +from typing import List +from copy import copy, deepcopy +from diffusion_policy.env.pusht.pusht_keypoints_sttrj_env import PushTKeypointsStTrjEnv +from diffusion_policy.gym_util.async_vector_env import AsyncVectorEnv +from diffusion_policy.gym_util.multistep_wrapper_state import MultiStepWrapperPushTFullState +from diffusion_policy.gym_util.video_recording_wrapper import VideoRecordingWrapper, VideoRecorder + +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.common.pytorch_util import dict_apply +from diffusion_policy.env_runner.pusht_keypoints_runner import PushTKeypointsRunner + + +logger = logging.getLogger(__name__) + +class PushTKeypointsFixInitRunner(PushTKeypointsRunner): + def __init__(self, + output_dir, + # constraint_key, + keypoint_visible_rate=1.0, + n_train=10, + n_train_vis=3, + train_start_seed=0, + n_test=22, + n_test_vis=6, + legacy_test=False, + test_start_seed=10000, + max_steps=200, + n_obs_steps=8, + n_action_steps=8, + n_latency_steps=0, + fps=10, + crf=22, + agent_keypoints=False, + past_action=False, + tqdm_interval_sec=1.0, + n_envs=None + ): + super().__init__(output_dir) + + if n_envs is None: + n_envs = n_train + n_test + + # handle latency step + # to mimic latency, we request n_latency_steps additional steps + # of past observations, and the discard the last n_latency_steps + env_n_obs_steps = n_obs_steps + n_latency_steps + env_n_action_steps = n_action_steps + + # assert n_obs_steps <= n_action_steps + kp_kwargs = PushTKeypointsStTrjEnv.genenerate_keypoint_manager_params() + + + def env_fn(): + # return MultiStepWrapper( + return MultiStepWrapperPushTFullState( + VideoRecordingWrapper( + PushTKeypointsStTrjEnv( + # constraint_key=constraint_key, + legacy=legacy_test, + keypoint_visible_rate=keypoint_visible_rate, + agent_keypoints=agent_keypoints, + render_action=False, + draw_keypoints=True, + render_size=192, + **kp_kwargs + ), + video_recoder=VideoRecorder.create_h264( + fps=fps, + codec='h264', + input_pix_fmt='rgb24', + crf=crf, + thread_type='FRAME', + thread_count=1 + ), + file_path=None, + ), + n_obs_steps=env_n_obs_steps, + n_action_steps=env_n_action_steps, + max_episode_steps=max_steps + ) + + env_fns = [env_fn] * n_envs + env_seeds = list() + env_prefixs = list() + env_init_fn_dills = list() + # train + for i in range(n_train): + seed = train_start_seed + i + enable_render = i < n_train_vis + + def init_fn(env, seed=seed, enable_render=enable_render, prefix:str=''): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', prefix + wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapperPushTFullState) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('train/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + # test + for i in range(n_test): + seed = test_start_seed + i + enable_render = i < n_test_vis + + def init_fn(env, seed=seed, enable_render=enable_render, prefix:str=''): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', prefix + wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapperPushTFullState) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('test/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + env = AsyncVectorEnv(env_fns) + + + self.env = env + self.env_fns = env_fns + self.env_seeds = env_seeds + self.env_prefixs = env_prefixs + self.env_init_fn_dills = env_init_fn_dills + self.fps = fps + self.crf = crf + self.agent_keypoints = agent_keypoints + self.n_obs_steps = n_obs_steps + self.n_action_steps = n_action_steps + self.n_latency_steps = n_latency_steps + self.past_action = past_action + self.max_steps = max_steps + self.tqdm_interval_sec = tqdm_interval_sec + + def run(self, + policy: BaseLowdimPolicy, + init_states: List[np.ndarray], + ): + device = policy.device + dtype = policy.dtype + + env = self.env + + # plan for rollout + n_envs = len(self.env_fns) + n_inits = len(self.env_init_fn_dills) + n_init_states = len(init_states) + n_chunks = math.ceil(n_inits / n_envs) + + ## allocate data + all_video_paths = [[None]* n_inits ] * n_init_states + all_rewards = np.empty((n_inits, self.max_steps, n_init_states)) + all_state_trjs = np.empty((n_inits, self.max_steps+1, 5, n_init_states)) + n_act_pred_steps = ((self.max_steps + policy.n_action_steps - 1) // policy.n_action_steps) * policy.horizon + all_pred_action_trjs = np.empty((n_inits, n_act_pred_steps, 2, n_init_states)) + all_roll_action_trjs = np.empty((n_inits, self.max_steps, 2, n_init_states)) + total_obs_groups = (self.max_steps + policy.n_action_steps - 1) // policy.n_action_steps + all_obs_trjs = np.empty((n_inits, total_obs_groups, self.n_obs_steps, 20, n_init_states), dtype=np.float32) + + T_grad = policy.num_inference_steps - policy.t_stopgrad if hasattr(policy, 't_stopgrad') else 0 + all_guide_grad_trjs = np.empty((n_inits, T_grad, self.max_steps, 2, n_init_states), dtype=np.float32) + + ## Fill with NaN to protect missing data + all_rewards.fill(np.nan) + all_state_trjs.fill(np.nan) + all_pred_action_trjs.fill(np.nan) + all_roll_action_trjs.fill(np.nan) + all_obs_trjs.fill(np.nan) + all_guide_grad_trjs.fill(np.nan) + + logger.info(f"Running evaluation for {n_inits} envs, {n_init_states} init states, {n_chunks} chunks") + for init_st_idx in tqdm.tqdm(range(n_init_states)): + + for chunk_idx in range(n_chunks): + start = chunk_idx * n_envs + end = min(n_inits, start + n_envs) + this_global_slice = slice(start, end) + this_n_active_envs = end - start + this_local_slice = slice(0,this_n_active_envs) + + action_pred_trjs = np.empty((n_envs, 0, 2)) + action_roll_trjs = np.empty((n_envs, 0, 2)) + obs_roll_trjs = np.empty((n_envs, 0, self.n_obs_steps, 20), np.float32) + guide_grad_trjs = np.empty((n_envs, T_grad, 0, 2)) + + this_init_fns = self.env_init_fn_dills[this_global_slice] + n_diff = n_envs - len(this_init_fns) + if n_diff > 0: + this_init_fns.extend([self.env_init_fn_dills[0]]*n_diff) + assert len(this_init_fns) == n_envs + + # init envs + env.call_each( + 'run_dill_function', + args_list=[(x,) for x in this_init_fns], + kwargs_list=[{ + 'prefix': f"init_st_{init_st_idx:03d}_", + }] * n_envs + ) + + # start rollout + _ = env.reset() + obs = np.atleast_2d( + env.call( + 'set_to_state', + init_states[init_st_idx], + )) + obs = np.stack([obs] * self.n_obs_steps, axis=1) + + past_action = None + policy.reset() + + pbar = tqdm.tqdm(total=self.max_steps, desc=f"Eval PushtKeypointsFixInitRunner {chunk_idx+1}/{n_chunks}", + leave=False, mininterval=self.tqdm_interval_sec) + done = False + while not done: + Do = obs.shape[-1] // 2 + # create obs dict + np_obs_dict = { + # handle n_latency_steps by discarding the last n_latency_steps + 'obs': obs[...,:self.n_obs_steps,:Do].astype(np.float32), + 'obs_mask': obs[...,:self.n_obs_steps,Do:] > 0.5 + } + if self.past_action and (past_action is not None): + # TODO: not tested + np_obs_dict['past_action'] = past_action[ + :,-(self.n_obs_steps-1):].astype(np.float32) + + # device transfer + obs_dict = dict_apply(np_obs_dict, + lambda x: torch.from_numpy(x).to( + device=device)) + + # run policy + with torch.no_grad(): + action_dict = policy.predict_action(obs_dict) + + # device_transfer + np_action_dict = dict_apply(action_dict, + lambda x: x.detach().to('cpu').numpy()) + + # handle latency_steps, we discard the first n_latency_steps actions + # to simulate latency + action = np_action_dict['action'][:,self.n_latency_steps:] + + # step env + obs, reward, done, info = env.step(action) + done = np.all(done) + past_action = action + + # record actions + action_pred = np_action_dict['action_pred'] + action_pred_trjs = np.concatenate([action_pred_trjs, action_pred], axis=1) + ## Note: rollout action is restricted by max_steps + delta_t_act_roll = min(action.shape[1], self.max_steps - pbar.n) + action_roll_trjs = np.concatenate([action_roll_trjs, action[:,:delta_t_act_roll,...]], axis=1) + obs_roll_trjs = np.concatenate([obs_roll_trjs, np_obs_dict['obs'][..., None, :, :]], axis=-3) + # record guide gradients + if "guidance_grads" in np_action_dict: + guide_grad = np_action_dict['guidance_grads'] + guide_grad = guide_grad[:,self.n_latency_steps:] + guide_grad_trjs = np.concatenate([guide_grad_trjs, guide_grad], axis=2) + + ## update pbar + pbar.update(action.shape[1]) + pbar.close() + + ## collect data for this round + # all_video_paths[this_global_slice] = env.render()[this_local_slice] + ### Prefix the filenames, so that we can distinguish different LTLs + ### Do it the dump way + this_video_paths = env.render()[this_local_slice] + all_video_paths[init_st_idx][this_global_slice] = self.prefix_filenames( + file_paths = this_video_paths, + # prefixes = [f'ltl{ltl_idx:03}_'] * this_n_active_envs + # prefixes = [f'init_st_{init_st_idx:03d}_'] * this_n_active_envs + prefixes = [''] * len(this_video_paths) + ) + + # all_rewards[this_global_slice] = env.call('get_attr', 'reward')[this_local_slice] + reward_trjs = env.call('get_attr', 'reward')[this_local_slice] + reward_trjs = self.pad(reward_trjs, self.max_steps) + # reward_trjs = np.concatenate(reward_trjs, axis=-1) + reward_trjs = np.stack(reward_trjs, axis=0) + all_rewards[this_global_slice, :, init_st_idx] = reward_trjs + + ## evaluate this chunk's value + state_trjs = deepcopy(env.call('get_attr', 'state_trj')[this_local_slice]) + state_trjs = self.pad(state_trjs, self.max_steps+1) + state_trjs = np.stack(state_trjs, axis=0) # (bs, max_steps, state_dim) + all_state_trjs[this_global_slice, :, :, init_st_idx] = state_trjs + + ## Nod padding and keep the NaN values + all_pred_action_trjs[this_global_slice, :action_pred_trjs.shape[1], :, init_st_idx] = action_pred_trjs + all_roll_action_trjs[this_global_slice, :action_roll_trjs.shape[1], :, init_st_idx] = action_roll_trjs + + all_obs_trjs[this_global_slice, :obs_roll_trjs.shape[1], :, :, init_st_idx] = obs_roll_trjs + if "guidance_grads" in np_action_dict: + all_guide_grad_trjs[this_global_slice, ..., init_st_idx] = guide_grad_trjs + + #end for [chunk] + # end for [init_states] + + # Sanity Check + assert not np.any(np.isnan(all_pred_action_trjs)), \ + f"all_pred_action_trjs contains NaN values! {np.isnan(all_pred_action_trjs).sum()} NaNs found" + assert not np.any(np.isnan(all_roll_action_trjs)), \ + f"all_roll_action_trjs contains NaN values! {np.isnan(all_roll_action_trjs).sum()} NaNs found" + for _ in range(n_init_states): + for i in range(n_inits-1): + try: + assert np.allclose( + all_state_trjs[i, 0, :, _], + all_state_trjs[i+1, 0, :, _], + atol=1.0, + rtol=1e-2, + ), \ + f"Initial state {i} and {i+1} are different! \n"\ + f"\tall_state_trjs[{i}, 0, :, {_}]: {all_state_trjs[i, 0, :, _]}\n"\ + f"\tall_state_trjs[{i+1}, 0, :, {_}]: {all_state_trjs[i+1, 0, :, _]}" + except AssertionError as err: + logger.error(err) + # raise err + import pdb; pdb.set_trace() + + # log + max_rewards = collections.defaultdict(list) + mean_satisfactions = collections.defaultdict(list) + log_data = dict() + + + log_data['n_chunks'] = np.array(n_chunks) + log_data['n_initial_states'] = np.array(n_init_states) + log_data['n_envs'] = np.array(n_envs) + log_data['n_inits'] = np.array(n_inits) + log_data['all_rewards'] = all_rewards + log_data['all_state_trjs'] = all_state_trjs + log_data['all_pred_action_trjs'] = all_pred_action_trjs + log_data['all_roll_action_trjs'] = all_roll_action_trjs + log_data['all_obs_trjs'] = all_obs_trjs + if "guidance_grads" in np_action_dict: + log_data['all_guide_grad_trjs'] = all_guide_grad_trjs + + for i in range(n_inits): + seed = self.env_seeds[i] + prefix = self.env_prefixs[i] + + rewards_per_init_st = np.max(all_rewards[i], axis=0) + assert rewards_per_init_st.shape[0] == n_init_states, \ + f"rewards_per_ltl.shape[0]: {rewards_per_init_st.shape[0]}, not equal to n_ltls: {n_init_states}" + avg_max_reward = np.mean(rewards_per_init_st) + max_rewards[prefix].append(avg_max_reward) + log_data[prefix+f'sim_max_reward_{seed}'] = avg_max_reward + + ## visualize sim + for j in range(n_init_states): + video_path = all_video_paths[j][i] + if video_path is not None: + sim_video = wandb.Video(video_path) + log_data[prefix+f'init_st_{j}/'+f'sim_video_{seed}'] = sim_video + + # log aggregate metrics + for prefix, value in max_rewards.items(): + avg = prefix+'mean_score' + avg_value = np.mean(value) + log_data[avg] = avg_value + + std = prefix+'score_std' + std_value = np.std(value) + log_data[std] = std_value + + if hasattr(policy, "n_guide_steps") and hasattr(policy, "grad_scale"): + logger.info( + f"stp{policy.n_guide_steps}-scl{policy.grad_scale}-t{n_inits}"\ + f"--mScr: {avg_value:.4g} ± {std_value:.4g}" + ) + else: + logger.info( + f"t{n_inits}"\ + f"--mScr: {avg_value:.4g} ± {std_value:.4g}" + ) + + for prefix, value in mean_satisfactions.items(): + avg = prefix+'mean_satisfaction_rate' + avg_value = np.mean(value) + log_data[avg] = avg_value + + std = prefix+'satisfaction_rate_std' + std_value = np.std(value) + log_data[std] = std_value + + if hasattr(policy, "n_guide_steps") and hasattr(policy, "grad_scale"): + logger.info( + f"stp{policy.n_guide_steps}-scl{policy.grad_scale}-t{n_inits}"\ + f"--mSR: {avg_value:.4g} ± {std_value:.4g}" + ) + else: + logger.info( + f"t{n_inits}"\ + f"--mSR: {avg_value:.4g} ± {std_value:.4g}" + ) + + return log_data + + + def pad(self, trjs, max_steps) -> list: + trjs = list(trjs) + for i in range(len(trjs)): + max_steps = max(max_steps, len(trjs[i])) + for i, trj in enumerate(trjs): + l = len(trj) + if l < max_steps: + # print(f"l before padding: {l}") + last = trj[-1] * np.ones_like(trj[-1]) + trjs[i] = np.concatenate([trj, [last]*(max_steps-l)], axis=0) + # print(f"after padding: {trjs[i].shape}") + + return trjs + + + + def extract_state(self, info): + states = [] + for i in range(len(info)): + states.append(info[i]['state']) + states_np = np.array(states) + return states_np + + + def prefix_filenames(self, file_paths:List[str], prefixes:List[str]): + # Result list to hold the new filenames + result = [] + + # Iterate over the file_paths and prefixes together + for file, prefix in zip(file_paths, prefixes): + if file is None: + result.append(None) + continue + # Split the path into directory and file name + path_parts = file.rsplit('/', 1) + # Append the prefixed filename to the path + new_file = f'{path_parts[0]}/{prefix}{path_parts[1]}' + # Add the new file path to the result list + result.append(new_file) + + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_runner.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..a16bd58ed17fe3a8bd23e4f3f22e209fcc195d00 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/env_runner/pusht_keypoints_runner.py @@ -0,0 +1,273 @@ +import wandb +import numpy as np +import torch +import collections +import pathlib +import tqdm +import dill +import math +import wandb.sdk.data_types.video as wv +from diffusion_policy.env.pusht.pusht_keypoints_env import PushTKeypointsEnv +from diffusion_policy.gym_util.async_vector_env import AsyncVectorEnv +# from diffusion_policy.gym_util.sync_vector_env import SyncVectorEnv +from diffusion_policy.gym_util.multistep_wrapper import MultiStepWrapper +from diffusion_policy.gym_util.video_recording_wrapper import VideoRecordingWrapper, VideoRecorder + +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.common.pytorch_util import dict_apply +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner + +class PushTKeypointsRunner(BaseLowdimRunner): + def __init__(self, + output_dir, + keypoint_visible_rate=1.0, + n_train=10, + n_train_vis=3, + train_start_seed=0, + n_test=22, + n_test_vis=6, + legacy_test=False, + test_start_seed=10000, + max_steps=200, + n_obs_steps=8, + n_action_steps=8, + n_latency_steps=0, + fps=10, + crf=22, + agent_keypoints=False, + past_action=False, + tqdm_interval_sec=5.0, + n_envs=None + ): + super().__init__(output_dir) + + if n_envs is None: + n_envs = n_train + n_test + + # handle latency step + # to mimic latency, we request n_latency_steps additional steps + # of past observations, and the discard the last n_latency_steps + env_n_obs_steps = n_obs_steps + n_latency_steps + env_n_action_steps = n_action_steps + + # assert n_obs_steps <= n_action_steps + kp_kwargs = PushTKeypointsEnv.genenerate_keypoint_manager_params() + + def env_fn(): + return MultiStepWrapper( + VideoRecordingWrapper( + PushTKeypointsEnv( + legacy=legacy_test, + keypoint_visible_rate=keypoint_visible_rate, + agent_keypoints=agent_keypoints, + **kp_kwargs + ), + video_recoder=VideoRecorder.create_h264( + fps=fps, + codec='h264', + input_pix_fmt='rgb24', + crf=crf, + thread_type='FRAME', + thread_count=1 + ), + file_path=None, + ), + n_obs_steps=env_n_obs_steps, + n_action_steps=env_n_action_steps, + max_episode_steps=max_steps + ) + + env_fns = [env_fn] * n_envs + env_seeds = list() + env_prefixs = list() + env_init_fn_dills = list() + # train + for i in range(n_train): + seed = train_start_seed + i + enable_render = i < n_train_vis + + def init_fn(env, seed=seed, enable_render=enable_render): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapper) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('train/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + # test + for i in range(n_test): + seed = test_start_seed + i + enable_render = i < n_test_vis + + def init_fn(env, seed=seed, enable_render=enable_render): + # setup rendering + # video_wrapper + assert isinstance(env.env, VideoRecordingWrapper) + env.env.video_recoder.stop() + env.env.file_path = None + if enable_render: + filename = pathlib.Path(output_dir).joinpath( + 'media', wv.util.generate_id() + ".mp4") + filename.parent.mkdir(parents=False, exist_ok=True) + filename = str(filename) + env.env.file_path = filename + + # set seed + assert isinstance(env, MultiStepWrapper) + env.seed(seed) + + env_seeds.append(seed) + env_prefixs.append('test/') + env_init_fn_dills.append(dill.dumps(init_fn)) + + env = AsyncVectorEnv(env_fns) + + # test env + # env.reset(seed=env_seeds) + # x = env.step(env.action_space.sample()) + # imgs = env.call('render') + # import pdb; pdb.set_trace() + + self.env = env + self.env_fns = env_fns + self.env_seeds = env_seeds + self.env_prefixs = env_prefixs + self.env_init_fn_dills = env_init_fn_dills + self.fps = fps + self.crf = crf + self.agent_keypoints = agent_keypoints + self.n_obs_steps = n_obs_steps + self.n_action_steps = n_action_steps + self.n_latency_steps = n_latency_steps + self.past_action = past_action + self.max_steps = max_steps + self.tqdm_interval_sec = tqdm_interval_sec + + def run(self, policy: BaseLowdimPolicy): + device = policy.device + dtype = policy.dtype + + env = self.env + + # plan for rollout + n_envs = len(self.env_fns) + n_inits = len(self.env_init_fn_dills) + n_chunks = math.ceil(n_inits / n_envs) + + # allocate data + all_video_paths = [None] * n_inits + all_rewards = [None] * n_inits + + for chunk_idx in range(n_chunks): + start = chunk_idx * n_envs + end = min(n_inits, start + n_envs) + this_global_slice = slice(start, end) + this_n_active_envs = end - start + this_local_slice = slice(0,this_n_active_envs) + + this_init_fns = self.env_init_fn_dills[this_global_slice] + n_diff = n_envs - len(this_init_fns) + if n_diff > 0: + this_init_fns.extend([self.env_init_fn_dills[0]]*n_diff) + assert len(this_init_fns) == n_envs + + # init envs + env.call_each('run_dill_function', + args_list=[(x,) for x in this_init_fns]) + + # start rollout + obs = env.reset() + past_action = None + policy.reset() + + pbar = tqdm.tqdm(total=self.max_steps, desc=f"Eval PushtKeypointsRunner {chunk_idx+1}/{n_chunks}", + leave=False, mininterval=self.tqdm_interval_sec) + done = False + while not done: + Do = obs.shape[-1] // 2 + # create obs dict + np_obs_dict = { + # handle n_latency_steps by discarding the last n_latency_steps + 'obs': obs[...,:self.n_obs_steps,:Do].astype(np.float32), + 'obs_mask': obs[...,:self.n_obs_steps,Do:] > 0.5 + } + if self.past_action and (past_action is not None): + # TODO: not tested + np_obs_dict['past_action'] = past_action[ + :,-(self.n_obs_steps-1):].astype(np.float32) + + # device transfer + obs_dict = dict_apply(np_obs_dict, + lambda x: torch.from_numpy(x).to( + device=device)) + + # run policy + with torch.no_grad(): + action_dict = policy.predict_action(obs_dict) + + # device_transfer + np_action_dict = dict_apply(action_dict, + lambda x: x.detach().to('cpu').numpy()) + + # handle latency_steps, we discard the first n_latency_steps actions + # to simulate latency + action = np_action_dict['action'][:,self.n_latency_steps:] + + # step env + obs, reward, done, info = env.step(action) + done = np.all(done) + past_action = action + + # update pbar + pbar.update(action.shape[1]) + pbar.close() + + # collect data for this round + all_video_paths[this_global_slice] = env.render()[this_local_slice] + all_rewards[this_global_slice] = env.call('get_attr', 'reward')[this_local_slice] + # import pdb; pdb.set_trace() + + # log + max_rewards = collections.defaultdict(list) + log_data = dict() + # results reported in the paper are generated using the commented out line below + # which will only report and average metrics from first n_envs initial condition and seeds + # fortunately this won't invalidate our conclusion since + # 1. This bug only affects the variance of metrics, not their mean + # 2. All baseline methods are evaluated using the same code + # to completely reproduce reported numbers, uncomment this line: + # for i in range(len(self.env_fns)): + # and comment out this line + for i in range(n_inits): + seed = self.env_seeds[i] + prefix = self.env_prefixs[i] + max_reward = np.max(all_rewards[i]) + max_rewards[prefix].append(max_reward) + log_data[prefix+f'sim_max_reward_{seed}'] = max_reward + + # visualize sim + video_path = all_video_paths[i] + if video_path is not None: + sim_video = wandb.Video(video_path) + log_data[prefix+f'sim_video_{seed}'] = sim_video + + # log aggregate metrics + for prefix, value in max_rewards.items(): + name = prefix+'mean_score' + value = np.mean(value) + log_data[name] = value + + return log_data diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/async_vector_env.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/async_vector_env.py new file mode 100644 index 0000000000000000000000000000000000000000..dfb0f620ac3d39861e0dfc1a3a58fb22d443f244 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/async_vector_env.py @@ -0,0 +1,671 @@ +""" +Back ported methods: call, set_attr from v0.26 +Disabled auto-reset after done +Added render method. +""" + + +import numpy as np +import multiprocessing as mp +import time +import sys +from enum import Enum +from copy import deepcopy + +from gym import logger +from gym.vector.vector_env import VectorEnv +from gym.error import ( + AlreadyPendingCallError, + NoAsyncCallError, + ClosedEnvironmentError, + CustomSpaceError, +) +from gym.vector.utils import ( + create_shared_memory, + create_empty_array, + write_to_shared_memory, + read_from_shared_memory, + concatenate, + CloudpickleWrapper, + clear_mpi_env_vars, +) + +__all__ = ["AsyncVectorEnv"] + + +class AsyncState(Enum): + DEFAULT = "default" + WAITING_RESET = "reset" + WAITING_STEP = "step" + WAITING_CALL = "call" + + +class AsyncVectorEnv(VectorEnv): + """Vectorized environment that runs multiple environments in parallel. It + uses `multiprocessing` processes, and pipes for communication. + Parameters + ---------- + env_fns : iterable of callable + Functions that create the environments. + observation_space : `gym.spaces.Space` instance, optional + Observation space of a single environment. If `None`, then the + observation space of the first environment is taken. + action_space : `gym.spaces.Space` instance, optional + Action space of a single environment. If `None`, then the action space + of the first environment is taken. + shared_memory : bool (default: `True`) + If `True`, then the observations from the worker processes are + communicated back through shared variables. This can improve the + efficiency if the observations are large (e.g. images). + copy : bool (default: `True`) + If `True`, then the `reset` and `step` methods return a copy of the + observations. + context : str, optional + Context for multiprocessing. If `None`, then the default context is used. + Only available in Python 3. + daemon : bool (default: `True`) + If `True`, then subprocesses have `daemon` flag turned on; that is, they + will quit if the head process quits. However, `daemon=True` prevents + subprocesses to spawn children, so for some environments you may want + to have it set to `False` + worker : function, optional + WARNING - advanced mode option! If set, then use that worker in a subprocess + instead of a default one. Can be useful to override some inner vector env + logic, for instance, how resets on done are handled. Provides high + degree of flexibility and a high chance to shoot yourself in the foot; thus, + if you are writing your own worker, it is recommended to start from the code + for `_worker` (or `_worker_shared_memory`) method below, and add changes + """ + + def __init__( + self, + env_fns, + dummy_env_fn=None, + observation_space=None, + action_space=None, + shared_memory=True, + copy=True, + context=None, + daemon=True, + worker=None, + ): + ctx = mp.get_context(context) + self.env_fns = env_fns + self.shared_memory = shared_memory + self.copy = copy + + # Added dummy_env_fn to fix OpenGL error in Mujoco + # disable any OpenGL rendering in dummy_env_fn, since it + # will conflict with OpenGL context in the forked child process + if dummy_env_fn is None: + dummy_env_fn = env_fns[0] + dummy_env = dummy_env_fn() + self.metadata = dummy_env.metadata + + if (observation_space is None) or (action_space is None): + observation_space = observation_space or dummy_env.observation_space + action_space = action_space or dummy_env.action_space + dummy_env.close() + del dummy_env + super(AsyncVectorEnv, self).__init__( + num_envs=len(env_fns), + observation_space=observation_space, + action_space=action_space, + ) + + if self.shared_memory: + try: + _obs_buffer = create_shared_memory( + self.single_observation_space, n=self.num_envs, ctx=ctx + ) + self.observations = read_from_shared_memory( + _obs_buffer, self.single_observation_space, n=self.num_envs + ) + except CustomSpaceError: + raise ValueError( + "Using `shared_memory=True` in `AsyncVectorEnv` " + "is incompatible with non-standard Gym observation spaces " + "(i.e. custom spaces inheriting from `gym.Space`), and is " + "only compatible with default Gym spaces (e.g. `Box`, " + "`Tuple`, `Dict`) for batching. Set `shared_memory=False` " + "if you use custom observation spaces." + ) + else: + _obs_buffer = None + self.observations = create_empty_array( + self.single_observation_space, n=self.num_envs, fn=np.zeros + ) + + self.parent_pipes, self.processes = [], [] + self.error_queue = ctx.Queue() + target = _worker_shared_memory if self.shared_memory else _worker + target = worker or target + with clear_mpi_env_vars(): + for idx, env_fn in enumerate(self.env_fns): + parent_pipe, child_pipe = ctx.Pipe() + process = ctx.Process( + target=target, + name="Worker<{0}>-{1}".format(type(self).__name__, idx), + args=( + idx, + CloudpickleWrapper(env_fn), + child_pipe, + parent_pipe, + _obs_buffer, + self.error_queue, + ), + ) + + self.parent_pipes.append(parent_pipe) + self.processes.append(process) + + process.daemon = daemon + process.start() + child_pipe.close() + + self._state = AsyncState.DEFAULT + self._check_observation_spaces() + + def seed(self, seeds=None): + self._assert_is_running() + if seeds is None: + seeds = [None for _ in range(self.num_envs)] + if isinstance(seeds, int): + seeds = [seeds + i for i in range(self.num_envs)] + assert len(seeds) == self.num_envs + + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `seed` while waiting " + "for a pending call to `{0}` to complete.".format(self._state.value), + self._state.value, + ) + + for pipe, seed in zip(self.parent_pipes, seeds): + pipe.send(("seed", seed)) + _, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + + def reset_async(self): + self._assert_is_running() + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `reset_async` while waiting " + "for a pending call to `{0}` to complete".format(self._state.value), + self._state.value, + ) + + for pipe in self.parent_pipes: + pipe.send(("reset", None)) + self._state = AsyncState.WAITING_RESET + + def reset_wait(self, timeout=None): + """ + Parameters + ---------- + timeout : int or float, optional + Number of seconds before the call to `reset_wait` times out. If + `None`, the call to `reset_wait` never times out. + Returns + ------- + observations : sample from `observation_space` + A batch of observations from the vectorized environment. + """ + self._assert_is_running() + if self._state != AsyncState.WAITING_RESET: + raise NoAsyncCallError( + "Calling `reset_wait` without any prior " "call to `reset_async`.", + AsyncState.WAITING_RESET.value, + ) + + if not self._poll(timeout): + self._state = AsyncState.DEFAULT + raise mp.TimeoutError( + "The call to `reset_wait` has timed out after " + "{0} second{1}.".format(timeout, "s" if timeout > 1 else "") + ) + + results, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + self._state = AsyncState.DEFAULT + + if not self.shared_memory: + self.observations = concatenate( + results, self.observations, self.single_observation_space + ) + + return deepcopy(self.observations) if self.copy else self.observations + + def step_async(self, actions): + """ + Parameters + ---------- + actions : iterable of samples from `action_space` + List of actions. + """ + self._assert_is_running() + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `step_async` while waiting " + "for a pending call to `{0}` to complete.".format(self._state.value), + self._state.value, + ) + + for pipe, action in zip(self.parent_pipes, actions): + pipe.send(("step", action)) + self._state = AsyncState.WAITING_STEP + + def step_wait(self, timeout=None): + """ + Parameters + ---------- + timeout : int or float, optional + Number of seconds before the call to `step_wait` times out. If + `None`, the call to `step_wait` never times out. + Returns + ------- + observations : sample from `observation_space` + A batch of observations from the vectorized environment. + rewards : `np.ndarray` instance (dtype `np.float_`) + A vector of rewards from the vectorized environment. + dones : `np.ndarray` instance (dtype `np.bool_`) + A vector whose entries indicate whether the episode has ended. + infos : list of dict + A list of auxiliary diagnostic information. + """ + self._assert_is_running() + if self._state != AsyncState.WAITING_STEP: + raise NoAsyncCallError( + "Calling `step_wait` without any prior call " "to `step_async`.", + AsyncState.WAITING_STEP.value, + ) + + if not self._poll(timeout): + self._state = AsyncState.DEFAULT + raise mp.TimeoutError( + "The call to `step_wait` has timed out after " + "{0} second{1}.".format(timeout, "s" if timeout > 1 else "") + ) + + results, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + self._state = AsyncState.DEFAULT + observations_list, rewards, dones, infos = zip(*results) + + if not self.shared_memory: + self.observations = concatenate( + observations_list, self.observations, self.single_observation_space + ) + + return ( + deepcopy(self.observations) if self.copy else self.observations, + np.array(rewards), + np.array(dones, dtype=np.bool_), + infos, + ) + + def close_extras(self, timeout=None, terminate=False): + """ + Parameters + ---------- + timeout : int or float, optional + Number of seconds before the call to `close` times out. If `None`, + the call to `close` never times out. If the call to `close` times + out, then all processes are terminated. + terminate : bool (default: `False`) + If `True`, then the `close` operation is forced and all processes + are terminated. + """ + timeout = 0 if terminate else timeout + try: + if self._state != AsyncState.DEFAULT: + logger.warn( + "Calling `close` while waiting for a pending " + "call to `{0}` to complete.".format(self._state.value) + ) + function = getattr(self, "{0}_wait".format(self._state.value)) + function(timeout) + except mp.TimeoutError: + terminate = True + + if terminate: + for process in self.processes: + if process.is_alive(): + process.terminate() + else: + for pipe in self.parent_pipes: + if (pipe is not None) and (not pipe.closed): + pipe.send(("close", None)) + for pipe in self.parent_pipes: + if (pipe is not None) and (not pipe.closed): + pipe.recv() + + for pipe in self.parent_pipes: + if pipe is not None: + pipe.close() + for process in self.processes: + process.join() + + def _poll(self, timeout=None): + self._assert_is_running() + if timeout is None: + return True + end_time = time.perf_counter() + timeout + delta = None + for pipe in self.parent_pipes: + delta = max(end_time - time.perf_counter(), 0) + if pipe is None: + return False + if pipe.closed or (not pipe.poll(delta)): + return False + return True + + def _check_observation_spaces(self): + self._assert_is_running() + for pipe in self.parent_pipes: + pipe.send(("_check_observation_space", self.single_observation_space)) + same_spaces, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + if not all(same_spaces): + raise RuntimeError( + "Some environments have an observation space " + "different from `{0}`. In order to batch observations, the " + "observation spaces from all environments must be " + "equal.".format(self.single_observation_space) + ) + + def _assert_is_running(self): + if self.closed: + raise ClosedEnvironmentError( + "Trying to operate on `{0}`, after a " + "call to `close()`.".format(type(self).__name__) + ) + + def _raise_if_errors(self, successes): + if all(successes): + return + + num_errors = self.num_envs - sum(successes) + assert num_errors > 0 + for _ in range(num_errors): + index, exctype, value = self.error_queue.get() + logger.error( + "Received the following error from Worker-{0}: " + "{1}: {2}".format(index, exctype.__name__, value) + ) + logger.error("Shutting down Worker-{0}.".format(index)) + self.parent_pipes[index].close() + self.parent_pipes[index] = None + + logger.error("Raising the last exception back to the main process.") + raise exctype(value) + + def call_async(self, name: str, *args, **kwargs): + """Calls the method with name asynchronously and apply args and kwargs to the method. + + Args: + name: Name of the method or property to call. + *args: Arguments to apply to the method call. + **kwargs: Keyword arguments to apply to the method call. + + Raises: + ClosedEnvironmentError: If the environment was closed (if :meth:`close` was previously called). + AlreadyPendingCallError: Calling `call_async` while waiting for a pending call to complete + """ + self._assert_is_running() + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `call_async` while waiting " + f"for a pending call to `{self._state.value}` to complete.", + self._state.value, + ) + + for pipe in self.parent_pipes: + pipe.send(("_call", (name, args, kwargs))) + self._state = AsyncState.WAITING_CALL + + def call_wait(self, timeout = None) -> list: + """Calls all parent pipes and waits for the results. + + Args: + timeout: Number of seconds before the call to `step_wait` times out. + If `None` (default), the call to `step_wait` never times out. + + Returns: + List of the results of the individual calls to the method or property for each environment. + + Raises: + NoAsyncCallError: Calling `call_wait` without any prior call to `call_async`. + TimeoutError: The call to `call_wait` has timed out after timeout second(s). + """ + self._assert_is_running() + if self._state != AsyncState.WAITING_CALL: + raise NoAsyncCallError( + "Calling `call_wait` without any prior call to `call_async`.", + AsyncState.WAITING_CALL.value, + ) + + if not self._poll(timeout): + self._state = AsyncState.DEFAULT + raise mp.TimeoutError( + f"The call to `call_wait` has timed out after {timeout} second(s)." + ) + + results, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + self._state = AsyncState.DEFAULT + + return results + + def call(self, name: str, *args, **kwargs): + """Call a method, or get a property, from each parallel environment. + + Args: + name (str): Name of the method or property to call. + *args: Arguments to apply to the method call. + **kwargs: Keyword arguments to apply to the method call. + + Returns: + List of the results of the individual calls to the method or property for each environment. + """ + self.call_async(name, *args, **kwargs) + return self.call_wait() + + + def call_each(self, name: str, + args_list: list=None, + kwargs_list: list=None, + timeout = None): + n_envs = len(self.parent_pipes) + if args_list is None: + args_list = [[]] * n_envs + assert len(args_list) == n_envs + + if kwargs_list is None: + kwargs_list = [dict()] * n_envs + assert len(kwargs_list) == n_envs + + # send + self._assert_is_running() + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `call_async` while waiting " + f"for a pending call to `{self._state.value}` to complete.", + self._state.value, + ) + + for i, pipe in enumerate(self.parent_pipes): + pipe.send(("_call", (name, args_list[i], kwargs_list[i]))) + self._state = AsyncState.WAITING_CALL + + # receive + self._assert_is_running() + if self._state != AsyncState.WAITING_CALL: + raise NoAsyncCallError( + "Calling `call_wait` without any prior call to `call_async`.", + AsyncState.WAITING_CALL.value, + ) + + if not self._poll(timeout): + self._state = AsyncState.DEFAULT + raise mp.TimeoutError( + f"The call to `call_wait` has timed out after {timeout} second(s)." + ) + + results, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + self._state = AsyncState.DEFAULT + + return results + + + def set_attr(self, name: str, values): + """Sets an attribute of the sub-environments. + + Args: + name: Name of the property to be set in each individual environment. + values: Values of the property to be set to. If ``values`` is a list or + tuple, then it corresponds to the values for each individual + environment, otherwise a single value is set for all environments. + + Raises: + ValueError: Values must be a list or tuple with length equal to the number of environments. + AlreadyPendingCallError: Calling `set_attr` while waiting for a pending call to complete. + """ + self._assert_is_running() + if not isinstance(values, (list, tuple)): + values = [values for _ in range(self.num_envs)] + if len(values) != self.num_envs: + raise ValueError( + "Values must be a list or tuple with length equal to the " + f"number of environments. Got `{len(values)}` values for " + f"{self.num_envs} environments." + ) + + if self._state != AsyncState.DEFAULT: + raise AlreadyPendingCallError( + "Calling `set_attr` while waiting " + f"for a pending call to `{self._state.value}` to complete.", + self._state.value, + ) + + for pipe, value in zip(self.parent_pipes, values): + pipe.send(("_setattr", (name, value))) + _, successes = zip(*[pipe.recv() for pipe in self.parent_pipes]) + self._raise_if_errors(successes) + + def render(self, *args, **kwargs): + return self.call('render', *args, **kwargs) + + + +def _worker(index, env_fn, pipe, parent_pipe, shared_memory, error_queue): + assert shared_memory is None + env = env_fn() + parent_pipe.close() + try: + while True: + command, data = pipe.recv() + if command == "reset": + observation = env.reset() + pipe.send((observation, True)) + elif command == "step": + observation, reward, done, info = env.step(data) + # if done: + # observation = env.reset() + pipe.send(((observation, reward, done, info), True)) + elif command == "seed": + env.seed(data) + pipe.send((None, True)) + elif command == "close": + pipe.send((None, True)) + break + elif command == "_call": + name, args, kwargs = data + if name in ["reset", "step", "seed", "close"]: + raise ValueError( + f"Trying to call function `{name}` with " + f"`_call`. Use `{name}` directly instead." + ) + function = getattr(env, name) + if callable(function): + pipe.send((function(*args, **kwargs), True)) + else: + pipe.send((function, True)) + elif command == "_setattr": + name, value = data + setattr(env, name, value) + pipe.send((None, True)) + + elif command == "_check_observation_space": + pipe.send((data == env.observation_space, True)) + else: + raise RuntimeError( + "Received unknown command `{0}`. Must " + "be one of {`reset`, `step`, `seed`, `close`, " + "`_check_observation_space`}.".format(command) + ) + except (KeyboardInterrupt, Exception): + error_queue.put((index,) + sys.exc_info()[:2]) + pipe.send((None, False)) + finally: + env.close() + + +def _worker_shared_memory(index, env_fn, pipe, parent_pipe, shared_memory, error_queue): + assert shared_memory is not None + env = env_fn() + observation_space = env.observation_space + parent_pipe.close() + try: + while True: + command, data = pipe.recv() + if command == "reset": + observation = env.reset() + write_to_shared_memory( + index, observation, shared_memory, observation_space + ) + pipe.send((None, True)) + elif command == "step": + observation, reward, done, info = env.step(data) + # if done: + # observation = env.reset() + write_to_shared_memory( + index, observation, shared_memory, observation_space + ) + pipe.send(((None, reward, done, info), True)) + elif command == "seed": + env.seed(data) + pipe.send((None, True)) + elif command == "close": + pipe.send((None, True)) + break + elif command == "_call": + name, args, kwargs = data + if name in ["reset", "step", "seed", "close"]: + raise ValueError( + f"Trying to call function `{name}` with " + f"`_call`. Use `{name}` directly instead." + ) + function = getattr(env, name) + if callable(function): + pipe.send((function(*args, **kwargs), True)) + else: + pipe.send((function, True)) + elif command == "_setattr": + name, value = data + setattr(env, name, value) + pipe.send((None, True)) + elif command == "_check_observation_space": + pipe.send((data == observation_space, True)) + else: + raise RuntimeError( + "Received unknown command `{0}`. Must " + "be one of {`reset`, `step`, `seed`, `close`, " + "`_check_observation_space`}.".format(command) + ) + except (KeyboardInterrupt, Exception): + error_queue.put((index,) + sys.exc_info()[:2]) + pipe.send((None, False)) + finally: + env.close() \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..cf2cfdac13fe478485fac90acb4ca1b0e8b8c524 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper.py @@ -0,0 +1,162 @@ +import gym +from gym import spaces +import numpy as np +from collections import defaultdict, deque +import dill + +def stack_repeated(x, n): + return np.repeat(np.expand_dims(x,axis=0),n,axis=0) + +def repeated_box(box_space, n): + return spaces.Box( + low=stack_repeated(box_space.low, n), + high=stack_repeated(box_space.high, n), + shape=(n,) + box_space.shape, + dtype=box_space.dtype + ) + +def repeated_space(space, n): + if isinstance(space, spaces.Box): + return repeated_box(space, n) + elif isinstance(space, spaces.Dict): + result_space = spaces.Dict() + for key, value in space.items(): + result_space[key] = repeated_space(value, n) + return result_space + else: + raise RuntimeError(f'Unsupported space type {type(space)}') + +def take_last_n(x, n): + x = list(x) + n = min(len(x), n) + return np.array(x[-n:]) + +def dict_take_last_n(x, n): + result = dict() + for key, value in x.items(): + result[key] = take_last_n(value, n) + return result + +def aggregate(data, method='max'): + if method == 'max': + # equivalent to any + return np.max(data) + elif method == 'min': + # equivalent to all + return np.min(data) + elif method == 'mean': + return np.mean(data) + elif method == 'sum': + return np.sum(data) + else: + raise NotImplementedError() + +def stack_last_n_obs(all_obs, n_steps): + assert(len(all_obs) > 0) + all_obs = list(all_obs) + result = np.zeros((n_steps,) + all_obs[-1].shape, + dtype=all_obs[-1].dtype) + start_idx = -min(n_steps, len(all_obs)) + result[start_idx:] = np.array(all_obs[start_idx:]) + if n_steps > len(all_obs): + # pad + result[:start_idx] = result[start_idx] + return result + + +class MultiStepWrapper(gym.Wrapper): + def __init__(self, + env, + n_obs_steps, + n_action_steps, + max_episode_steps=None, + reward_agg_method='max' + ): + super().__init__(env) + self._action_space = repeated_space(env.action_space, n_action_steps) + self._observation_space = repeated_space(env.observation_space, n_obs_steps) + self.max_episode_steps = max_episode_steps + self.n_obs_steps = n_obs_steps + self.n_action_steps = n_action_steps + self.reward_agg_method = reward_agg_method + self.n_obs_steps = n_obs_steps + + self.obs = deque(maxlen=n_obs_steps+1) + self.reward = list() + self.done = list() + self.info = defaultdict(lambda : deque(maxlen=n_obs_steps+1)) + + def reset(self): + """Resets the environment using kwargs.""" + obs = super().reset() + + self.obs = deque([obs], maxlen=self.n_obs_steps+1) + self.reward = list() + self.done = list() + self.info = defaultdict(lambda : deque(maxlen=self.n_obs_steps+1)) + + obs = self._get_obs(self.n_obs_steps) + return obs + + def step(self, action): + """ + actions: (n_action_steps,) + action_shape + """ + for act in action: + if len(self.done) > 0 and self.done[-1]: + # termination + break + observation, reward, done, info = super().step(act) + + self.obs.append(observation) + self.reward.append(reward) + if (self.max_episode_steps is not None) \ + and (len(self.reward) >= self.max_episode_steps): + # truncation + done = True + self.done.append(done) + self._add_info(info) + + observation = self._get_obs(self.n_obs_steps) + reward = aggregate(self.reward, self.reward_agg_method) + done = aggregate(self.done, 'max') + info = dict_take_last_n(self.info, self.n_obs_steps) + return observation, reward, done, info + + def _get_obs(self, n_steps=1): + """ + Output (n_steps,) + obs_shape + """ + assert(len(self.obs) > 0) + if isinstance(self.observation_space, spaces.Box): + return stack_last_n_obs(self.obs, n_steps) + elif isinstance(self.observation_space, spaces.Dict): + result = dict() + for key in self.observation_space.keys(): + result[key] = stack_last_n_obs( + [obs[key] for obs in self.obs], + n_steps + ) + return result + else: + raise RuntimeError('Unsupported space type') + + def _add_info(self, info): + for key, value in info.items(): + self.info[key].append(value) + + def get_rewards(self): + return self.reward + + def get_attr(self, name): + return getattr(self, name) + + def run_dill_function(self, dill_fn): + fn = dill.loads(dill_fn) + return fn(self) + + def get_infos(self): + result = dict() + for k, v in self.info.items(): + result[k] = list(v) + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper_state.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper_state.py new file mode 100644 index 0000000000000000000000000000000000000000..c79254fe00cf62789122d092e5598b4b7e2d1b6c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/multistep_wrapper_state.py @@ -0,0 +1,61 @@ +import gym +from gym import spaces +import numpy as np +from collections import defaultdict, deque +import dill +from diffusion_policy.gym_util.multistep_wrapper import MultiStepWrapper, aggregate, dict_take_last_n + + +class MultiStepWrapperPushTFullState(MultiStepWrapper): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state_trj = np.empty((0, 5)) + self.base_env = self.unwrapped + + def reset(self): + """Resets the environment using kwargs.""" + obs = super().reset() + st_trj = self.base_env.get_state_trj() + assert st_trj is not None, \ + f"Initial state of the env is not properly set." + self.state_trj = st_trj + + return obs + + def step(self, action): + """ + actions: (n_action_steps,) + action_shape + """ + for act in action: + if len(self.done) > 0 and self.done[-1]: + # termination + break + observation, reward, done, info = \ + super(MultiStepWrapper, self).step(act) + + st_trj = self.base_env.get_state_trj() + assert st_trj is not None, \ + f"Initial state of the env is not properly set." + self.state_trj = st_trj + + self.obs.append(observation) + self.reward.append(reward) + if (self.max_episode_steps is not None) \ + and (len(self.reward) >= self.max_episode_steps): + # truncation + done = True + self.done.append(done) + self._add_info(info) + + observation = self._get_obs(self.n_obs_steps) + reward = aggregate(self.reward, self.reward_agg_method) + done = aggregate(self.done, 'max') + info = dict_take_last_n(self.info, self.n_obs_steps) + return observation, reward, done, info + + + def run_dill_function(self, dill_fn, **kwargs): + fn = dill.loads(dill_fn) + return fn(self, **kwargs) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/sync_vector_env.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/sync_vector_env.py new file mode 100644 index 0000000000000000000000000000000000000000..c85a68223ec1a1f7e3cbb406d3472795374704f8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/sync_vector_env.py @@ -0,0 +1,182 @@ +import numpy as np +from copy import deepcopy + +from gym import logger +from gym.vector.vector_env import VectorEnv +from gym.vector.utils import concatenate, create_empty_array + +__all__ = ["SyncVectorEnv"] + + +class SyncVectorEnv(VectorEnv): + """Vectorized environment that serially runs multiple environments. + Parameters + ---------- + env_fns : iterable of callable + Functions that create the environments. + observation_space : `gym.spaces.Space` instance, optional + Observation space of a single environment. If `None`, then the + observation space of the first environment is taken. + action_space : `gym.spaces.Space` instance, optional + Action space of a single environment. If `None`, then the action space + of the first environment is taken. + copy : bool (default: `True`) + If `True`, then the `reset` and `step` methods return a copy of the + observations. + """ + + def __init__(self, env_fns, observation_space=None, action_space=None, copy=True): + self.env_fns = env_fns + self.envs = [env_fn() for env_fn in env_fns] + self.copy = copy + self.metadata = self.envs[0].metadata + + if (observation_space is None) or (action_space is None): + observation_space = observation_space or self.envs[0].observation_space + action_space = action_space or self.envs[0].action_space + super(SyncVectorEnv, self).__init__( + num_envs=len(env_fns), + observation_space=observation_space, + action_space=action_space, + ) + + self._check_observation_spaces() + self.observations = create_empty_array( + self.single_observation_space, n=self.num_envs, fn=np.zeros + ) + self._rewards = np.zeros((self.num_envs,), dtype=np.float64) + self._dones = np.zeros((self.num_envs,), dtype=np.bool_) + # self._rewards = [0] * self.num_envs + # self._dones = [False] * self.num_envs + self._actions = None + + def seed(self, seeds=None): + if seeds is None: + seeds = [None for _ in range(self.num_envs)] + if isinstance(seeds, int): + seeds = [seeds + i for i in range(self.num_envs)] + assert len(seeds) == self.num_envs + + for env, seed in zip(self.envs, seeds): + env.seed(seed) + + def reset_wait(self): + self._dones[:] = False + observations = [] + for env in self.envs: + observation = env.reset() + observations.append(observation) + self.observations = concatenate( + observations, self.observations, self.single_observation_space + ) + + return deepcopy(self.observations) if self.copy else self.observations + + def step_async(self, actions): + self._actions = actions + + def step_wait(self): + observations, infos = [], [] + for i, (env, action) in enumerate(zip(self.envs, self._actions)): + observation, self._rewards[i], self._dones[i], info = env.step(action) + # if self._dones[i]: + # observation = env.reset() + observations.append(observation) + infos.append(info) + self.observations = concatenate( + observations, self.observations, self.single_observation_space + ) + + return ( + deepcopy(self.observations) if self.copy else self.observations, + np.copy(self._rewards), + np.copy(self._dones), + infos, + ) + + def close_extras(self, **kwargs): + [env.close() for env in self.envs] + + def _check_observation_spaces(self): + for env in self.envs: + if not (env.observation_space == self.single_observation_space): + break + else: + return True + raise RuntimeError( + "Some environments have an observation space " + "different from `{0}`. In order to batch observations, the " + "observation spaces from all environments must be " + "equal.".format(self.single_observation_space) + ) + + def call(self, name, *args, **kwargs) -> tuple: + """Calls the method with name and applies args and kwargs. + + Args: + name: The method name + *args: The method args + **kwargs: The method kwargs + + Returns: + Tuple of results + """ + results = [] + for env in self.envs: + function = getattr(env, name) + if callable(function): + results.append(function(*args, **kwargs)) + else: + results.append(function) + + return tuple(results) + + def call_each(self, name: str, + args_list: list=None, + kwargs_list: list=None): + n_envs = len(self.envs) + if args_list is None: + args_list = [[]] * n_envs + assert len(args_list) == n_envs + + if kwargs_list is None: + kwargs_list = [dict()] * n_envs + assert len(kwargs_list) == n_envs + + results = [] + for i, env in enumerate(self.envs): + function = getattr(env, name) + if callable(function): + results.append(function(*args_list[i], **kwargs_list[i])) + else: + results.append(function) + + return tuple(results) + + + def render(self, *args, **kwargs): + return self.call('render', *args, **kwargs) + + def set_attr(self, name: str, values): + """Sets an attribute of the sub-environments. + + Args: + name: The property name to change + values: Values of the property to be set to. If ``values`` is a list or + tuple, then it corresponds to the values for each individual + environment, otherwise, a single value is set for all environments. + + Raises: + ValueError: Values must be a list or tuple with length equal to the number of environments. + """ + if not isinstance(values, (list, tuple)): + values = [values for _ in range(self.num_envs)] + if len(values) != self.num_envs: + raise ValueError( + "Values must be a list or tuple with length equal to the " + f"number of environments. Got `{len(values)}` values for " + f"{self.num_envs} environments." + ) + + for env, value in zip(self.envs, values): + setattr(env, name, value) \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_recording_wrapper.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_recording_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..c690f50dca46b151d5931eb76a1b1db58986d3c8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_recording_wrapper.py @@ -0,0 +1,51 @@ +import gym +import numpy as np +from diffusion_policy.real_world.video_recorder import VideoRecorder + +class VideoRecordingWrapper(gym.Wrapper): + def __init__(self, + env, + video_recoder: VideoRecorder, + mode='rgb_array', + file_path=None, + steps_per_render=1, + **kwargs + ): + """ + When file_path is None, don't record. + """ + super().__init__(env) + + self.mode = mode + self.render_kwargs = kwargs + self.steps_per_render = steps_per_render + self.file_path = file_path + self.video_recoder = video_recoder + + self.step_count = 0 + + def reset(self, **kwargs): + obs = super().reset(**kwargs) + self.frames = list() + self.step_count = 1 + self.video_recoder.stop() + return obs + + def step(self, action): + result = super().step(action) + self.step_count += 1 + if self.file_path is not None \ + and ((self.step_count % self.steps_per_render) == 0): + if not self.video_recoder.is_ready(): + self.video_recoder.start(self.file_path) + + frame = self.env.render( + mode=self.mode, **self.render_kwargs) + assert frame.dtype == np.uint8 + self.video_recoder.write_frame(frame) + return result + + def render(self, mode='rgb_array', **kwargs): + if self.video_recoder.is_ready(): + self.video_recoder.stop() + return self.file_path diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_wrapper.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..abfebbefea52ac7e7eef9e21707c8e091b741940 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/gym_util/video_wrapper.py @@ -0,0 +1,44 @@ +import gym +import numpy as np + +class VideoWrapper(gym.Wrapper): + def __init__(self, + env, + mode='rgb_array', + enabled=True, + steps_per_render=1, + **kwargs + ): + super().__init__(env) + + self.mode = mode + self.enabled = enabled + self.render_kwargs = kwargs + self.steps_per_render = steps_per_render + + self.frames = list() + self.step_count = 0 + + def reset(self, **kwargs): + obs = super().reset(**kwargs) + self.frames = list() + self.step_count = 1 + if self.enabled: + frame = self.env.render( + mode=self.mode, **self.render_kwargs) + assert frame.dtype == np.uint8 + self.frames.append(frame) + return obs + + def step(self, action): + result = super().step(action) + self.step_count += 1 + if self.enabled and ((self.step_count % self.steps_per_render) == 0): + frame = self.env.render( + mode=self.mode, **self.render_kwargs) + assert frame.dtype == np.uint8 + self.frames.append(frame) + return result + + def render(self, mode='rgb_array', **kwargs): + return self.frames diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9a7b88d8e6f8363f5a6d10c2bb5f4d00016d46c0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/__init__.py @@ -0,0 +1,63 @@ +import torch +import torch.nn as nn +from torch.utils.data import DataLoader +import abc + +from typing import Optional, Union + +import diffusion_policy.model.bet.utils as utils + + +class AbstractActionAE(utils.SaveModule, abc.ABC): + @abc.abstractmethod + def fit_model( + self, + input_dataloader: DataLoader, + eval_dataloader: DataLoader, + obs_encoding_net: Optional[nn.Module] = None, + ) -> None: + pass + + @abc.abstractmethod + def encode_into_latent( + self, + input_action: torch.Tensor, + input_rep: Optional[torch.Tensor], + ) -> torch.Tensor: + """ + Given the input action, discretize it. + + Inputs: + input_action (shape: ... x action_dim): The input action to discretize. This can be in a batch, + and is generally assumed that the last dimnesion is the action dimension. + + Outputs: + discretized_action (shape: ... x num_tokens): The discretized action. + """ + raise NotImplementedError + + @abc.abstractmethod + def decode_actions( + self, + latent_action_batch: Optional[torch.Tensor], + input_rep_batch: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """ + Given a discretized action, convert it to a continuous action. + + Inputs: + latent_action_batch (shape: ... x num_tokens): The discretized action + generated by the discretizer. + + Outputs: + continuous_action (shape: ... x action_dim): The continuous action. + """ + raise NotImplementedError + + @property + @abc.abstractmethod + def num_latents(self) -> Union[int, float]: + """ + Number of possible latents for this generator, useful for state priors that use softmax. + """ + return float("inf") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/discretizers/k_means.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/discretizers/k_means.py new file mode 100644 index 0000000000000000000000000000000000000000..9051df083413701ff7f57642761b5b1eab2ed453 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/action_ae/discretizers/k_means.py @@ -0,0 +1,148 @@ +import torch +import numpy as np + +import tqdm + +from typing import Optional, Tuple, Union +from diffusion_policy.model.common.dict_of_tensor_mixin import DictOfTensorMixin + + +class KMeansDiscretizer(DictOfTensorMixin): + """ + Simplified and modified version of KMeans algorithm from sklearn. + """ + + def __init__( + self, + action_dim: int, + num_bins: int = 100, + predict_offsets: bool = False, + ): + super().__init__() + self.n_bins = num_bins + self.action_dim = action_dim + self.predict_offsets = predict_offsets + + def fit_discretizer(self, input_actions: torch.Tensor) -> None: + assert ( + self.action_dim == input_actions.shape[-1] + ), f"Input action dimension {self.action_dim} does not match fitted model {input_actions.shape[-1]}" + + flattened_actions = input_actions.view(-1, self.action_dim) + cluster_centers = KMeansDiscretizer._kmeans( + flattened_actions, ncluster=self.n_bins + ) + self.params_dict['bin_centers'] = cluster_centers + + @property + def suggested_actions(self) -> torch.Tensor: + return self.params_dict['bin_centers'] + + @classmethod + def _kmeans(cls, x: torch.Tensor, ncluster: int = 512, niter: int = 50): + """ + Simple k-means clustering algorithm adapted from Karpathy's minGPT library + https://github.com/karpathy/minGPT/blob/master/play_image.ipynb + """ + N, D = x.size() + c = x[torch.randperm(N)[:ncluster]] # init clusters at random + + pbar = tqdm.trange(niter) + pbar.set_description("K-means clustering") + for i in pbar: + # assign all pixels to the closest codebook element + a = ((x[:, None, :] - c[None, :, :]) ** 2).sum(-1).argmin(1) + # move each codebook element to be the mean of the pixels that assigned to it + c = torch.stack([x[a == k].mean(0) for k in range(ncluster)]) + # re-assign any poorly positioned codebook elements + nanix = torch.any(torch.isnan(c), dim=1) + ndead = nanix.sum().item() + if ndead: + tqdm.tqdm.write( + "done step %d/%d, re-initialized %d dead clusters" + % (i + 1, niter, ndead) + ) + c[nanix] = x[torch.randperm(N)[:ndead]] # re-init dead clusters + return c + + def encode_into_latent( + self, input_action: torch.Tensor, input_rep: Optional[torch.Tensor] = None + ) -> torch.Tensor: + """ + Given the input action, discretize it using the k-Means clustering algorithm. + + Inputs: + input_action (shape: ... x action_dim): The input action to discretize. This can be in a batch, + and is generally assumed that the last dimnesion is the action dimension. + + Outputs: + discretized_action (shape: ... x num_tokens): The discretized action. + If self.predict_offsets is True, then the offsets are also returned. + """ + assert ( + input_action.shape[-1] == self.action_dim + ), "Input action dimension does not match fitted model" + + # flatten the input action + flattened_actions = input_action.view(-1, self.action_dim) + + # get the closest cluster center + closest_cluster_center = torch.argmin( + torch.sum( + (flattened_actions[:, None, :] - self.params_dict['bin_centers'][None, :, :]) ** 2, + dim=2, + ), + dim=1, + ) + # Reshape to the original shape + discretized_action = closest_cluster_center.view(input_action.shape[:-1] + (1,)) + + if self.predict_offsets: + # decode from latent and get the difference + reconstructed_action = self.decode_actions(discretized_action) + offsets = input_action - reconstructed_action + return (discretized_action, offsets) + else: + # return the one-hot vector + return discretized_action + + def decode_actions( + self, + latent_action_batch: torch.Tensor, + input_rep_batch: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """ + Given the latent action, reconstruct the original action. + + Inputs: + latent_action (shape: ... x 1): The latent action to reconstruct. This can be in a batch, + and is generally assumed that the last dimension is the action dimension. If the latent_action_batch + is a tuple, then it is assumed to be (discretized_action, offsets). + + Outputs: + reconstructed_action (shape: ... x action_dim): The reconstructed action. + """ + offsets = None + if type(latent_action_batch) == tuple: + latent_action_batch, offsets = latent_action_batch + # get the closest cluster center + closest_cluster_center = self.params_dict['bin_centers'][latent_action_batch] + # Reshape to the original shape + reconstructed_action = closest_cluster_center.view( + latent_action_batch.shape[:-1] + (self.action_dim,) + ) + if offsets is not None: + reconstructed_action += offsets + return reconstructed_action + + @property + def discretized_space(self) -> int: + return self.n_bins + + @property + def latent_dim(self) -> int: + return 1 + + @property + def num_latents(self) -> int: + return self.n_bins diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/latent_generator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/latent_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..072bdc86abe3e303ea6d8563071a41d86142b957 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/latent_generator.py @@ -0,0 +1,71 @@ +import abc +import torch +from typing import Tuple, Optional + +import diffusion_policy.model.bet.utils as utils + + +class AbstractLatentGenerator(abc.ABC, utils.SaveModule): + """ + Abstract class for a generative model that can generate latents given observation representations. + + In the probabilisitc sense, this model fits and samples from P(latent|observation) given some observation. + """ + + @abc.abstractmethod + def get_latent_and_loss( + self, + obs_rep: torch.Tensor, + target_latents: torch.Tensor, + seq_masks: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Given a set of observation representation and generated latents, get the encoded latent and the loss. + + Inputs: + input_action: Batch of the actions taken in the multimodal demonstrations. + target_latents: Batch of the latents that the generator should learn to generate the actions from. + seq_masks: Batch of masks that indicate which timesteps are valid. + + Outputs: + latent: The sampled latent from the observation. + loss: The loss of the latent generator. + """ + pass + + @abc.abstractmethod + def generate_latents( + self, seq_obses: torch.Tensor, seq_masks: torch.Tensor + ) -> torch.Tensor: + """ + Given a batch of sequences of observations, generate a batch of sequences of latents. + + Inputs: + seq_obses: Batch of sequences of observations, of shape seq x batch x dim, following the transformer convention. + seq_masks: Batch of sequences of masks, of shape seq x batch, following the transformer convention. + + Outputs: + seq_latents: Batch of sequences of latents of shape seq x batch x latent_dim. + """ + pass + + def get_optimizer( + self, weight_decay: float, learning_rate: float, betas: Tuple[float, float] + ) -> torch.optim.Optimizer: + """ + Default optimizer class. Override this if you want to use a different optimizer. + """ + return torch.optim.Adam( + self.parameters(), lr=learning_rate, weight_decay=weight_decay, betas=betas + ) + + +class LatentGeneratorDataParallel(torch.nn.DataParallel): + def get_latent_and_loss(self, *args, **kwargs): + return self.module.get_latent_and_loss(*args, **kwargs) # type: ignore + + def generate_latents(self, *args, **kwargs): + return self.module.generate_latents(*args, **kwargs) # type: ignore + + def get_optimizer(self, *args, **kwargs): + return self.module.get_optimizer(*args, **kwargs) # type: ignore diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/mingpt.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/mingpt.py new file mode 100644 index 0000000000000000000000000000000000000000..4836b2791c070054dfaa68f0a76d27723dd2488f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/mingpt.py @@ -0,0 +1,188 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import einops +import diffusion_policy.model.bet.latent_generators.latent_generator as latent_generator + +import diffusion_policy.model.bet.libraries.mingpt.model as mingpt_model +import diffusion_policy.model.bet.libraries.mingpt.trainer as mingpt_trainer +from diffusion_policy.model.bet.libraries.loss_fn import FocalLoss, soft_cross_entropy + +from typing import Optional, Tuple + + +class MinGPT(latent_generator.AbstractLatentGenerator): + def __init__( + self, + input_dim: int, + n_layer: int = 12, + n_head: int = 12, + n_embd: int = 768, + embd_pdrop: float = 0.1, + resid_pdrop: float = 0.1, + attn_pdrop: float = 0.1, + block_size: int = 128, + vocab_size: int = 50257, + latent_dim: int = 768, # Ignore, used for compatibility with other models. + action_dim: int = 0, + discrete_input: bool = False, + predict_offsets: bool = False, + offset_loss_scale: float = 1.0, + focal_loss_gamma: float = 0.0, + **kwargs + ): + super().__init__() + self.input_size = input_dim + self.n_layer = n_layer + self.n_head = n_head + self.n_embd = n_embd + self.embd_pdrop = embd_pdrop + self.resid_pdrop = resid_pdrop + self.attn_pdrop = attn_pdrop + self.block_size = block_size + self.vocab_size = vocab_size + self.action_dim = action_dim + self.predict_offsets = predict_offsets + self.offset_loss_scale = offset_loss_scale + self.focal_loss_gamma = focal_loss_gamma + for k, v in kwargs.items(): + setattr(self, k, v) + + gpt_config = mingpt_model.GPTConfig( + input_size=self.input_size, + vocab_size=self.vocab_size * (1 + self.action_dim) + if self.predict_offsets + else self.vocab_size, + block_size=self.block_size, + n_layer=n_layer, + n_head=n_head, + n_embd=n_embd, + discrete_input=discrete_input, + embd_pdrop=embd_pdrop, + resid_pdrop=resid_pdrop, + attn_pdrop=attn_pdrop, + ) + + self.model = mingpt_model.GPT(gpt_config) + + def get_latent_and_loss( + self, + obs_rep: torch.Tensor, + target_latents: torch.Tensor, + seq_masks: Optional[torch.Tensor] = None, + return_loss_components: bool = False, + ) -> Tuple[torch.Tensor, torch.Tensor]: + # Unlike torch.transformers, GPT takes in batch x seq_len x embd_dim + # obs_rep = einops.rearrange(obs_rep, "seq batch embed -> batch seq embed") + # target_latents = einops.rearrange( + # target_latents, "seq batch embed -> batch seq embed" + # ) + # While this has been trained autoregressively, + # there is no reason why it needs to be so. + # We can just use the observation as the input and the next latent as the target. + if self.predict_offsets: + target_latents, target_offsets = target_latents + is_soft_target = (target_latents.shape[-1] == self.vocab_size) and ( + self.vocab_size != 1 + ) + if is_soft_target: + target_latents = target_latents.view(-1, target_latents.size(-1)) + criterion = soft_cross_entropy + else: + target_latents = target_latents.view(-1) + if self.vocab_size == 1: + # unify k-means (target_class == 0) and GMM (target_prob == 1) + target_latents = torch.zeros_like(target_latents) + criterion = FocalLoss(gamma=self.focal_loss_gamma) + if self.predict_offsets: + output, _ = self.model(obs_rep) + logits = output[:, :, : self.vocab_size] + offsets = output[:, :, self.vocab_size :] + batch = logits.shape[0] + seq = logits.shape[1] + offsets = einops.rearrange( + offsets, + "N T (V A) -> (N T) V A", # N = batch, T = seq + V=self.vocab_size, + A=self.action_dim, + ) + # calculate (optionally soft) cross entropy and offset losses + class_loss = criterion(logits.view(-1, logits.size(-1)), target_latents) + # offset loss is only calculated on the target class + # if soft targets, argmax is considered the target class + selected_offsets = offsets[ + torch.arange(offsets.size(0)), + target_latents.argmax(dim=-1).view(-1) + if is_soft_target + else target_latents.view(-1), + ] + offset_loss = self.offset_loss_scale * F.mse_loss( + selected_offsets, target_offsets.view(-1, self.action_dim) + ) + loss = offset_loss + class_loss + logits = einops.rearrange(logits, "batch seq classes -> seq batch classes") + offsets = einops.rearrange( + offsets, + "(N T) V A -> T N V A", # ? N, T order? Anyway does not affect loss and training (might affect visualization) + N=batch, + T=seq, + ) + if return_loss_components: + return ( + (logits, offsets), + loss, + {"offset": offset_loss, "class": class_loss, "total": loss}, + ) + else: + return (logits, offsets), loss + else: + logits, _ = self.model(obs_rep) + loss = criterion(logits.view(-1, logits.size(-1)), target_latents) + logits = einops.rearrange( + logits, "batch seq classes -> seq batch classes" + ) # ? N, T order? Anyway does not affect loss and training (might affect visualization) + if return_loss_components: + return logits, loss, {"class": loss, "total": loss} + else: + return logits, loss + + def generate_latents( + self, obs_rep: torch.Tensor + ) -> torch.Tensor: + batch, seq, embed = obs_rep.shape + + output, _ = self.model(obs_rep, None) + if self.predict_offsets: + logits = output[:, :, : self.vocab_size] + offsets = output[:, :, self.vocab_size :] + offsets = einops.rearrange( + offsets, + "N T (V A) -> (N T) V A", # N = batch, T = seq + V=self.vocab_size, + A=self.action_dim, + ) + else: + logits = output + probs = F.softmax(logits, dim=-1) + batch, seq, choices = probs.shape + # Sample from the multinomial distribution, one per row. + sampled_data = torch.multinomial(probs.view(-1, choices), num_samples=1) + sampled_data = einops.rearrange( + sampled_data, "(batch seq) 1 -> batch seq 1", batch=batch, seq=seq + ) + if self.predict_offsets: + sampled_offsets = offsets[ + torch.arange(offsets.shape[0]), sampled_data.flatten() + ].view(batch, seq, self.action_dim) + + return (sampled_data, sampled_offsets) + else: + return sampled_data + + def get_optimizer( + self, weight_decay: float, learning_rate: float, betas: Tuple[float, float] + ) -> torch.optim.Optimizer: + trainer_cfg = mingpt_trainer.TrainerConfig( + weight_decay=weight_decay, learning_rate=learning_rate, betas=betas + ) + return self.model.configure_optimizers(trainer_cfg) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/transformer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/transformer.py new file mode 100644 index 0000000000000000000000000000000000000000..76af7ccedfe7db8fcc9f2802b41de780fd088d2c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/latent_generators/transformer.py @@ -0,0 +1,108 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import einops +import diffusion_policy.model.bet.latent_generators.latent_generator as latent_generator + +from diffusion_policy.model.diffusion.transformer_for_diffusion import TransformerForDiffusion +from diffusion_policy.model.bet.libraries.loss_fn import FocalLoss, soft_cross_entropy + +from typing import Optional, Tuple + +class Transformer(latent_generator.AbstractLatentGenerator): + def __init__( + self, + input_dim: int, + num_bins: int, + action_dim: int, + horizon: int, + focal_loss_gamma: float, + offset_loss_scale: float, + **kwargs + ): + super().__init__() + self.model = TransformerForDiffusion( + input_dim=input_dim, + output_dim=num_bins * (1 + action_dim), + horizon=horizon, + **kwargs + ) + self.vocab_size = num_bins + self.focal_loss_gamma = focal_loss_gamma + self.offset_loss_scale = offset_loss_scale + self.action_dim = action_dim + + def get_optimizer(self, **kwargs) -> torch.optim.Optimizer: + return self.model.configure_optimizers(**kwargs) + + def get_latent_and_loss(self, + obs_rep: torch.Tensor, + target_latents: torch.Tensor, + return_loss_components=True, + ) -> Tuple[torch.Tensor, torch.Tensor]: + target_latents, target_offsets = target_latents + target_latents = target_latents.view(-1) + criterion = FocalLoss(gamma=self.focal_loss_gamma) + + t = torch.tensor(0, device=self.model.device) + output = self.model(obs_rep, t) + logits = output[:, :, : self.vocab_size] + offsets = output[:, :, self.vocab_size :] + batch = logits.shape[0] + seq = logits.shape[1] + offsets = einops.rearrange( + offsets, + "N T (V A) -> (N T) V A", # N = batch, T = seq + V=self.vocab_size, + A=self.action_dim, + ) + # calculate (optionally soft) cross entropy and offset losses + class_loss = criterion(logits.view(-1, logits.size(-1)), target_latents) + # offset loss is only calculated on the target class + # if soft targets, argmax is considered the target class + selected_offsets = offsets[ + torch.arange(offsets.size(0)), + target_latents.view(-1), + ] + offset_loss = self.offset_loss_scale * F.mse_loss( + selected_offsets, target_offsets.view(-1, self.action_dim) + ) + loss = offset_loss + class_loss + logits = einops.rearrange(logits, "batch seq classes -> seq batch classes") + offsets = einops.rearrange( + offsets, + "(N T) V A -> T N V A", # ? N, T order? Anyway does not affect loss and training (might affect visualization) + N=batch, + T=seq, + ) + return ( + (logits, offsets), + loss, + {"offset": offset_loss, "class": class_loss, "total": loss}, + ) + + def generate_latents( + self, obs_rep: torch.Tensor + ) -> torch.Tensor: + t = torch.tensor(0, device=self.model.device) + output = self.model(obs_rep, t) + logits = output[:, :, : self.vocab_size] + offsets = output[:, :, self.vocab_size :] + offsets = einops.rearrange( + offsets, + "N T (V A) -> (N T) V A", # N = batch, T = seq + V=self.vocab_size, + A=self.action_dim, + ) + + probs = F.softmax(logits, dim=-1) + batch, seq, choices = probs.shape + # Sample from the multinomial distribution, one per row. + sampled_data = torch.multinomial(probs.view(-1, choices), num_samples=1) + sampled_data = einops.rearrange( + sampled_data, "(batch seq) 1 -> batch seq 1", batch=batch, seq=seq + ) + sampled_offsets = offsets[ + torch.arange(offsets.shape[0]), sampled_data.flatten() + ].view(batch, seq, self.action_dim) + return (sampled_data, sampled_offsets) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/loss_fn.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/loss_fn.py new file mode 100644 index 0000000000000000000000000000000000000000..7873ae1b483a711087e1483c3637853bf50cc4c6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/loss_fn.py @@ -0,0 +1,168 @@ +from typing import Optional, Sequence + +import torch +from torch import Tensor +from torch import nn +from torch.nn import functional as F + +# Reference: https://github.com/pytorch/pytorch/issues/11959 +def soft_cross_entropy( + input: torch.Tensor, + target: torch.Tensor, +) -> torch.Tensor: + """ + Args: + input: (batch_size, num_classes): tensor of raw logits + target: (batch_size, num_classes): tensor of class probability; sum(target) == 1 + + Returns: + loss: (batch_size,) + """ + log_probs = torch.log_softmax(input, dim=-1) + # target is a distribution + loss = F.kl_div(log_probs, target, reduction="batchmean") + return loss + + +# Focal loss implementation +# Source: https://github.com/AdeelH/pytorch-multi-class-focal-loss/blob/master/focal_loss.py +# MIT License +# +# Copyright (c) 2020 Adeel Hassan +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +class FocalLoss(nn.Module): + """Focal Loss, as described in https://arxiv.org/abs/1708.02002. + It is essentially an enhancement to cross entropy loss and is + useful for classification tasks when there is a large class imbalance. + x is expected to contain raw, unnormalized scores for each class. + y is expected to contain class labels. + Shape: + - x: (batch_size, C) or (batch_size, C, d1, d2, ..., dK), K > 0. + - y: (batch_size,) or (batch_size, d1, d2, ..., dK), K > 0. + """ + + def __init__( + self, + alpha: Optional[Tensor] = None, + gamma: float = 0.0, + reduction: str = "mean", + ignore_index: int = -100, + ): + """Constructor. + Args: + alpha (Tensor, optional): Weights for each class. Defaults to None. + gamma (float, optional): A constant, as described in the paper. + Defaults to 0. + reduction (str, optional): 'mean', 'sum' or 'none'. + Defaults to 'mean'. + ignore_index (int, optional): class label to ignore. + Defaults to -100. + """ + if reduction not in ("mean", "sum", "none"): + raise ValueError('Reduction must be one of: "mean", "sum", "none".') + + super().__init__() + self.alpha = alpha + self.gamma = gamma + self.ignore_index = ignore_index + self.reduction = reduction + + self.nll_loss = nn.NLLLoss( + weight=alpha, reduction="none", ignore_index=ignore_index + ) + + def __repr__(self): + arg_keys = ["alpha", "gamma", "ignore_index", "reduction"] + arg_vals = [self.__dict__[k] for k in arg_keys] + arg_strs = [f"{k}={v}" for k, v in zip(arg_keys, arg_vals)] + arg_str = ", ".join(arg_strs) + return f"{type(self).__name__}({arg_str})" + + def forward(self, x: Tensor, y: Tensor) -> Tensor: + if x.ndim > 2: + # (N, C, d1, d2, ..., dK) --> (N * d1 * ... * dK, C) + c = x.shape[1] + x = x.permute(0, *range(2, x.ndim), 1).reshape(-1, c) + # (N, d1, d2, ..., dK) --> (N * d1 * ... * dK,) + y = y.view(-1) + + unignored_mask = y != self.ignore_index + y = y[unignored_mask] + if len(y) == 0: + return 0.0 + x = x[unignored_mask] + + # compute weighted cross entropy term: -alpha * log(pt) + # (alpha is already part of self.nll_loss) + log_p = F.log_softmax(x, dim=-1) + ce = self.nll_loss(log_p, y) + + # get true class column from each row + all_rows = torch.arange(len(x)) + log_pt = log_p[all_rows, y] + + # compute focal term: (1 - pt)^gamma + pt = log_pt.exp() + focal_term = (1 - pt) ** self.gamma + + # the full loss: -alpha * ((1 - pt)^gamma) * log(pt) + loss = focal_term * ce + + if self.reduction == "mean": + loss = loss.mean() + elif self.reduction == "sum": + loss = loss.sum() + + return loss + + +def focal_loss( + alpha: Optional[Sequence] = None, + gamma: float = 0.0, + reduction: str = "mean", + ignore_index: int = -100, + device="cpu", + dtype=torch.float32, +) -> FocalLoss: + """Factory function for FocalLoss. + Args: + alpha (Sequence, optional): Weights for each class. Will be converted + to a Tensor if not None. Defaults to None. + gamma (float, optional): A constant, as described in the paper. + Defaults to 0. + reduction (str, optional): 'mean', 'sum' or 'none'. + Defaults to 'mean'. + ignore_index (int, optional): class label to ignore. + Defaults to -100. + device (str, optional): Device to move alpha to. Defaults to 'cpu'. + dtype (torch.dtype, optional): dtype to cast alpha to. + Defaults to torch.float32. + Returns: + A FocalLoss object + """ + if alpha is not None: + if not isinstance(alpha, Tensor): + alpha = torch.tensor(alpha) + alpha = alpha.to(device=device, dtype=dtype) + + fl = FocalLoss( + alpha=alpha, gamma=gamma, reduction=reduction, ignore_index=ignore_index + ) + return fl diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..87bfb153c9a87537d1e21114b9fcaacdebe6a761 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/LICENSE @@ -0,0 +1,8 @@ +The MIT License (MIT) Copyright (c) 2020 Andrej Karpathy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/model.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/model.py new file mode 100644 index 0000000000000000000000000000000000000000..06308e3919b7f7d2b968551ac613ccac4e2df7ff --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/model.py @@ -0,0 +1,250 @@ +""" +GPT model: +- the initial stem consists of a combination of token encoding and a positional encoding +- the meat of it is a uniform sequence of Transformer blocks + - each Transformer is a sequential combination of a 1-hidden-layer MLP block and a self-attention block + - all blocks feed into a central residual pathway similar to resnets +- the final decoder is a linear projection into a vanilla Softmax classifier +""" + +import math +import logging + +import torch +import torch.nn as nn +from torch.nn import functional as F + +logger = logging.getLogger(__name__) + + +class GPTConfig: + """base GPT config, params common to all GPT versions""" + + embd_pdrop = 0.1 + resid_pdrop = 0.1 + attn_pdrop = 0.1 + discrete_input = False + input_size = 10 + n_embd = 768 + n_layer = 12 + + def __init__(self, vocab_size, block_size, **kwargs): + self.vocab_size = vocab_size + self.block_size = block_size + for k, v in kwargs.items(): + setattr(self, k, v) + + +class GPT1Config(GPTConfig): + """GPT-1 like network roughly 125M params""" + + n_layer = 12 + n_head = 12 + n_embd = 768 + + +class CausalSelfAttention(nn.Module): + """ + A vanilla multi-head masked self-attention layer with a projection at the end. + It is possible to use torch.nn.MultiheadAttention here but I am including an + explicit implementation here to show that there is nothing too scary here. + """ + + def __init__(self, config): + super().__init__() + assert config.n_embd % config.n_head == 0 + # key, query, value projections for all heads + self.key = nn.Linear(config.n_embd, config.n_embd) + self.query = nn.Linear(config.n_embd, config.n_embd) + self.value = nn.Linear(config.n_embd, config.n_embd) + # regularization + self.attn_drop = nn.Dropout(config.attn_pdrop) + self.resid_drop = nn.Dropout(config.resid_pdrop) + # output projection + self.proj = nn.Linear(config.n_embd, config.n_embd) + # causal mask to ensure that attention is only applied to the left in the input sequence + self.register_buffer( + "mask", + torch.tril(torch.ones(config.block_size, config.block_size)).view( + 1, 1, config.block_size, config.block_size + ), + ) + self.n_head = config.n_head + + def forward(self, x): + ( + B, + T, + C, + ) = x.size() # batch size, sequence length, embedding dimensionality (n_embd) + + # calculate query, key, values for all heads in batch and move head forward to be the batch dim + k = ( + self.key(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) + ) # (B, nh, T, hs) + q = ( + self.query(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) + ) # (B, nh, T, hs) + v = ( + self.value(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) + ) # (B, nh, T, hs) + + # causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T) + att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) + att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf")) + att = F.softmax(att, dim=-1) + att = self.attn_drop(att) + y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs) + y = ( + y.transpose(1, 2).contiguous().view(B, T, C) + ) # re-assemble all head outputs side by side + + # output projection + y = self.resid_drop(self.proj(y)) + return y + + +class Block(nn.Module): + """an unassuming Transformer block""" + + def __init__(self, config): + super().__init__() + self.ln1 = nn.LayerNorm(config.n_embd) + self.ln2 = nn.LayerNorm(config.n_embd) + self.attn = CausalSelfAttention(config) + self.mlp = nn.Sequential( + nn.Linear(config.n_embd, 4 * config.n_embd), + nn.GELU(), + nn.Linear(4 * config.n_embd, config.n_embd), + nn.Dropout(config.resid_pdrop), + ) + + def forward(self, x): + x = x + self.attn(self.ln1(x)) + x = x + self.mlp(self.ln2(x)) + return x + + +class GPT(nn.Module): + """the full GPT language model, with a context size of block_size""" + + def __init__(self, config: GPTConfig): + super().__init__() + + # input embedding stem + if config.discrete_input: + self.tok_emb = nn.Embedding(config.vocab_size, config.n_embd) + else: + self.tok_emb = nn.Linear(config.input_size, config.n_embd) + self.discrete_input = config.discrete_input + self.pos_emb = nn.Parameter(torch.zeros(1, config.block_size, config.n_embd)) + self.drop = nn.Dropout(config.embd_pdrop) + # transformer + self.blocks = nn.Sequential(*[Block(config) for _ in range(config.n_layer)]) + # decoder head + self.ln_f = nn.LayerNorm(config.n_embd) + self.head = nn.Linear(config.n_embd, config.vocab_size, bias=False) + + self.block_size = config.block_size + self.apply(self._init_weights) + + logger.info( + "number of parameters: %e", sum(p.numel() for p in self.parameters()) + ) + + def get_block_size(self): + return self.block_size + + def _init_weights(self, module): + if isinstance(module, (nn.Linear, nn.Embedding)): + torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) + if isinstance(module, nn.Linear) and module.bias is not None: + torch.nn.init.zeros_(module.bias) + elif isinstance(module, nn.LayerNorm): + torch.nn.init.zeros_(module.bias) + torch.nn.init.ones_(module.weight) + elif isinstance(module, GPT): + torch.nn.init.normal_(module.pos_emb, mean=0.0, std=0.02) + + def configure_optimizers(self, train_config): + """ + This long function is unfortunately doing something very simple and is being very defensive: + We are separating out all parameters of the model into two buckets: those that will experience + weight decay for regularization and those that won't (biases, and layernorm/embedding weights). + We are then returning the PyTorch optimizer object. + """ + + # separate out all parameters to those that will and won't experience regularizing weight decay + decay = set() + no_decay = set() + whitelist_weight_modules = (torch.nn.Linear,) + blacklist_weight_modules = (torch.nn.LayerNorm, torch.nn.Embedding) + for mn, m in self.named_modules(): + for pn, p in m.named_parameters(): + fpn = "%s.%s" % (mn, pn) if mn else pn # full param name + + if pn.endswith("bias"): + # all biases will not be decayed + no_decay.add(fpn) + elif pn.endswith("weight") and isinstance(m, whitelist_weight_modules): + # weights of whitelist modules will be weight decayed + decay.add(fpn) + elif pn.endswith("weight") and isinstance(m, blacklist_weight_modules): + # weights of blacklist modules will NOT be weight decayed + no_decay.add(fpn) + + # special case the position embedding parameter in the root GPT module as not decayed + no_decay.add("pos_emb") + + # validate that we considered every parameter + param_dict = {pn: p for pn, p in self.named_parameters()} + inter_params = decay & no_decay + union_params = decay | no_decay + assert ( + len(inter_params) == 0 + ), "parameters %s made it into both decay/no_decay sets!" % (str(inter_params),) + assert ( + len(param_dict.keys() - union_params) == 0 + ), "parameters %s were not separated into either decay/no_decay set!" % ( + str(param_dict.keys() - union_params), + ) + + # create the pytorch optimizer object + optim_groups = [ + { + "params": [param_dict[pn] for pn in sorted(list(decay))], + "weight_decay": train_config.weight_decay, + }, + { + "params": [param_dict[pn] for pn in sorted(list(no_decay))], + "weight_decay": 0.0, + }, + ] + optimizer = torch.optim.AdamW( + optim_groups, lr=train_config.learning_rate, betas=train_config.betas + ) + return optimizer + + def forward(self, idx, targets=None): + if self.discrete_input: + b, t = idx.size() + else: + b, t, dim = idx.size() + assert t <= self.block_size, "Cannot forward, model block size is exhausted." + + # forward the GPT model + token_embeddings = self.tok_emb(idx) # each index maps to a (learnable) vector + position_embeddings = self.pos_emb[ + :, :t, : + ] # each position maps to a (learnable) vector + x = self.drop(token_embeddings + position_embeddings) + x = self.blocks(x) + x = self.ln_f(x) + logits = self.head(x) + + # if we are given some desired targets also calculate the loss + loss = None + if targets is not None: + loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1)) + + return logits, loss diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/trainer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..64fec8ee98a2674c92523ff331fbfa7c54c091cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/trainer.py @@ -0,0 +1,162 @@ +""" +Simple training loop; Boilerplate that could apply to any arbitrary neural network, +so nothing in this file really has anything to do with GPT specifically. +""" + +import math +import logging + +from tqdm import tqdm +import numpy as np + +import torch +import torch.optim as optim +from torch.optim.lr_scheduler import LambdaLR +from torch.utils.data.dataloader import DataLoader + +logger = logging.getLogger(__name__) + + +class TrainerConfig: + # optimization parameters + max_epochs = 10 + batch_size = 64 + learning_rate = 3e-4 + betas = (0.9, 0.95) + grad_norm_clip = 1.0 + weight_decay = 0.1 # only applied on matmul weights + # learning rate decay params: linear warmup followed by cosine decay to 10% of original + lr_decay = False + warmup_tokens = 375e6 # these two numbers come from the GPT-3 paper, but may not be good defaults elsewhere + final_tokens = 260e9 # (at what point we reach 10% of original LR) + # checkpoint settings + ckpt_path = None + num_workers = 0 # for DataLoader + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +class Trainer: + def __init__(self, model, train_dataset, test_dataset, config): + self.model = model + self.train_dataset = train_dataset + self.test_dataset = test_dataset + self.config = config + + # take over whatever gpus are on the system + self.device = "cpu" + if torch.cuda.is_available(): + self.device = torch.cuda.current_device() + self.model = torch.nn.DataParallel(self.model).to(self.device) + + def save_checkpoint(self): + # DataParallel wrappers keep raw model object in .module attribute + raw_model = self.model.module if hasattr(self.model, "module") else self.model + logger.info("saving %s", self.config.ckpt_path) + torch.save(raw_model.state_dict(), self.config.ckpt_path) + + def train(self): + model, config = self.model, self.config + raw_model = model.module if hasattr(self.model, "module") else model + optimizer = raw_model.configure_optimizers(config) + + def run_epoch(loader, is_train): + model.train(is_train) + + losses = [] + pbar = ( + tqdm(enumerate(loader), total=len(loader)) + if is_train + else enumerate(loader) + ) + for it, (x, y) in pbar: + + # place data on the correct device + x = x.to(self.device) + y = y.to(self.device) + + # forward the model + with torch.set_grad_enabled(is_train): + logits, loss = model(x, y) + loss = ( + loss.mean() + ) # collapse all losses if they are scattered on multiple gpus + losses.append(loss.item()) + + if is_train: + + # backprop and update the parameters + model.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_( + model.parameters(), config.grad_norm_clip + ) + optimizer.step() + + # decay the learning rate based on our progress + if config.lr_decay: + self.tokens += ( + y >= 0 + ).sum() # number of tokens processed this step (i.e. label is not -100) + if self.tokens < config.warmup_tokens: + # linear warmup + lr_mult = float(self.tokens) / float( + max(1, config.warmup_tokens) + ) + else: + # cosine learning rate decay + progress = float( + self.tokens - config.warmup_tokens + ) / float( + max(1, config.final_tokens - config.warmup_tokens) + ) + lr_mult = max( + 0.1, 0.5 * (1.0 + math.cos(math.pi * progress)) + ) + lr = config.learning_rate * lr_mult + for param_group in optimizer.param_groups: + param_group["lr"] = lr + else: + lr = config.learning_rate + + # report progress + pbar.set_description( # type: ignore + f"epoch {epoch+1} iter {it}: train loss {loss.item():.5f}. lr {lr:e}" + ) + + if not is_train: + test_loss = float(np.mean(losses)) + logger.info("test loss: %f", test_loss) + return test_loss + + best_loss = float("inf") + self.tokens = 0 # counter used for learning rate decay + + train_loader = DataLoader( + self.train_dataset, + shuffle=True, + pin_memory=True, + batch_size=config.batch_size, + num_workers=config.num_workers, + ) + if self.test_dataset is not None: + test_loader = DataLoader( + self.test_dataset, + shuffle=True, + pin_memory=True, + batch_size=config.batch_size, + num_workers=config.num_workers, + ) + + for epoch in range(config.max_epochs): + run_epoch(train_loader, is_train=True) + if self.test_dataset is not None: + test_loss = run_epoch(test_loader, is_train=False) + + # supports early stopping based on the test loss, or just save always if no test set is provided + good_model = self.test_dataset is None or test_loss < best_loss + if self.config.ckpt_path is not None and good_model: + best_loss = test_loss + self.save_checkpoint() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..620b9d8c376354c72849e508f9cbe1dfc74aa7fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/libraries/mingpt/utils.py @@ -0,0 +1,52 @@ +import random +import numpy as np +import torch +import torch.nn as nn +from torch.nn import functional as F + + +def set_seed(seed): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + + +def top_k_logits(logits, k): + v, ix = torch.topk(logits, k) + out = logits.clone() + out[out < v[:, [-1]]] = -float("Inf") + return out + + +@torch.no_grad() +def sample(model, x, steps, temperature=1.0, sample=False, top_k=None): + """ + take a conditioning sequence of indices in x (of shape (b,t)) and predict the next token in + the sequence, feeding the predictions back into the model each time. Clearly the sampling + has quadratic complexity unlike an RNN that is only linear, and has a finite context window + of block_size, unlike an RNN that has an infinite context window. + """ + block_size = model.get_block_size() + model.eval() + for k in range(steps): + x_cond = ( + x if x.size(1) <= block_size else x[:, -block_size:] + ) # crop context if needed + logits, _ = model(x_cond) + # pluck the logits at the final step and scale by temperature + logits = logits[:, -1, :] / temperature + # optionally crop probabilities to only the top k options + if top_k is not None: + logits = top_k_logits(logits, top_k) + # apply softmax to convert to probabilities + probs = F.softmax(logits, dim=-1) + # sample from the distribution or take the most likely + if sample: + ix = torch.multinomial(probs, num_samples=1) + else: + _, ix = torch.topk(probs, k=1, dim=-1) + # append to the sequence and continue + x = torch.cat((x, ix), dim=1) + + return x diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e021a0b22a735ac8f7d6e20e835bdd0429734e59 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/bet/utils.py @@ -0,0 +1,131 @@ +import os +import random +from collections import OrderedDict +from typing import List, Optional + +import einops +import numpy as np +import torch +import torch.nn as nn + +from torch.utils.data import random_split +import wandb + + +def mlp(input_dim, hidden_dim, output_dim, hidden_depth, output_mod=None): + if hidden_depth == 0: + mods = [nn.Linear(input_dim, output_dim)] + else: + mods = [nn.Linear(input_dim, hidden_dim), nn.ReLU(inplace=True)] + for i in range(hidden_depth - 1): + mods += [nn.Linear(hidden_dim, hidden_dim), nn.ReLU(inplace=True)] + mods.append(nn.Linear(hidden_dim, output_dim)) + if output_mod is not None: + mods.append(output_mod) + trunk = nn.Sequential(*mods) + return trunk + + +class eval_mode: + def __init__(self, *models, no_grad=False): + self.models = models + self.no_grad = no_grad + self.no_grad_context = torch.no_grad() + + def __enter__(self): + self.prev_states = [] + for model in self.models: + self.prev_states.append(model.training) + model.train(False) + if self.no_grad: + self.no_grad_context.__enter__() + + def __exit__(self, *args): + if self.no_grad: + self.no_grad_context.__exit__(*args) + for model, state in zip(self.models, self.prev_states): + model.train(state) + return False + + +def freeze_module(module: nn.Module) -> nn.Module: + for param in module.parameters(): + param.requires_grad = False + module.eval() + return module + + +def set_seed_everywhere(seed): + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + np.random.seed(seed) + random.seed(seed) + + +def shuffle_along_axis(a, axis): + idx = np.random.rand(*a.shape).argsort(axis=axis) + return np.take_along_axis(a, idx, axis=axis) + + +def transpose_batch_timestep(*args): + return (einops.rearrange(arg, "b t ... -> t b ...") for arg in args) + + +class TrainWithLogger: + def reset_log(self): + self.log_components = OrderedDict() + + def log_append(self, log_key, length, loss_components): + for key, value in loss_components.items(): + key_name = f"{log_key}/{key}" + count, sum = self.log_components.get(key_name, (0, 0.0)) + self.log_components[key_name] = ( + count + length, + sum + (length * value.detach().cpu().item()), + ) + + def flush_log(self, epoch, iterator=None): + log_components = OrderedDict() + iterator_log_component = OrderedDict() + for key, value in self.log_components.items(): + count, sum = value + to_log = sum / count + log_components[key] = to_log + # Set the iterator status + log_key, name_key = key.split("/") + iterator_log_name = f"{log_key[0]}{name_key[0]}".upper() + iterator_log_component[iterator_log_name] = to_log + postfix = ",".join( + "{}:{:.2e}".format(key, iterator_log_component[key]) + for key in iterator_log_component.keys() + ) + if iterator is not None: + iterator.set_postfix_str(postfix) + wandb.log(log_components, step=epoch) + self.log_components = OrderedDict() + + +class SaveModule(nn.Module): + def set_snapshot_path(self, path): + self.snapshot_path = path + print(f"Setting snapshot path to {self.snapshot_path}") + + def save_snapshot(self): + os.makedirs(self.snapshot_path, exist_ok=True) + torch.save(self.state_dict(), self.snapshot_path / "snapshot.pth") + + def load_snapshot(self): + self.load_state_dict(torch.load(self.snapshot_path / "snapshot.pth")) + + +def split_datasets(dataset, train_fraction=0.95, random_seed=42): + dataset_length = len(dataset) + lengths = [ + int(train_fraction * dataset_length), + dataset_length - int(train_fraction * dataset_length), + ] + train_set, val_set = random_split( + dataset, lengths, generator=torch.Generator().manual_seed(random_seed) + ) + return train_set, val_set diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/dict_of_tensor_mixin.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/dict_of_tensor_mixin.py new file mode 100644 index 0000000000000000000000000000000000000000..9d08bc13561d43079fcb62050d2ad7c3a94b4c18 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/dict_of_tensor_mixin.py @@ -0,0 +1,38 @@ +import torch +import torch.nn as nn + +class DictOfTensorMixin(nn.Module): + def __init__(self, params_dict=None): + super().__init__() + if params_dict is None: + params_dict = nn.ParameterDict() + self.params_dict = params_dict + + @property + def device(self): + return next(iter(self.parameters())).device + + def _load_from_state_dict(self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs): + def dfs_add(dest, keys, value: torch.Tensor): + if len(keys) == 1: + dest[keys[0]] = value + return + + if keys[0] not in dest: + dest[keys[0]] = nn.ParameterDict() + dfs_add(dest[keys[0]], keys[1:], value) + + def load_dict(state_dict, prefix): + out_dict = nn.ParameterDict() + for key, value in state_dict.items(): + value: torch.Tensor + if key.startswith(prefix): + param_keys = key[len(prefix):].split('.')[1:] + # if len(param_keys) == 0: + # import pdb; pdb.set_trace() + dfs_add(out_dict, param_keys, value.clone()) + return out_dict + + self.params_dict = load_dict(state_dict, prefix + 'params_dict') + self.params_dict.requires_grad_(False) + return diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/lr_scheduler.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/lr_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..f4c053363788826e65e894a33d3e52651a41d864 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/lr_scheduler.py @@ -0,0 +1,46 @@ +from diffusers.optimization import ( + Union, SchedulerType, Optional, + Optimizer, TYPE_TO_SCHEDULER_FUNCTION +) + +def get_scheduler( + name: Union[str, SchedulerType], + optimizer: Optimizer, + num_warmup_steps: Optional[int] = None, + num_training_steps: Optional[int] = None, + **kwargs +): + """ + Added kwargs vs diffuser's original implementation + + Unified API to get any scheduler from its name. + + Args: + name (`str` or `SchedulerType`): + The name of the scheduler to use. + optimizer (`torch.optim.Optimizer`): + The optimizer that will be used during training. + num_warmup_steps (`int`, *optional*): + The number of warmup steps to do. This is not required by all schedulers (hence the argument being + optional), the function will raise an error if it's unset and the scheduler type requires it. + num_training_steps (`int``, *optional*): + The number of training steps to do. This is not required by all schedulers (hence the argument being + optional), the function will raise an error if it's unset and the scheduler type requires it. + """ + name = SchedulerType(name) + schedule_func = TYPE_TO_SCHEDULER_FUNCTION[name] + if name == SchedulerType.CONSTANT: + return schedule_func(optimizer, **kwargs) + + # All other schedulers require `num_warmup_steps` + if num_warmup_steps is None: + raise ValueError(f"{name} requires `num_warmup_steps`, please provide that argument.") + + if name == SchedulerType.CONSTANT_WITH_WARMUP: + return schedule_func(optimizer, num_warmup_steps=num_warmup_steps, **kwargs) + + # All other schedulers require `num_training_steps` + if num_training_steps is None: + raise ValueError(f"{name} requires `num_training_steps`, please provide that argument.") + + return schedule_func(optimizer, num_warmup_steps=num_warmup_steps, num_training_steps=num_training_steps, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/module_attr_mixin.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/module_attr_mixin.py new file mode 100644 index 0000000000000000000000000000000000000000..8cbdf709927984de04126c30aed349e38df9f85b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/module_attr_mixin.py @@ -0,0 +1,14 @@ +import torch.nn as nn + +class ModuleAttrMixin(nn.Module): + def __init__(self): + super().__init__() + self._dummy_variable = nn.Parameter() + + @property + def device(self): + return next(iter(self.parameters())).device + + @property + def dtype(self): + return next(iter(self.parameters())).dtype diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/normalizer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/normalizer.py new file mode 100644 index 0000000000000000000000000000000000000000..a26469290e7670d435257a1215f77d8e295cd8df --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/normalizer.py @@ -0,0 +1,353 @@ +from typing import Union, Dict + +import unittest +import zarr +import numpy as np +import torch +import torch.nn as nn +from diffusion_policy.common.pytorch_util import dict_apply +from diffusion_policy.model.common.dict_of_tensor_mixin import DictOfTensorMixin + + +class LinearNormalizer(DictOfTensorMixin): + avaliable_modes = ['limits', 'gaussian'] + + @torch.no_grad() + def fit(self, + data: Union[Dict, torch.Tensor, np.ndarray, zarr.Array], + last_n_dims=1, + dtype=torch.float32, + mode='limits', + output_max=1., + output_min=-1., + range_eps=1e-4, + fit_offset=True): + if isinstance(data, dict): + for key, value in data.items(): + self.params_dict[key] = _fit(value, + last_n_dims=last_n_dims, + dtype=dtype, + mode=mode, + output_max=output_max, + output_min=output_min, + range_eps=range_eps, + fit_offset=fit_offset) + else: + self.params_dict['_default'] = _fit(data, + last_n_dims=last_n_dims, + dtype=dtype, + mode=mode, + output_max=output_max, + output_min=output_min, + range_eps=range_eps, + fit_offset=fit_offset) + + def __call__(self, x: Union[Dict, torch.Tensor, np.ndarray]) -> torch.Tensor: + return self.normalize(x) + + def __getitem__(self, key: str): + return SingleFieldLinearNormalizer(self.params_dict[key]) + + def __setitem__(self, key: str , value: 'SingleFieldLinearNormalizer'): + self.params_dict[key] = value.params_dict + + def _normalize_impl(self, x, forward=True): + if isinstance(x, dict): + result = dict() + for key, value in x.items(): + params = self.params_dict[key] + result[key] = _normalize(value, params, forward=forward) + return result + else: + if '_default' not in self.params_dict: + raise RuntimeError("Not initialized") + params = self.params_dict['_default'] + return _normalize(x, params, forward=forward) + + def normalize(self, x: Union[Dict, torch.Tensor, np.ndarray]) -> torch.Tensor: + return self._normalize_impl(x, forward=True) + + def unnormalize(self, x: Union[Dict, torch.Tensor, np.ndarray]) -> torch.Tensor: + return self._normalize_impl(x, forward=False) + + def get_input_stats(self) -> Dict: + if len(self.params_dict) == 0: + raise RuntimeError("Not initialized") + if len(self.params_dict) == 1 and '_default' in self.params_dict: + return self.params_dict['_default']['input_stats'] + + result = dict() + for key, value in self.params_dict.items(): + if key != '_default': + result[key] = value['input_stats'] + return result + + + def get_output_stats(self, key='_default'): + input_stats = self.get_input_stats() + if 'min' in input_stats: + # no dict + return dict_apply(input_stats, self.normalize) + + result = dict() + for key, group in input_stats.items(): + this_dict = dict() + for name, value in group.items(): + this_dict[name] = self.normalize({key:value})[key] + result[key] = this_dict + return result + + +class SingleFieldLinearNormalizer(DictOfTensorMixin): + avaliable_modes = ['limits', 'gaussian'] + + @torch.no_grad() + def fit(self, + data: Union[torch.Tensor, np.ndarray, zarr.Array], + last_n_dims=1, + dtype=torch.float32, + mode='limits', + output_max=1., + output_min=-1., + range_eps=1e-4, + fit_offset=True): + self.params_dict = _fit(data, + last_n_dims=last_n_dims, + dtype=dtype, + mode=mode, + output_max=output_max, + output_min=output_min, + range_eps=range_eps, + fit_offset=fit_offset) + + @classmethod + def create_fit(cls, data: Union[torch.Tensor, np.ndarray, zarr.Array], **kwargs): + obj = cls() + obj.fit(data, **kwargs) + return obj + + @classmethod + def create_manual(cls, + scale: Union[torch.Tensor, np.ndarray], + offset: Union[torch.Tensor, np.ndarray], + input_stats_dict: Dict[str, Union[torch.Tensor, np.ndarray]]): + def to_tensor(x): + if not isinstance(x, torch.Tensor): + x = torch.from_numpy(x) + x = x.flatten() + return x + + # check + for x in [offset] + list(input_stats_dict.values()): + assert x.shape == scale.shape + assert x.dtype == scale.dtype + + params_dict = nn.ParameterDict({ + 'scale': to_tensor(scale), + 'offset': to_tensor(offset), + 'input_stats': nn.ParameterDict( + dict_apply(input_stats_dict, to_tensor)) + }) + return cls(params_dict) + + @classmethod + def create_identity(cls, dtype=torch.float32): + scale = torch.tensor([1], dtype=dtype) + offset = torch.tensor([0], dtype=dtype) + input_stats_dict = { + 'min': torch.tensor([-1], dtype=dtype), + 'max': torch.tensor([1], dtype=dtype), + 'mean': torch.tensor([0], dtype=dtype), + 'std': torch.tensor([1], dtype=dtype) + } + return cls.create_manual(scale, offset, input_stats_dict) + + def normalize(self, x: Union[torch.Tensor, np.ndarray]) -> torch.Tensor: + return _normalize(x, self.params_dict, forward=True) + + def unnormalize(self, x: Union[torch.Tensor, np.ndarray]) -> torch.Tensor: + return _normalize(x, self.params_dict, forward=False) + + def get_input_stats(self): + return self.params_dict['input_stats'] + + def get_output_stats(self): + return dict_apply(self.params_dict['input_stats'], self.normalize) + + def __call__(self, x: Union[torch.Tensor, np.ndarray]) -> torch.Tensor: + return self.normalize(x) + + + +def _fit(data: Union[torch.Tensor, np.ndarray, zarr.Array], + last_n_dims=1, + dtype=torch.float32, + mode='limits', + output_max=1., + output_min=-1., + range_eps=1e-4, + fit_offset=True): + assert mode in ['limits', 'gaussian'] + assert last_n_dims >= 0 + assert output_max > output_min + + # convert data to torch and type + if isinstance(data, zarr.Array): + data = data[:] + if isinstance(data, np.ndarray): + data = torch.from_numpy(data) + if dtype is not None: + data = data.type(dtype) + + # convert shape + dim = 1 + if last_n_dims > 0: + dim = np.prod(data.shape[-last_n_dims:]) + data = data.reshape(-1,dim) + + # compute input stats min max mean std + input_min, _ = data.min(axis=0) + input_max, _ = data.max(axis=0) + input_mean = data.mean(axis=0) + input_std = data.std(axis=0) + + # compute scale and offset + if mode == 'limits': + if fit_offset: + # unit scale + input_range = input_max - input_min + ignore_dim = input_range < range_eps + input_range[ignore_dim] = output_max - output_min + scale = (output_max - output_min) / input_range + offset = output_min - scale * input_min + offset[ignore_dim] = (output_max + output_min) / 2 - input_min[ignore_dim] + # ignore dims scaled to mean of output max and min + else: + # use this when data is pre-zero-centered. + assert output_max > 0 + assert output_min < 0 + # unit abs + output_abs = min(abs(output_min), abs(output_max)) + input_abs = torch.maximum(torch.abs(input_min), torch.abs(input_max)) + ignore_dim = input_abs < range_eps + input_abs[ignore_dim] = output_abs + # don't scale constant channels + scale = output_abs / input_abs + offset = torch.zeros_like(input_mean) + elif mode == 'gaussian': + ignore_dim = input_std < range_eps + scale = input_std.clone() + scale[ignore_dim] = 1 + scale = 1 / scale + + if fit_offset: + offset = - input_mean * scale + else: + offset = torch.zeros_like(input_mean) + + # save + this_params = nn.ParameterDict({ + 'scale': scale, + 'offset': offset, + 'input_stats': nn.ParameterDict({ + 'min': input_min, + 'max': input_max, + 'mean': input_mean, + 'std': input_std + }) + }) + for p in this_params.parameters(): + p.requires_grad_(False) + return this_params + + +def _normalize(x, params, forward=True): + assert 'scale' in params + if isinstance(x, np.ndarray): + x = torch.from_numpy(x) + scale = params['scale'] + offset = params['offset'] + x = x.to(device=scale.device, dtype=scale.dtype) + src_shape = x.shape + x = x.reshape(-1, scale.shape[0]) + if forward: + x = x * scale + offset + else: + x = (x - offset) / scale + x = x.reshape(src_shape) + return x + + +def test(): + data = torch.zeros((100,10,9,2)).uniform_() + data[...,0,0] = 0 + + normalizer = SingleFieldLinearNormalizer() + normalizer.fit(data, mode='limits', last_n_dims=2) + datan = normalizer.normalize(data) + assert datan.shape == data.shape + assert np.allclose(datan.max(), 1.) + assert np.allclose(datan.min(), -1.) + dataun = normalizer.unnormalize(datan) + assert torch.allclose(data, dataun, atol=1e-7) + + input_stats = normalizer.get_input_stats() + output_stats = normalizer.get_output_stats() + + normalizer = SingleFieldLinearNormalizer() + normalizer.fit(data, mode='limits', last_n_dims=1, fit_offset=False) + datan = normalizer.normalize(data) + assert datan.shape == data.shape + assert np.allclose(datan.max(), 1., atol=1e-3) + assert np.allclose(datan.min(), 0., atol=1e-3) + dataun = normalizer.unnormalize(datan) + assert torch.allclose(data, dataun, atol=1e-7) + + data = torch.zeros((100,10,9,2)).uniform_() + normalizer = SingleFieldLinearNormalizer() + normalizer.fit(data, mode='gaussian', last_n_dims=0) + datan = normalizer.normalize(data) + assert datan.shape == data.shape + assert np.allclose(datan.mean(), 0., atol=1e-3) + assert np.allclose(datan.std(), 1., atol=1e-3) + dataun = normalizer.unnormalize(datan) + assert torch.allclose(data, dataun, atol=1e-7) + + + # dict + data = torch.zeros((100,10,9,2)).uniform_() + data[...,0,0] = 0 + + normalizer = LinearNormalizer() + normalizer.fit(data, mode='limits', last_n_dims=2) + datan = normalizer.normalize(data) + assert datan.shape == data.shape + assert np.allclose(datan.max(), 1.) + assert np.allclose(datan.min(), -1.) + dataun = normalizer.unnormalize(datan) + assert torch.allclose(data, dataun, atol=1e-7) + + input_stats = normalizer.get_input_stats() + output_stats = normalizer.get_output_stats() + + data = { + 'obs': torch.zeros((1000,128,9,2)).uniform_() * 512, + 'action': torch.zeros((1000,128,2)).uniform_() * 512 + } + normalizer = LinearNormalizer() + normalizer.fit(data) + datan = normalizer.normalize(data) + dataun = normalizer.unnormalize(datan) + for key in data: + assert torch.allclose(data[key], dataun[key], atol=1e-4) + + input_stats = normalizer.get_input_stats() + output_stats = normalizer.get_output_stats() + + state_dict = normalizer.state_dict() + n = LinearNormalizer() + n.load_state_dict(state_dict) + datan = n.normalize(data) + dataun = n.unnormalize(datan) + for key in data: + assert torch.allclose(data[key], dataun[key], atol=1e-4) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/rotation_transformer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/rotation_transformer.py new file mode 100644 index 0000000000000000000000000000000000000000..a801d285fae5086adf9f15004a55429b00a9c9da --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/rotation_transformer.py @@ -0,0 +1,103 @@ +from typing import Union +import pytorch3d.transforms as pt +import torch +import numpy as np +import functools + +class RotationTransformer: + valid_reps = [ + 'axis_angle', + 'euler_angles', + 'quaternion', + 'rotation_6d', + 'matrix' + ] + + def __init__(self, + from_rep='axis_angle', + to_rep='rotation_6d', + from_convention=None, + to_convention=None): + """ + Valid representations + + Always use matrix as intermediate representation. + """ + assert from_rep != to_rep + assert from_rep in self.valid_reps + assert to_rep in self.valid_reps + if from_rep == 'euler_angles': + assert from_convention is not None + if to_rep == 'euler_angles': + assert to_convention is not None + + forward_funcs = list() + inverse_funcs = list() + + if from_rep != 'matrix': + funcs = [ + getattr(pt, f'{from_rep}_to_matrix'), + getattr(pt, f'matrix_to_{from_rep}') + ] + if from_convention is not None: + funcs = [functools.partial(func, convention=from_convention) + for func in funcs] + forward_funcs.append(funcs[0]) + inverse_funcs.append(funcs[1]) + + if to_rep != 'matrix': + funcs = [ + getattr(pt, f'matrix_to_{to_rep}'), + getattr(pt, f'{to_rep}_to_matrix') + ] + if to_convention is not None: + funcs = [functools.partial(func, convention=to_convention) + for func in funcs] + forward_funcs.append(funcs[0]) + inverse_funcs.append(funcs[1]) + + inverse_funcs = inverse_funcs[::-1] + + self.forward_funcs = forward_funcs + self.inverse_funcs = inverse_funcs + + @staticmethod + def _apply_funcs(x: Union[np.ndarray, torch.Tensor], funcs: list) -> Union[np.ndarray, torch.Tensor]: + x_ = x + if isinstance(x, np.ndarray): + x_ = torch.from_numpy(x) + x_: torch.Tensor + for func in funcs: + x_ = func(x_) + y = x_ + if isinstance(x, np.ndarray): + y = x_.numpy() + return y + + def forward(self, x: Union[np.ndarray, torch.Tensor] + ) -> Union[np.ndarray, torch.Tensor]: + return self._apply_funcs(x, self.forward_funcs) + + def inverse(self, x: Union[np.ndarray, torch.Tensor] + ) -> Union[np.ndarray, torch.Tensor]: + return self._apply_funcs(x, self.inverse_funcs) + + +def test(): + tf = RotationTransformer() + + rotvec = np.random.uniform(-2*np.pi,2*np.pi,size=(1000,3)) + rot6d = tf.forward(rotvec) + new_rotvec = tf.inverse(rot6d) + + from scipy.spatial.transform import Rotation + diff = Rotation.from_rotvec(rotvec) * Rotation.from_rotvec(new_rotvec).inv() + dist = diff.magnitude() + assert dist.max() < 1e-7 + + tf = RotationTransformer('rotation_6d', 'matrix') + rot6d_wrong = rot6d + np.random.normal(scale=0.1, size=rot6d.shape) + mat = tf.forward(rot6d_wrong) + mat_det = np.linalg.det(mat) + assert np.allclose(mat_det, 1) + # rotaiton_6d will be normalized to rotation matrix diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/shape_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/shape_util.py new file mode 100644 index 0000000000000000000000000000000000000000..e1786c174e6f22794faa90ef0c3d2a7d29bae873 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/shape_util.py @@ -0,0 +1,23 @@ +from typing import Dict, List, Tuple, Callable +import torch +import torch.nn as nn + +def get_module_device(m: nn.Module): + device = torch.device('cpu') + try: + param = next(iter(m.parameters())) + device = param.device + except StopIteration: + pass + return device + +@torch.no_grad() +def get_output_shape( + input_shape: Tuple[int], + net: Callable[[torch.Tensor], torch.Tensor] + ): + device = get_module_device(net) + test_input = torch.zeros((1,)+tuple(input_shape), device=device) + test_output = net(test_input) + output_shape = tuple(test_output.shape[1:]) + return output_shape diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/tensor_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/tensor_util.py new file mode 100644 index 0000000000000000000000000000000000000000..4c8451538d2ed017e462e2620d58fd13db442e34 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/common/tensor_util.py @@ -0,0 +1,980 @@ +""" +A collection of utilities for working with nested tensor structures consisting +of numpy arrays and torch tensors. +""" +import collections +import numpy as np +import torch + + +def recursive_dict_list_tuple_apply(x, type_func_dict): + """ + Recursively apply functions to a nested dictionary or list or tuple, given a dictionary of + {data_type: function_to_apply}. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + type_func_dict (dict): a mapping from data types to the functions to be + applied for each data type. + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + assert(list not in type_func_dict) + assert(tuple not in type_func_dict) + assert(dict not in type_func_dict) + + if isinstance(x, (dict, collections.OrderedDict)): + new_x = collections.OrderedDict() if isinstance(x, collections.OrderedDict) else dict() + for k, v in x.items(): + new_x[k] = recursive_dict_list_tuple_apply(v, type_func_dict) + return new_x + elif isinstance(x, (list, tuple)): + ret = [recursive_dict_list_tuple_apply(v, type_func_dict) for v in x] + if isinstance(x, tuple): + ret = tuple(ret) + return ret + else: + for t, f in type_func_dict.items(): + if isinstance(x, t): + return f(x) + else: + raise NotImplementedError( + 'Cannot handle data type %s' % str(type(x))) + + +def map_tensor(x, func): + """ + Apply function @func to torch.Tensor objects in a nested dictionary or + list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + func (function): function to apply to each tensor + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: func, + type(None): lambda x: x, + } + ) + + +def map_ndarray(x, func): + """ + Apply function @func to np.ndarray objects in a nested dictionary or + list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + func (function): function to apply to each array + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + np.ndarray: func, + type(None): lambda x: x, + } + ) + + +def map_tensor_ndarray(x, tensor_func, ndarray_func): + """ + Apply function @tensor_func to torch.Tensor objects and @ndarray_func to + np.ndarray objects in a nested dictionary or list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + tensor_func (function): function to apply to each tensor + ndarray_Func (function): function to apply to each array + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: tensor_func, + np.ndarray: ndarray_func, + type(None): lambda x: x, + } + ) + +def op_tensor(x, y, op, **op_kwargs): + """ + Apply operation @op to torch.Tensor objects in two dictionaries. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + y (dict or list or tuple): a possibly nested dictionary or list or tuple + op (function): operation to apply to each pair of tensors + + Returns: + z (dict or list or tuple): new dict + """ + z = {} + for (kx, vx), (ky, vy) in zip(flatten_nested_dict_list(x), flatten_nested_dict_list(y)): + assert kx == ky + assert isinstance(vx, torch.Tensor) + assert isinstance(vy, torch.Tensor) + z[kx] = op(vx, vy, **op_kwargs) + + return z + +def clone(x): + """ + Clones all torch tensors and numpy arrays in nested dictionary or list + or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.clone(), + np.ndarray: lambda x: x.copy(), + type(None): lambda x: x, + } + ) + + +def detach(x): + """ + Detaches all torch tensors in nested dictionary or list + or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.detach(), + } + ) + + +def to_batch(x): + """ + Introduces a leading batch dimension of 1 for all torch tensors and numpy + arrays in nested dictionary or list or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x[None, ...], + np.ndarray: lambda x: x[None, ...], + type(None): lambda x: x, + } + ) + + +def to_sequence(x): + """ + Introduces a time dimension of 1 at dimension 1 for all torch tensors and numpy + arrays in nested dictionary or list or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x[:, None, ...], + np.ndarray: lambda x: x[:, None, ...], + type(None): lambda x: x, + } + ) + + +def index_at_time(x, ind): + """ + Indexes all torch tensors and numpy arrays in dimension 1 with index @ind in + nested dictionary or list or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + ind (int): index + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x[:, ind, ...], + np.ndarray: lambda x: x[:, ind, ...], + type(None): lambda x: x, + } + ) + + +def unsqueeze(x, dim): + """ + Adds dimension of size 1 at dimension @dim in all torch tensors and numpy arrays + in nested dictionary or list or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + dim (int): dimension + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.unsqueeze(dim=dim), + np.ndarray: lambda x: np.expand_dims(x, axis=dim), + type(None): lambda x: x, + } + ) + + +def contiguous(x): + """ + Makes all torch tensors and numpy arrays contiguous in nested dictionary or + list or tuple and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.contiguous(), + np.ndarray: lambda x: np.ascontiguousarray(x), + type(None): lambda x: x, + } + ) + + +def to_device(x, device): + """ + Sends all torch tensors in nested dictionary or list or tuple to device + @device, and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + device (torch.Device): device to send tensors to + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x, d=device: x.to(d), + type(None): lambda x: x, + } + ) + + +def to_tensor(x): + """ + Converts all numpy arrays in nested dictionary or list or tuple to + torch tensors (and leaves existing torch Tensors as-is), and returns + a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x, + np.ndarray: lambda x: torch.from_numpy(x), + type(None): lambda x: x, + } + ) + + +def to_numpy(x): + """ + Converts all torch tensors in nested dictionary or list or tuple to + numpy (and leaves existing numpy arrays as-is), and returns + a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + def f(tensor): + if tensor.is_cuda: + return tensor.detach().cpu().numpy() + else: + return tensor.detach().numpy() + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: f, + np.ndarray: lambda x: x, + type(None): lambda x: x, + } + ) + + +def to_list(x): + """ + Converts all torch tensors and numpy arrays in nested dictionary or list + or tuple to a list, and returns a new nested structure. Useful for + json encoding. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + def f(tensor): + if tensor.is_cuda: + return tensor.detach().cpu().numpy().tolist() + else: + return tensor.detach().numpy().tolist() + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: f, + np.ndarray: lambda x: x.tolist(), + type(None): lambda x: x, + } + ) + + +def to_float(x): + """ + Converts all torch tensors and numpy arrays in nested dictionary or list + or tuple to float type entries, and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.float(), + np.ndarray: lambda x: x.astype(np.float32), + type(None): lambda x: x, + } + ) + + +def to_uint8(x): + """ + Converts all torch tensors and numpy arrays in nested dictionary or list + or tuple to uint8 type entries, and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.byte(), + np.ndarray: lambda x: x.astype(np.uint8), + type(None): lambda x: x, + } + ) + + +def to_torch(x, device): + """ + Converts all numpy arrays and torch tensors in nested dictionary or list or tuple to + torch tensors on device @device and returns a new nested structure. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + device (torch.Device): device to send tensors to + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return to_device(to_float(to_tensor(x)), device) + + +def to_one_hot_single(tensor, num_class): + """ + Convert tensor to one-hot representation, assuming a certain number of total class labels. + + Args: + tensor (torch.Tensor): tensor containing integer labels + num_class (int): number of classes + + Returns: + x (torch.Tensor): tensor containing one-hot representation of labels + """ + x = torch.zeros(tensor.size() + (num_class,)).to(tensor.device) + x.scatter_(-1, tensor.unsqueeze(-1), 1) + return x + + +def to_one_hot(tensor, num_class): + """ + Convert all tensors in nested dictionary or list or tuple to one-hot representation, + assuming a certain number of total class labels. + + Args: + tensor (dict or list or tuple): a possibly nested dictionary or list or tuple + num_class (int): number of classes + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return map_tensor(tensor, func=lambda x, nc=num_class: to_one_hot_single(x, nc)) + + +def flatten_single(x, begin_axis=1): + """ + Flatten a tensor in all dimensions from @begin_axis onwards. + + Args: + x (torch.Tensor): tensor to flatten + begin_axis (int): which axis to flatten from + + Returns: + y (torch.Tensor): flattened tensor + """ + fixed_size = x.size()[:begin_axis] + _s = list(fixed_size) + [-1] + return x.reshape(*_s) + + +def flatten(x, begin_axis=1): + """ + Flatten all tensors in nested dictionary or list or tuple, from @begin_axis onwards. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + begin_axis (int): which axis to flatten from + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x, b=begin_axis: flatten_single(x, begin_axis=b), + } + ) + + +def reshape_dimensions_single(x, begin_axis, end_axis, target_dims): + """ + Reshape selected dimensions in a tensor to a target dimension. + + Args: + x (torch.Tensor): tensor to reshape + begin_axis (int): begin dimension + end_axis (int): end dimension + target_dims (tuple or list): target shape for the range of dimensions + (@begin_axis, @end_axis) + + Returns: + y (torch.Tensor): reshaped tensor + """ + assert(begin_axis <= end_axis) + assert(begin_axis >= 0) + assert(end_axis < len(x.shape)) + assert(isinstance(target_dims, (tuple, list))) + s = x.shape + final_s = [] + for i in range(len(s)): + if i == begin_axis: + final_s.extend(target_dims) + elif i < begin_axis or i > end_axis: + final_s.append(s[i]) + return x.reshape(*final_s) + + +def reshape_dimensions(x, begin_axis, end_axis, target_dims): + """ + Reshape selected dimensions for all tensors in nested dictionary or list or tuple + to a target dimension. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + begin_axis (int): begin dimension + end_axis (int): end dimension + target_dims (tuple or list): target shape for the range of dimensions + (@begin_axis, @end_axis) + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x, b=begin_axis, e=end_axis, t=target_dims: reshape_dimensions_single( + x, begin_axis=b, end_axis=e, target_dims=t), + np.ndarray: lambda x, b=begin_axis, e=end_axis, t=target_dims: reshape_dimensions_single( + x, begin_axis=b, end_axis=e, target_dims=t), + type(None): lambda x: x, + } + ) + + +def join_dimensions(x, begin_axis, end_axis): + """ + Joins all dimensions between dimensions (@begin_axis, @end_axis) into a flat dimension, for + all tensors in nested dictionary or list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + begin_axis (int): begin dimension + end_axis (int): end dimension + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x, b=begin_axis, e=end_axis: reshape_dimensions_single( + x, begin_axis=b, end_axis=e, target_dims=[-1]), + np.ndarray: lambda x, b=begin_axis, e=end_axis: reshape_dimensions_single( + x, begin_axis=b, end_axis=e, target_dims=[-1]), + type(None): lambda x: x, + } + ) + + +def expand_at_single(x, size, dim): + """ + Expand a tensor at a single dimension @dim by @size + + Args: + x (torch.Tensor): input tensor + size (int): size to expand + dim (int): dimension to expand + + Returns: + y (torch.Tensor): expanded tensor + """ + assert dim < x.ndimension() + assert x.shape[dim] == 1 + expand_dims = [-1] * x.ndimension() + expand_dims[dim] = size + return x.expand(*expand_dims) + + +def expand_at(x, size, dim): + """ + Expand all tensors in nested dictionary or list or tuple at a single + dimension @dim by @size. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + size (int): size to expand + dim (int): dimension to expand + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return map_tensor(x, lambda t, s=size, d=dim: expand_at_single(t, s, d)) + + +def unsqueeze_expand_at(x, size, dim): + """ + Unsqueeze and expand a tensor at a dimension @dim by @size. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + size (int): size to expand + dim (int): dimension to unsqueeze and expand + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + x = unsqueeze(x, dim) + return expand_at(x, size, dim) + + +def repeat_by_expand_at(x, repeats, dim): + """ + Repeat a dimension by combining expand and reshape operations. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + repeats (int): number of times to repeat the target dimension + dim (int): dimension to repeat on + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + x = unsqueeze_expand_at(x, repeats, dim + 1) + return join_dimensions(x, dim, dim + 1) + + +def named_reduce_single(x, reduction, dim): + """ + Reduce tensor at a dimension by named reduction functions. + + Args: + x (torch.Tensor): tensor to be reduced + reduction (str): one of ["sum", "max", "mean", "flatten"] + dim (int): dimension to be reduced (or begin axis for flatten) + + Returns: + y (torch.Tensor): reduced tensor + """ + assert x.ndimension() > dim + assert reduction in ["sum", "max", "mean", "flatten"] + if reduction == "flatten": + x = flatten(x, begin_axis=dim) + elif reduction == "max": + x = torch.max(x, dim=dim)[0] # [B, D] + elif reduction == "sum": + x = torch.sum(x, dim=dim) + else: + x = torch.mean(x, dim=dim) + return x + + +def named_reduce(x, reduction, dim): + """ + Reduces all tensors in nested dictionary or list or tuple at a dimension + using a named reduction function. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + reduction (str): one of ["sum", "max", "mean", "flatten"] + dim (int): dimension to be reduced (or begin axis for flatten) + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return map_tensor(x, func=lambda t, r=reduction, d=dim: named_reduce_single(t, r, d)) + + +def gather_along_dim_with_dim_single(x, target_dim, source_dim, indices): + """ + This function indexes out a target dimension of a tensor in a structured way, + by allowing a different value to be selected for each member of a flat index + tensor (@indices) corresponding to a source dimension. This can be interpreted + as moving along the source dimension, using the corresponding index value + in @indices to select values for all other dimensions outside of the + source and target dimensions. A common use case is to gather values + in target dimension 1 for each batch member (target dimension 0). + + Args: + x (torch.Tensor): tensor to gather values for + target_dim (int): dimension to gather values along + source_dim (int): dimension to hold constant and use for gathering values + from the other dimensions + indices (torch.Tensor): flat index tensor with same shape as tensor @x along + @source_dim + + Returns: + y (torch.Tensor): gathered tensor, with dimension @target_dim indexed out + """ + assert len(indices.shape) == 1 + assert x.shape[source_dim] == indices.shape[0] + + # unsqueeze in all dimensions except the source dimension + new_shape = [1] * x.ndimension() + new_shape[source_dim] = -1 + indices = indices.reshape(*new_shape) + + # repeat in all dimensions - but preserve shape of source dimension, + # and make sure target_dimension has singleton dimension + expand_shape = list(x.shape) + expand_shape[source_dim] = -1 + expand_shape[target_dim] = 1 + indices = indices.expand(*expand_shape) + + out = x.gather(dim=target_dim, index=indices) + return out.squeeze(target_dim) + + +def gather_along_dim_with_dim(x, target_dim, source_dim, indices): + """ + Apply @gather_along_dim_with_dim_single to all tensors in a nested + dictionary or list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + target_dim (int): dimension to gather values along + source_dim (int): dimension to hold constant and use for gathering values + from the other dimensions + indices (torch.Tensor): flat index tensor with same shape as tensor @x along + @source_dim + + Returns: + y (dict or list or tuple): new nested dict-list-tuple + """ + return map_tensor(x, + lambda y, t=target_dim, s=source_dim, i=indices: gather_along_dim_with_dim_single(y, t, s, i)) + + +def gather_sequence_single(seq, indices): + """ + Given a tensor with leading dimensions [B, T, ...], gather an element from each sequence in + the batch given an index for each sequence. + + Args: + seq (torch.Tensor): tensor with leading dimensions [B, T, ...] + indices (torch.Tensor): tensor indices of shape [B] + + Return: + y (torch.Tensor): indexed tensor of shape [B, ....] + """ + return gather_along_dim_with_dim_single(seq, target_dim=1, source_dim=0, indices=indices) + + +def gather_sequence(seq, indices): + """ + Given a nested dictionary or list or tuple, gathers an element from each sequence of the batch + for tensors with leading dimensions [B, T, ...]. + + Args: + seq (dict or list or tuple): a possibly nested dictionary or list or tuple with tensors + of leading dimensions [B, T, ...] + indices (torch.Tensor): tensor indices of shape [B] + + Returns: + y (dict or list or tuple): new nested dict-list-tuple with tensors of shape [B, ...] + """ + return gather_along_dim_with_dim(seq, target_dim=1, source_dim=0, indices=indices) + + +def pad_sequence_single(seq, padding, batched=False, pad_same=True, pad_values=None): + """ + Pad input tensor or array @seq in the time dimension (dimension 1). + + Args: + seq (np.ndarray or torch.Tensor): sequence to be padded + padding (tuple): begin and end padding, e.g. [1, 1] pads both begin and end of the sequence by 1 + batched (bool): if sequence has the batch dimension + pad_same (bool): if pad by duplicating + pad_values (scalar or (ndarray, Tensor)): values to be padded if not pad_same + + Returns: + padded sequence (np.ndarray or torch.Tensor) + """ + assert isinstance(seq, (np.ndarray, torch.Tensor)) + assert pad_same or pad_values is not None + if pad_values is not None: + assert isinstance(pad_values, float) + repeat_func = np.repeat if isinstance(seq, np.ndarray) else torch.repeat_interleave + concat_func = np.concatenate if isinstance(seq, np.ndarray) else torch.cat + ones_like_func = np.ones_like if isinstance(seq, np.ndarray) else torch.ones_like + seq_dim = 1 if batched else 0 + + begin_pad = [] + end_pad = [] + + if padding[0] > 0: + pad = seq[[0]] if pad_same else ones_like_func(seq[[0]]) * pad_values + begin_pad.append(repeat_func(pad, padding[0], seq_dim)) + if padding[1] > 0: + pad = seq[[-1]] if pad_same else ones_like_func(seq[[-1]]) * pad_values + end_pad.append(repeat_func(pad, padding[1], seq_dim)) + + return concat_func(begin_pad + [seq] + end_pad, seq_dim) + + +def pad_sequence(seq, padding, batched=False, pad_same=True, pad_values=None): + """ + Pad a nested dictionary or list or tuple of sequence tensors in the time dimension (dimension 1). + + Args: + seq (dict or list or tuple): a possibly nested dictionary or list or tuple with tensors + of leading dimensions [B, T, ...] + padding (tuple): begin and end padding, e.g. [1, 1] pads both begin and end of the sequence by 1 + batched (bool): if sequence has the batch dimension + pad_same (bool): if pad by duplicating + pad_values (scalar or (ndarray, Tensor)): values to be padded if not pad_same + + Returns: + padded sequence (dict or list or tuple) + """ + return recursive_dict_list_tuple_apply( + seq, + { + torch.Tensor: lambda x, p=padding, b=batched, ps=pad_same, pv=pad_values: + pad_sequence_single(x, p, b, ps, pv), + np.ndarray: lambda x, p=padding, b=batched, ps=pad_same, pv=pad_values: + pad_sequence_single(x, p, b, ps, pv), + type(None): lambda x: x, + } + ) + + +def assert_size_at_dim_single(x, size, dim, msg): + """ + Ensure that array or tensor @x has size @size in dim @dim. + + Args: + x (np.ndarray or torch.Tensor): input array or tensor + size (int): size that tensors should have at @dim + dim (int): dimension to check + msg (str): text to display if assertion fails + """ + assert x.shape[dim] == size, msg + + +def assert_size_at_dim(x, size, dim, msg): + """ + Ensure that arrays and tensors in nested dictionary or list or tuple have + size @size in dim @dim. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + size (int): size that tensors should have at @dim + dim (int): dimension to check + """ + map_tensor(x, lambda t, s=size, d=dim, m=msg: assert_size_at_dim_single(t, s, d, m)) + + +def get_shape(x): + """ + Get all shapes of arrays and tensors in nested dictionary or list or tuple. + + Args: + x (dict or list or tuple): a possibly nested dictionary or list or tuple + + Returns: + y (dict or list or tuple): new nested dict-list-tuple that contains each array or + tensor's shape + """ + return recursive_dict_list_tuple_apply( + x, + { + torch.Tensor: lambda x: x.shape, + np.ndarray: lambda x: x.shape, + type(None): lambda x: x, + } + ) + + +def list_of_flat_dict_to_dict_of_list(list_of_dict): + """ + Helper function to go from a list of flat dictionaries to a dictionary of lists. + By "flat" we mean that none of the values are dictionaries, but are numpy arrays, + floats, etc. + + Args: + list_of_dict (list): list of flat dictionaries + + Returns: + dict_of_list (dict): dictionary of lists + """ + assert isinstance(list_of_dict, list) + dic = collections.OrderedDict() + for i in range(len(list_of_dict)): + for k in list_of_dict[i]: + if k not in dic: + dic[k] = [] + dic[k].append(list_of_dict[i][k]) + return dic + + +def flatten_nested_dict_list(d, parent_key='', sep='_', item_key=''): + """ + Flatten a nested dict or list to a list. + + For example, given a dict + { + a: 1 + b: { + c: 2 + } + c: 3 + } + + the function would return [(a, 1), (b_c, 2), (c, 3)] + + Args: + d (dict, list): a nested dict or list to be flattened + parent_key (str): recursion helper + sep (str): separator for nesting keys + item_key (str): recursion helper + Returns: + list: a list of (key, value) tuples + """ + items = [] + if isinstance(d, (tuple, list)): + new_key = parent_key + sep + item_key if len(parent_key) > 0 else item_key + for i, v in enumerate(d): + items.extend(flatten_nested_dict_list(v, new_key, sep=sep, item_key=str(i))) + return items + elif isinstance(d, dict): + new_key = parent_key + sep + item_key if len(parent_key) > 0 else item_key + for k, v in d.items(): + assert isinstance(k, str) + items.extend(flatten_nested_dict_list(v, new_key, sep=sep, item_key=k)) + return items + else: + new_key = parent_key + sep + item_key if len(parent_key) > 0 else item_key + return [(new_key, d)] + + +def time_distributed(inputs, op, activation=None, inputs_as_kwargs=False, inputs_as_args=False, **kwargs): + """ + Apply function @op to all tensors in nested dictionary or list or tuple @inputs in both the + batch (B) and time (T) dimension, where the tensors are expected to have shape [B, T, ...]. + Will do this by reshaping tensors to [B * T, ...], passing through the op, and then reshaping + outputs to [B, T, ...]. + + Args: + inputs (list or tuple or dict): a possibly nested dictionary or list or tuple with tensors + of leading dimensions [B, T, ...] + op: a layer op that accepts inputs + activation: activation to apply at the output + inputs_as_kwargs (bool): whether to feed input as a kwargs dict to the op + inputs_as_args (bool) whether to feed input as a args list to the op + kwargs (dict): other kwargs to supply to the op + + Returns: + outputs (dict or list or tuple): new nested dict-list-tuple with tensors of leading dimension [B, T]. + """ + batch_size, seq_len = flatten_nested_dict_list(inputs)[0][1].shape[:2] + inputs = join_dimensions(inputs, 0, 1) + if inputs_as_kwargs: + outputs = op(**inputs, **kwargs) + elif inputs_as_args: + outputs = op(*inputs, **kwargs) + else: + outputs = op(inputs, **kwargs) + + if activation is not None: + outputs = map_tensor(outputs, activation) + outputs = reshape_dimensions(outputs, begin_axis=0, end_axis=0, target_dims=(batch_size, seq_len)) + return outputs diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conditional_unet1d.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conditional_unet1d.py new file mode 100644 index 0000000000000000000000000000000000000000..fea22f5eaa25e72d3ee61ec4d7f0870edd41f4f7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conditional_unet1d.py @@ -0,0 +1,242 @@ +from typing import Union +import logging +import torch +import torch.nn as nn +import einops +from einops.layers.torch import Rearrange + +from diffusion_policy.model.diffusion.conv1d_components import ( + Downsample1d, Upsample1d, Conv1dBlock) +from diffusion_policy.model.diffusion.positional_embedding import SinusoidalPosEmb + +logger = logging.getLogger(__name__) + +class ConditionalResidualBlock1D(nn.Module): + def __init__(self, + in_channels, + out_channels, + cond_dim, + kernel_size=3, + n_groups=8, + cond_predict_scale=False): + super().__init__() + + self.blocks = nn.ModuleList([ + Conv1dBlock(in_channels, out_channels, kernel_size, n_groups=n_groups), + Conv1dBlock(out_channels, out_channels, kernel_size, n_groups=n_groups), + ]) + + # FiLM modulation https://arxiv.org/abs/1709.07871 + # predicts per-channel scale and bias + cond_channels = out_channels + if cond_predict_scale: + cond_channels = out_channels * 2 + self.cond_predict_scale = cond_predict_scale + self.out_channels = out_channels + self.cond_encoder = nn.Sequential( + nn.Mish(), + nn.Linear(cond_dim, cond_channels), + Rearrange('batch t -> batch t 1'), + ) + + # make sure dimensions compatible + self.residual_conv = nn.Conv1d(in_channels, out_channels, 1) \ + if in_channels != out_channels else nn.Identity() + + def forward(self, x, cond): + ''' + x : [ batch_size x in_channels x horizon ] + cond : [ batch_size x cond_dim] + + returns: + out : [ batch_size x out_channels x horizon ] + ''' + out = self.blocks[0](x) + embed = self.cond_encoder(cond) + if self.cond_predict_scale: + embed = embed.reshape( + embed.shape[0], 2, self.out_channels, 1) + scale = embed[:,0,...] + bias = embed[:,1,...] + out = scale * out + bias + else: + out = out + embed + out = self.blocks[1](out) + out = out + self.residual_conv(x) + return out + + +class ConditionalUnet1D(nn.Module): + def __init__(self, + input_dim, + local_cond_dim=None, + global_cond_dim=None, + diffusion_step_embed_dim=256, + down_dims=[256,512,1024], + kernel_size=3, + n_groups=8, + cond_predict_scale=False + ): + super().__init__() + all_dims = [input_dim] + list(down_dims) + start_dim = down_dims[0] + + dsed = diffusion_step_embed_dim + diffusion_step_encoder = nn.Sequential( + SinusoidalPosEmb(dsed), + nn.Linear(dsed, dsed * 4), + nn.Mish(), + nn.Linear(dsed * 4, dsed), + ) + cond_dim = dsed + if global_cond_dim is not None: + cond_dim += global_cond_dim + + in_out = list(zip(all_dims[:-1], all_dims[1:])) + + local_cond_encoder = None + if local_cond_dim is not None: + _, dim_out = in_out[0] + dim_in = local_cond_dim + local_cond_encoder = nn.ModuleList([ + # down encoder + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + # up encoder + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale) + ]) + + mid_dim = all_dims[-1] + self.mid_modules = nn.ModuleList([ + ConditionalResidualBlock1D( + mid_dim, mid_dim, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale + ), + ConditionalResidualBlock1D( + mid_dim, mid_dim, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale + ), + ]) + + down_modules = nn.ModuleList([]) + for ind, (dim_in, dim_out) in enumerate(in_out): + is_last = ind >= (len(in_out) - 1) + down_modules.append(nn.ModuleList([ + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + ConditionalResidualBlock1D( + dim_out, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + Downsample1d(dim_out) if not is_last else nn.Identity() + ])) + + up_modules = nn.ModuleList([]) + for ind, (dim_in, dim_out) in enumerate(reversed(in_out[1:])): + is_last = ind >= (len(in_out) - 1) + up_modules.append(nn.ModuleList([ + ConditionalResidualBlock1D( + dim_out*2, dim_in, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + ConditionalResidualBlock1D( + dim_in, dim_in, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + Upsample1d(dim_in) if not is_last else nn.Identity() + ])) + + final_conv = nn.Sequential( + Conv1dBlock(start_dim, start_dim, kernel_size=kernel_size), + nn.Conv1d(start_dim, input_dim, 1), + ) + + self.diffusion_step_encoder = diffusion_step_encoder + self.local_cond_encoder = local_cond_encoder + self.up_modules = up_modules + self.down_modules = down_modules + self.final_conv = final_conv + + logger.info( + "number of parameters: %e", sum(p.numel() for p in self.parameters()) + ) + + def forward(self, + sample: torch.Tensor, + timestep: Union[torch.Tensor, float, int], + local_cond=None, global_cond=None, **kwargs): + """ + x: (B,T,input_dim) + timestep: (B,) or int, diffusion step + local_cond: (B,T,local_cond_dim) + global_cond: (B,global_cond_dim) + output: (B,T,input_dim) + """ + sample = einops.rearrange(sample, 'b h t -> b t h') + + # 1. time + timesteps = timestep + if not torch.is_tensor(timesteps): + # TODO: this requires sync between CPU and GPU. So try to pass timesteps as tensors if you can + timesteps = torch.tensor([timesteps], dtype=torch.long, device=sample.device) + elif torch.is_tensor(timesteps) and len(timesteps.shape) == 0: + timesteps = timesteps[None].to(sample.device) + # broadcast to batch dimension in a way that's compatible with ONNX/Core ML + timesteps = timesteps.expand(sample.shape[0]) + + global_feature = self.diffusion_step_encoder(timesteps) + + if global_cond is not None: + global_feature = torch.cat([ + global_feature, global_cond + ], axis=-1) + + # encode local features + h_local = list() + if local_cond is not None: + local_cond = einops.rearrange(local_cond, 'b h t -> b t h') + resnet, resnet2 = self.local_cond_encoder + x = resnet(local_cond, global_feature) + h_local.append(x) + x = resnet2(local_cond, global_feature) + h_local.append(x) + + x = sample + h = [] + for idx, (resnet, resnet2, downsample) in enumerate(self.down_modules): + x = resnet(x, global_feature) + if idx == 0 and len(h_local) > 0: + x = x + h_local[0] + x = resnet2(x, global_feature) + h.append(x) + x = downsample(x) + + for mid_module in self.mid_modules: + x = mid_module(x, global_feature) + + for idx, (resnet, resnet2, upsample) in enumerate(self.up_modules): + x = torch.cat((x, h.pop()), dim=1) + x = resnet(x, global_feature) + # The correct condition should be: + # if idx == (len(self.up_modules)-1) and len(h_local) > 0: + # However this change will break compatibility with published checkpoints. + # Therefore it is left as a comment. + if idx == len(self.up_modules) and len(h_local) > 0: + x = x + h_local[1] + x = resnet2(x, global_feature) + x = upsample(x) + + x = self.final_conv(x) + + x = einops.rearrange(x, 'b t h -> b h t') + return x + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conv1d_components.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conv1d_components.py new file mode 100644 index 0000000000000000000000000000000000000000..1c4cfc92550a753ab6bb0e9de958ba31f08f5146 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/conv1d_components.py @@ -0,0 +1,46 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +# from einops.layers.torch import Rearrange + + +class Downsample1d(nn.Module): + def __init__(self, dim): + super().__init__() + self.conv = nn.Conv1d(dim, dim, 3, 2, 1) + + def forward(self, x): + return self.conv(x) + +class Upsample1d(nn.Module): + def __init__(self, dim): + super().__init__() + self.conv = nn.ConvTranspose1d(dim, dim, 4, 2, 1) + + def forward(self, x): + return self.conv(x) + +class Conv1dBlock(nn.Module): + ''' + Conv1d --> GroupNorm --> Mish + ''' + + def __init__(self, inp_channels, out_channels, kernel_size, n_groups=8): + super().__init__() + + self.block = nn.Sequential( + nn.Conv1d(inp_channels, out_channels, kernel_size, padding=kernel_size // 2), + # Rearrange('batch channels horizon -> batch channels 1 horizon'), + nn.GroupNorm(n_groups, out_channels), + # Rearrange('batch channels 1 horizon -> batch channels horizon'), + nn.Mish(), + ) + + def forward(self, x): + return self.block(x) + + +def test(): + cb = Conv1dBlock(256, 128, kernel_size=3) + x = torch.zeros((1,256,16)) + o = cb(x) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/ema_model.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/ema_model.py new file mode 100644 index 0000000000000000000000000000000000000000..d916c2b12e5da1fc7c0850d40690970df8342877 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/ema_model.py @@ -0,0 +1,88 @@ +import copy +import torch +from torch.nn.modules.batchnorm import _BatchNorm + +class EMAModel: + """ + Exponential Moving Average of models weights + """ + + def __init__( + self, + model, + update_after_step=0, + inv_gamma=1.0, + power=2 / 3, + min_value=0.0, + max_value=0.9999 + ): + """ + @crowsonkb's notes on EMA Warmup: + If gamma=1 and power=1, implements a simple average. gamma=1, power=2/3 are good values for models you plan + to train for a million or more steps (reaches decay factor 0.999 at 31.6K steps, 0.9999 at 1M steps), + gamma=1, power=3/4 for models you plan to train for less (reaches decay factor 0.999 at 10K steps, 0.9999 + at 215.4k steps). + Args: + inv_gamma (float): Inverse multiplicative factor of EMA warmup. Default: 1. + power (float): Exponential factor of EMA warmup. Default: 2/3. + min_value (float): The minimum EMA decay rate. Default: 0. + """ + + self.averaged_model = model + self.averaged_model.eval() + self.averaged_model.requires_grad_(False) + + self.update_after_step = update_after_step + self.inv_gamma = inv_gamma + self.power = power + self.min_value = min_value + self.max_value = max_value + + self.decay = 0.0 + self.optimization_step = 0 + + def get_decay(self, optimization_step): + """ + Compute the decay factor for the exponential moving average. + """ + step = max(0, optimization_step - self.update_after_step - 1) + value = 1 - (1 + step / self.inv_gamma) ** -self.power + + if step <= 0: + return 0.0 + + return max(self.min_value, min(value, self.max_value)) + + @torch.no_grad() + def step(self, new_model): + self.decay = self.get_decay(self.optimization_step) + + # old_all_dataptrs = set() + # for param in new_model.parameters(): + # data_ptr = param.data_ptr() + # if data_ptr != 0: + # old_all_dataptrs.add(data_ptr) + + all_dataptrs = set() + for module, ema_module in zip(new_model.modules(), self.averaged_model.modules()): + for param, ema_param in zip(module.parameters(recurse=False), ema_module.parameters(recurse=False)): + # iterative over immediate parameters only. + if isinstance(param, dict): + raise RuntimeError('Dict parameter not supported') + + # data_ptr = param.data_ptr() + # if data_ptr != 0: + # all_dataptrs.add(data_ptr) + + if isinstance(module, _BatchNorm): + # skip batchnorms + ema_param.copy_(param.to(dtype=ema_param.dtype).data) + elif not param.requires_grad: + ema_param.copy_(param.to(dtype=ema_param.dtype).data) + else: + ema_param.mul_(self.decay) + ema_param.add_(param.data.to(dtype=ema_param.dtype), alpha=1 - self.decay) + + # verify that iterating over module and then parameters is identical to parameters recursively. + # assert old_all_dataptrs == all_dataptrs + self.optimization_step += 1 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/mask_generator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/mask_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..fc5bb7d467ab352c35f236c4c17043459f732937 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/mask_generator.py @@ -0,0 +1,221 @@ +from typing import Sequence, Optional +import torch +from torch import nn +from diffusion_policy.model.common.module_attr_mixin import ModuleAttrMixin + + +def get_intersection_slice_mask( + shape: tuple, + dim_slices: Sequence[slice], + device: Optional[torch.device]=None + ): + assert(len(shape) == len(dim_slices)) + mask = torch.zeros(size=shape, dtype=torch.bool, device=device) + mask[dim_slices] = True + return mask + + +def get_union_slice_mask( + shape: tuple, + dim_slices: Sequence[slice], + device: Optional[torch.device]=None + ): + assert(len(shape) == len(dim_slices)) + mask = torch.zeros(size=shape, dtype=torch.bool, device=device) + for i in range(len(dim_slices)): + this_slices = [slice(None)] * len(shape) + this_slices[i] = dim_slices[i] + mask[this_slices] = True + return mask + + +class DummyMaskGenerator(ModuleAttrMixin): + def __init__(self): + super().__init__() + + @torch.no_grad() + def forward(self, shape): + device = self.device + mask = torch.ones(size=shape, dtype=torch.bool, device=device) + return mask + + +class LowdimMaskGenerator(ModuleAttrMixin): + def __init__(self, + action_dim, obs_dim, + # obs mask setup + max_n_obs_steps=2, + fix_obs_steps=True, + # action mask + action_visible=False + ): + super().__init__() + self.action_dim = action_dim + self.obs_dim = obs_dim + self.max_n_obs_steps = max_n_obs_steps + self.fix_obs_steps = fix_obs_steps + self.action_visible = action_visible + + @torch.no_grad() + def forward(self, shape, seed=None): + device = self.device + B, T, D = shape + assert D == (self.action_dim + self.obs_dim) + + # create all tensors on this device + rng = torch.Generator(device=device) + if seed is not None: + rng = rng.manual_seed(seed) + + # generate dim mask + dim_mask = torch.zeros(size=shape, + dtype=torch.bool, device=device) + is_action_dim = dim_mask.clone() + is_action_dim[...,:self.action_dim] = True + is_obs_dim = ~is_action_dim + + # generate obs mask + if self.fix_obs_steps: + obs_steps = torch.full((B,), + fill_value=self.max_n_obs_steps, device=device) + else: + obs_steps = torch.randint( + low=1, high=self.max_n_obs_steps+1, + size=(B,), generator=rng, device=device) + + steps = torch.arange(0, T, device=device).reshape(1,T).expand(B,T) + obs_mask = (steps.T < obs_steps).T.reshape(B,T,1).expand(B,T,D) + obs_mask = obs_mask & is_obs_dim + + # generate action mask + if self.action_visible: + action_steps = torch.maximum( + obs_steps - 1, + torch.tensor(0, + dtype=obs_steps.dtype, + device=obs_steps.device)) + action_mask = (steps.T < action_steps).T.reshape(B,T,1).expand(B,T,D) + action_mask = action_mask & is_action_dim + + mask = obs_mask + if self.action_visible: + mask = mask | action_mask + + return mask + + +class KeypointMaskGenerator(ModuleAttrMixin): + def __init__(self, + # dimensions + action_dim, keypoint_dim, + # obs mask setup + max_n_obs_steps=2, fix_obs_steps=True, + # keypoint mask setup + keypoint_visible_rate=0.7, time_independent=False, + # action mask + action_visible=False, + context_dim=0, # dim for context + n_context_steps=1 + ): + super().__init__() + self.action_dim = action_dim + self.keypoint_dim = keypoint_dim + self.context_dim = context_dim + self.max_n_obs_steps = max_n_obs_steps + self.fix_obs_steps = fix_obs_steps + self.keypoint_visible_rate = keypoint_visible_rate + self.time_independent = time_independent + self.action_visible = action_visible + self.n_context_steps = n_context_steps + + @torch.no_grad() + def forward(self, shape, seed=None): + device = self.device + B, T, D = shape + all_keypoint_dims = D - self.action_dim - self.context_dim + n_keypoints = all_keypoint_dims // self.keypoint_dim + + # create all tensors on this device + rng = torch.Generator(device=device) + if seed is not None: + rng = rng.manual_seed(seed) + + # generate dim mask + dim_mask = torch.zeros(size=shape, + dtype=torch.bool, device=device) + is_action_dim = dim_mask.clone() + is_action_dim[...,:self.action_dim] = True + is_context_dim = dim_mask.clone() + if self.context_dim > 0: + is_context_dim[...,-self.context_dim:] = True + is_obs_dim = ~(is_action_dim | is_context_dim) + # assumption trajectory=cat([action, keypoints, context], dim=-1) + + # generate obs mask + if self.fix_obs_steps: + obs_steps = torch.full((B,), + fill_value=self.max_n_obs_steps, device=device) + else: + obs_steps = torch.randint( + low=1, high=self.max_n_obs_steps+1, + size=(B,), generator=rng, device=device) + + steps = torch.arange(0, T, device=device).reshape(1,T).expand(B,T) + obs_mask = (steps.T < obs_steps).T.reshape(B,T,1).expand(B,T,D) + obs_mask = obs_mask & is_obs_dim + + # generate action mask + if self.action_visible: + action_steps = torch.maximum( + obs_steps - 1, + torch.tensor(0, + dtype=obs_steps.dtype, + device=obs_steps.device)) + action_mask = (steps.T < action_steps).T.reshape(B,T,1).expand(B,T,D) + action_mask = action_mask & is_action_dim + + # generate keypoint mask + if self.time_independent: + visible_kps = torch.rand(size=(B, T, n_keypoints), + generator=rng, device=device) < self.keypoint_visible_rate + visible_dims = torch.repeat_interleave(visible_kps, repeats=self.keypoint_dim, dim=-1) + visible_dims_mask = torch.cat([ + torch.ones((B, T, self.action_dim), + dtype=torch.bool, device=device), + visible_dims, + torch.ones((B, T, self.context_dim), + dtype=torch.bool, device=device), + ], axis=-1) + keypoint_mask = visible_dims_mask + else: + visible_kps = torch.rand(size=(B,n_keypoints), + generator=rng, device=device) < self.keypoint_visible_rate + visible_dims = torch.repeat_interleave(visible_kps, repeats=self.keypoint_dim, dim=-1) + visible_dims_mask = torch.cat([ + torch.ones((B, self.action_dim), + dtype=torch.bool, device=device), + visible_dims, + torch.ones((B, self.context_dim), + dtype=torch.bool, device=device), + ], axis=-1) + keypoint_mask = visible_dims_mask.reshape(B,1,D).expand(B,T,D) + keypoint_mask = keypoint_mask & is_obs_dim + + # generate context mask + context_mask = is_context_dim.clone() + context_mask[:,self.n_context_steps:,:] = False + + mask = obs_mask & keypoint_mask + if self.action_visible: + mask = mask | action_mask + if self.context_dim > 0: + mask = mask | context_mask + + return mask + + +def test(): + # kmg = KeypointMaskGenerator(2,2, random_obs_steps=True) + # self = KeypointMaskGenerator(2,2,context_dim=2, action_visible=True) + # self = KeypointMaskGenerator(2,2,context_dim=0, action_visible=True) + self = LowdimMaskGenerator(2,20, max_n_obs_steps=3, action_visible=True) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/positional_embedding.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/positional_embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..fe7fdcd3f7962e9e81fba23d117f21e18298b46e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/positional_embedding.py @@ -0,0 +1,17 @@ +import math +import torch +import torch.nn as nn + +class SinusoidalPosEmb(nn.Module): + def __init__(self, dim): + super().__init__() + self.dim = dim + + def forward(self, x): + device = x.device + half_dim = self.dim // 2 + emb = math.log(10000) / (half_dim - 1) + emb = torch.exp(torch.arange(half_dim, device=device) * -emb) + emb = x[:, None] * emb[None, :] + emb = torch.cat((emb.sin(), emb.cos()), dim=-1) + return emb diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/transformer_for_diffusion.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/transformer_for_diffusion.py new file mode 100644 index 0000000000000000000000000000000000000000..2c533c3d3c09c2e790f22060444e76e7a0fb002d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/transformer_for_diffusion.py @@ -0,0 +1,418 @@ +from typing import Union, Optional, Tuple +import logging +import torch +import torch.nn as nn +from diffusion_policy.model.diffusion.positional_embedding import SinusoidalPosEmb +from diffusion_policy.model.common.module_attr_mixin import ModuleAttrMixin + +logger = logging.getLogger(__name__) + +class TransformerForDiffusion(ModuleAttrMixin): + def __init__(self, + input_dim: int, + output_dim: int, + horizon: int, + n_obs_steps: int = None, + cond_dim: int = 0, + n_layer: int = 12, + n_head: int = 12, + n_emb: int = 768, + p_drop_emb: float = 0.1, + p_drop_attn: float = 0.1, + causal_attn: bool=False, + time_as_cond: bool=True, + obs_as_cond: bool=False, + n_cond_layers: int = 0 + ) -> None: + super().__init__() + + # compute number of tokens for main trunk and condition encoder + if n_obs_steps is None: + n_obs_steps = horizon + + T = horizon + T_cond = 1 + if not time_as_cond: + T += 1 + T_cond -= 1 + obs_as_cond = cond_dim > 0 + if obs_as_cond: + assert time_as_cond + T_cond += n_obs_steps + + # input embedding stem + self.input_emb = nn.Linear(input_dim, n_emb) + self.pos_emb = nn.Parameter(torch.zeros(1, T, n_emb)) + self.drop = nn.Dropout(p_drop_emb) + + # cond encoder + self.time_emb = SinusoidalPosEmb(n_emb) + self.cond_obs_emb = None + + if obs_as_cond: + self.cond_obs_emb = nn.Linear(cond_dim, n_emb) + + self.cond_pos_emb = None + self.encoder = None + self.decoder = None + encoder_only = False + if T_cond > 0: + self.cond_pos_emb = nn.Parameter(torch.zeros(1, T_cond, n_emb)) + if n_cond_layers > 0: + encoder_layer = nn.TransformerEncoderLayer( + d_model=n_emb, + nhead=n_head, + dim_feedforward=4*n_emb, + dropout=p_drop_attn, + activation='gelu', + batch_first=True, + norm_first=True + ) + self.encoder = nn.TransformerEncoder( + encoder_layer=encoder_layer, + num_layers=n_cond_layers + ) + else: + self.encoder = nn.Sequential( + nn.Linear(n_emb, 4 * n_emb), + nn.Mish(), + nn.Linear(4 * n_emb, n_emb) + ) + # decoder + decoder_layer = nn.TransformerDecoderLayer( + d_model=n_emb, + nhead=n_head, + dim_feedforward=4*n_emb, + dropout=p_drop_attn, + activation='gelu', + batch_first=True, + norm_first=True # important for stability + ) + self.decoder = nn.TransformerDecoder( + decoder_layer=decoder_layer, + num_layers=n_layer + ) + else: + # encoder only BERT + encoder_only = True + + encoder_layer = nn.TransformerEncoderLayer( + d_model=n_emb, + nhead=n_head, + dim_feedforward=4*n_emb, + dropout=p_drop_attn, + activation='gelu', + batch_first=True, + norm_first=True + ) + self.encoder = nn.TransformerEncoder( + encoder_layer=encoder_layer, + num_layers=n_layer + ) + + # attention mask + if causal_attn: + # causal mask to ensure that attention is only applied to the left in the input sequence + # torch.nn.Transformer uses additive mask as opposed to multiplicative mask in minGPT + # therefore, the upper triangle should be -inf and others (including diag) should be 0. + sz = T + mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1) + mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0)) + self.register_buffer("mask", mask) + + if time_as_cond and obs_as_cond: + S = T_cond + t, s = torch.meshgrid( + torch.arange(T), + torch.arange(S), + indexing='ij' + ) + mask = t >= (s-1) # add one dimension since time is the first token in cond + mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0)) + self.register_buffer('memory_mask', mask) + else: + self.memory_mask = None + else: + self.mask = None + self.memory_mask = None + + # decoder head + self.ln_f = nn.LayerNorm(n_emb) + self.head = nn.Linear(n_emb, output_dim) + + # constants + self.T = T + self.T_cond = T_cond + self.horizon = horizon + self.time_as_cond = time_as_cond + self.obs_as_cond = obs_as_cond + self.encoder_only = encoder_only + + # init + self.apply(self._init_weights) + logger.info( + "number of parameters: %e", sum(p.numel() for p in self.parameters()) + ) + + def _init_weights(self, module): + ignore_types = (nn.Dropout, + SinusoidalPosEmb, + nn.TransformerEncoderLayer, + nn.TransformerDecoderLayer, + nn.TransformerEncoder, + nn.TransformerDecoder, + nn.ModuleList, + nn.Mish, + nn.Sequential) + if isinstance(module, (nn.Linear, nn.Embedding)): + torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) + if isinstance(module, nn.Linear) and module.bias is not None: + torch.nn.init.zeros_(module.bias) + elif isinstance(module, nn.MultiheadAttention): + weight_names = [ + 'in_proj_weight', 'q_proj_weight', 'k_proj_weight', 'v_proj_weight'] + for name in weight_names: + weight = getattr(module, name) + if weight is not None: + torch.nn.init.normal_(weight, mean=0.0, std=0.02) + + bias_names = ['in_proj_bias', 'bias_k', 'bias_v'] + for name in bias_names: + bias = getattr(module, name) + if bias is not None: + torch.nn.init.zeros_(bias) + elif isinstance(module, nn.LayerNorm): + torch.nn.init.zeros_(module.bias) + torch.nn.init.ones_(module.weight) + elif isinstance(module, TransformerForDiffusion): + torch.nn.init.normal_(module.pos_emb, mean=0.0, std=0.02) + if module.cond_obs_emb is not None: + torch.nn.init.normal_(module.cond_pos_emb, mean=0.0, std=0.02) + elif isinstance(module, ignore_types): + # no param + pass + else: + raise RuntimeError("Unaccounted module {}".format(module)) + + def get_optim_groups(self, weight_decay: float=1e-3): + """ + This long function is unfortunately doing something very simple and is being very defensive: + We are separating out all parameters of the model into two buckets: those that will experience + weight decay for regularization and those that won't (biases, and layernorm/embedding weights). + We are then returning the PyTorch optimizer object. + """ + + # separate out all parameters to those that will and won't experience regularizing weight decay + decay = set() + no_decay = set() + whitelist_weight_modules = (torch.nn.Linear, torch.nn.MultiheadAttention) + blacklist_weight_modules = (torch.nn.LayerNorm, torch.nn.Embedding) + for mn, m in self.named_modules(): + for pn, p in m.named_parameters(): + fpn = "%s.%s" % (mn, pn) if mn else pn # full param name + + if pn.endswith("bias"): + # all biases will not be decayed + no_decay.add(fpn) + elif pn.startswith("bias"): + # MultiheadAttention bias starts with "bias" + no_decay.add(fpn) + elif pn.endswith("weight") and isinstance(m, whitelist_weight_modules): + # weights of whitelist modules will be weight decayed + decay.add(fpn) + elif pn.endswith("weight") and isinstance(m, blacklist_weight_modules): + # weights of blacklist modules will NOT be weight decayed + no_decay.add(fpn) + + # special case the position embedding parameter in the root GPT module as not decayed + no_decay.add("pos_emb") + no_decay.add("_dummy_variable") + if self.cond_pos_emb is not None: + no_decay.add("cond_pos_emb") + + # validate that we considered every parameter + param_dict = {pn: p for pn, p in self.named_parameters()} + inter_params = decay & no_decay + union_params = decay | no_decay + assert ( + len(inter_params) == 0 + ), "parameters %s made it into both decay/no_decay sets!" % (str(inter_params),) + assert ( + len(param_dict.keys() - union_params) == 0 + ), "parameters %s were not separated into either decay/no_decay set!" % ( + str(param_dict.keys() - union_params), + ) + + # create the pytorch optimizer object + optim_groups = [ + { + "params": [param_dict[pn] for pn in sorted(list(decay))], + "weight_decay": weight_decay, + }, + { + "params": [param_dict[pn] for pn in sorted(list(no_decay))], + "weight_decay": 0.0, + }, + ] + return optim_groups + + + def configure_optimizers(self, + learning_rate: float=1e-4, + weight_decay: float=1e-3, + betas: Tuple[float, float]=(0.9,0.95)): + optim_groups = self.get_optim_groups(weight_decay=weight_decay) + optimizer = torch.optim.AdamW( + optim_groups, lr=learning_rate, betas=betas + ) + return optimizer + + def forward(self, + sample: torch.Tensor, + timestep: Union[torch.Tensor, float, int], + cond: Optional[torch.Tensor]=None, **kwargs): + """ + x: (B,T,input_dim) + timestep: (B,) or int, diffusion step + cond: (B,T',cond_dim) + output: (B,T,input_dim) + """ + # 1. time + timesteps = timestep + if not torch.is_tensor(timesteps): + # TODO: this requires sync between CPU and GPU. So try to pass timesteps as tensors if you can + timesteps = torch.tensor([timesteps], dtype=torch.long, device=sample.device) + elif torch.is_tensor(timesteps) and len(timesteps.shape) == 0: + timesteps = timesteps[None].to(sample.device) + # broadcast to batch dimension in a way that's compatible with ONNX/Core ML + timesteps = timesteps.expand(sample.shape[0]) + time_emb = self.time_emb(timesteps).unsqueeze(1) + # (B,1,n_emb) + + # process input + input_emb = self.input_emb(sample) + + if self.encoder_only: + # BERT + token_embeddings = torch.cat([time_emb, input_emb], dim=1) + t = token_embeddings.shape[1] + position_embeddings = self.pos_emb[ + :, :t, : + ] # each position maps to a (learnable) vector + x = self.drop(token_embeddings + position_embeddings) + # (B,T+1,n_emb) + x = self.encoder(src=x, mask=self.mask) + # (B,T+1,n_emb) + x = x[:,1:,:] + # (B,T,n_emb) + else: + # encoder + cond_embeddings = time_emb + if self.obs_as_cond: + cond_obs_emb = self.cond_obs_emb(cond) + # (B,To,n_emb) + cond_embeddings = torch.cat([cond_embeddings, cond_obs_emb], dim=1) + tc = cond_embeddings.shape[1] + position_embeddings = self.cond_pos_emb[ + :, :tc, : + ] # each position maps to a (learnable) vector + x = self.drop(cond_embeddings + position_embeddings) + x = self.encoder(x) + memory = x + # (B,T_cond,n_emb) + + # decoder + token_embeddings = input_emb + t = token_embeddings.shape[1] + position_embeddings = self.pos_emb[ + :, :t, : + ] # each position maps to a (learnable) vector + x = self.drop(token_embeddings + position_embeddings) + # (B,T,n_emb) + x = self.decoder( + tgt=x, + memory=memory, + tgt_mask=self.mask, + memory_mask=self.memory_mask + ) + # (B,T,n_emb) + + # head + x = self.ln_f(x) + x = self.head(x) + # (B,T,n_out) + return x + + +def test(): + # GPT with time embedding + transformer = TransformerForDiffusion( + input_dim=16, + output_dim=16, + horizon=8, + n_obs_steps=4, + # cond_dim=10, + causal_attn=True, + # time_as_cond=False, + # n_cond_layers=4 + ) + opt = transformer.configure_optimizers() + + timestep = torch.tensor(0) + sample = torch.zeros((4,8,16)) + out = transformer(sample, timestep) + + + # GPT with time embedding and obs cond + transformer = TransformerForDiffusion( + input_dim=16, + output_dim=16, + horizon=8, + n_obs_steps=4, + cond_dim=10, + causal_attn=True, + # time_as_cond=False, + # n_cond_layers=4 + ) + opt = transformer.configure_optimizers() + + timestep = torch.tensor(0) + sample = torch.zeros((4,8,16)) + cond = torch.zeros((4,4,10)) + out = transformer(sample, timestep, cond) + + # GPT with time embedding and obs cond and encoder + transformer = TransformerForDiffusion( + input_dim=16, + output_dim=16, + horizon=8, + n_obs_steps=4, + cond_dim=10, + causal_attn=True, + # time_as_cond=False, + n_cond_layers=4 + ) + opt = transformer.configure_optimizers() + + timestep = torch.tensor(0) + sample = torch.zeros((4,8,16)) + cond = torch.zeros((4,4,10)) + out = transformer(sample, timestep, cond) + + # BERT with time embedding token + transformer = TransformerForDiffusion( + input_dim=16, + output_dim=16, + horizon=8, + n_obs_steps=4, + # cond_dim=10, + # causal_attn=True, + time_as_cond=False, + # n_cond_layers=4 + ) + opt = transformer.configure_optimizers() + + timestep = torch.tensor(0) + sample = torch.zeros((4,8,16)) + out = transformer(sample, timestep) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/value_classifier.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/value_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..dc4c28fe3e339b3f95b7bcc12bc0369945aa5ee1 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/diffusion/value_classifier.py @@ -0,0 +1,242 @@ +from typing import Union, List +import logging +import torch +import torch.nn as nn +import numpy as np +import einops +from einops.layers.torch import Rearrange + +from diffusion_policy.model.diffusion.conv1d_components import ( + Downsample1d, Conv1dBlock) +from diffusion_policy.model.diffusion.conditional_unet1d import ( + ConditionalResidualBlock1D) +from diffusion_policy.model.diffusion.positional_embedding import SinusoidalPosEmb +from diffusion_policy.model.diffusion.conditional_unet1d import ( + ConditionalUnet1D) + +import dgl +import pdb +from diffusion_policy.common.ast_builder import ASTBuilder +from diffusion_policy.common.ltl_parser import LTLParser, LTLParseError +from diffusion_policy.model.gnns.graphs.RGCN import RGCNRootShared +from diffusion_policy.model.gnns.graphs.GNN import GNNMaker + +logger = logging.getLogger(__name__) + + +class ConditionalValueUnet1D(nn.Module): + def __init__(self, + horizon, + input_dim, + local_cond_dim=None, + global_cond_dim=None, + diffusion_step_embed_dim=256, + down_dims=[256,512,1024], + kernel_size=3, + n_groups=8, + cond_predict_scale=False, + ltl_embed_input_dim=22, + ltl_embed_output_dim=32, + ltl_embed_hidden_dim=32, + ltl_embed_num_layers=8, + propositions: List[str]=None, + output = print, + legacy=True, + ): + super().__init__() + all_dims = [input_dim] + list(down_dims) + start_dim = down_dims[0] + + dsed = diffusion_step_embed_dim + diffusion_step_encoder = nn.Sequential( + SinusoidalPosEmb(dsed), + nn.Linear(dsed, dsed * 4), + nn.Mish(), + nn.Linear(dsed * 4, dsed), + ) + cond_dim = dsed + if global_cond_dim is not None: + cond_dim += global_cond_dim + + in_out = list(zip(all_dims[:-1], all_dims[1:])) + + local_cond_encoder = None + if local_cond_dim is not None: + _, dim_out = in_out[0] + dim_in = local_cond_dim + local_cond_encoder = nn.ModuleList([ + # down encoder + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + # up encoder + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale) + ]) + + down_modules = nn.ModuleList([]) + for ind, (dim_in, dim_out) in enumerate(in_out): + is_last = ind >= (len(in_out) - 1) + down_modules.append(nn.ModuleList([ + ConditionalResidualBlock1D( + dim_in, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + ConditionalResidualBlock1D( + dim_out, dim_out, cond_dim=cond_dim, + kernel_size=kernel_size, n_groups=n_groups, + cond_predict_scale=cond_predict_scale), + Downsample1d(dim_out) if not is_last else nn.Identity() + ])) + + # final_conv = nn.Sequential( + # Conv1dBlock(start_dim, start_dim, kernel_size=kernel_size), + # nn.Conv1d(start_dim, input_dim, 1), + # ) + + # fc_dim = down_dims[-1] * max(horizon, 1) + fc_dim = down_dims[-1] + + self.ltl_embed_output_dim = ltl_embed_output_dim + self.gnn = RGCNRootShared( + ltl_embed_input_dim, + ltl_embed_output_dim, + hidden_dim=ltl_embed_hidden_dim, + num_layers=ltl_embed_num_layers + ) + + if not legacy: + propositions = list(set(list(propositions+['0', '1', 'True', 'False']))) + self.str2tup_converter = LTLParser(propositions=propositions) + self.graph_builder = ASTBuilder(propositions=propositions) + + self.final_block = nn.Sequential( + nn.Linear(fc_dim + ltl_embed_output_dim + cond_dim, fc_dim // 2), + nn.Mish(), + nn.Linear(fc_dim // 2, 1), + ) + + self.diffusion_step_encoder = diffusion_step_encoder + self.local_cond_encoder = local_cond_encoder + self.down_modules = down_modules + + self.output = output + logger.info( + "number of parameters: %e", sum(p.numel() for p in self.parameters()) + ) + + def forward(self, + sample: torch.Tensor, + timestep: Union[torch.Tensor, float, int], + formulas:Union[List[str], List[dgl.DGLGraph]], legacy=True, + local_cond=None, global_cond=None, **kwargs): + """ + x: (B,T,input_dim) + timestep: (B,) or int, diffusion step + local_cond: (B,T,local_cond_dim) + global_cond: (B,global_cond_dim) + output: (B,T,input_dim) + """ + sample = einops.rearrange(sample, 'b h t -> b t h') + + # 1. time + timesteps = timestep + if not torch.is_tensor(timesteps): + # TODO: this requires sync between CPU and GPU. So try to pass timesteps as tensors if you can + timesteps = torch.tensor([timesteps], dtype=torch.long, device=sample.device) + elif torch.is_tensor(timesteps) and len(timesteps.shape) == 0: + timesteps = timesteps[None].to(sample.device) + # broadcast to batch dimension in a way that's compatible with ONNX/Core ML + timesteps = timesteps.expand(sample.shape[0]) + + global_feature = self.diffusion_step_encoder(timesteps) + + if global_cond is not None: + global_feature = torch.cat([ + global_feature, global_cond + ], axis=-1) + + # encode local features + h_local = list() + if local_cond is not None: + local_cond = einops.rearrange(local_cond, 'b h t -> b t h') + resnet, resnet2 = self.local_cond_encoder + x = resnet(local_cond, global_feature) + h_local.append(x) + x = resnet2(local_cond, global_feature) + h_local.append(x) + + x = sample + h = [] + for idx, (resnet, resnet2, downsample) in enumerate(self.down_modules): + x = resnet(x, global_feature) + if idx == 0 and len(h_local) > 0: + x = x + h_local[0] + x = resnet2(x, global_feature) + h.append(x) + x = downsample(x) + + ''' + for mid_module in self.mid_modules: + x = mid_module(x, global_feature) + ''' + + ## TODO: check whether this works + ## if not, try using nn.AvgPool1d for lowering the dimension + x = torch.mean(x, dim=-1) + trj_embed = x.view(len(x), -1) + + if (not legacy): + ## Conversion upon inference + formula_tups = [self.str2tup_converter(form_str) for form_str in formulas] + ## Peformance tip: preprocess4gnn is very slow + formula_graphs = self.preprocess4gnn(formula_tups, legacy=legacy) + else: + formula_graphs = formulas + formula_embed = self.gnn(formula_graphs) + + embeds = torch.cat([trj_embed, formula_embed, global_feature], dim=-1) + + out = self.final_block(embeds) + return out + + def preprocess4gnn(self, inputs, ast=None, device=None, + legacy=False) -> np.ndarray: + """ + This function receives the LTL formulas and convert them into inputs + for a GNN. + + Args: + texts (`List[str]`): + LTL formulas to be converted. + ast: + A converter for converting a single formula to a graph; + Default: `self.graph_builder`. + device: + 'cuda' or 'cpu'; + Default: `self.device`. + """ + ast = self.graph_builder if ast is None else ast + if device is None: + device = 'cuda' if torch.cuda.is_available() else 'cpu' + + ## Performance bug fixed, no need to preprocess and load then load the graphs. + ## Set legacy to True to use the old version. + if not legacy: + graphs = np.array([ast(tup, dim_is_root=self.ltl_embed_output_dim).to(device) for tup in inputs]) + else: + logger.info("**UserWarning: Using legacy method for processing graphs, performance is poor.") + graphs = np.array([[ast(tup).to(device)] for tup in inputs]) + for i in range(graphs.shape[0]): + d = graphs[i][0].nodes[None].data['feat'].size() + # pdb.set_trace() + root_weight = torch.ones((1, self.ltl_embed_output_dim)) + others_weight = torch.zeros((d[0]-1, self.ltl_embed_output_dim)) + weight = torch.cat([root_weight, others_weight]) + graphs[i][0].nodes[None].data['is_root'] = weight.to(self.device) + + return graphs + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GCN.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GCN.py new file mode 100644 index 0000000000000000000000000000000000000000..9306415268381d6608a9ead94baafda1336eb934 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GCN.py @@ -0,0 +1,88 @@ +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F + +import dgl +from dgl.nn.pytorch.conv import GraphConv + +from .GNN import GNN + +class GCN(GNN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim) + + hidden_dims = kwargs.get('hidden_dims', [32]) + self.num_layers = len(hidden_dims) + + hidden_plus_input_dims = [hd + input_dim for hd in hidden_dims] + self.convs = nn.ModuleList([GraphConv(in_dim, out_dim, activation=F.relu) for (in_dim, out_dim) + in zip([input_dim] + hidden_plus_input_dims[:-1], hidden_dims)]) + + self.g_embed = nn.Linear(hidden_dims[-1], output_dim) + + # Uses the base implementation which averages hidden representations of all nodes + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = g.ndata["feat"].float() + h = h_0 + + for i in range(self.num_layers): + if i != 0: + h = self.convs[i](g, torch.cat([h, h_0], dim=1)) + else: + h = self.convs[i](g, h) + g.ndata['h'] = h + + # Calculate graph representation by averaging all the hidden node representations. + hg = dgl.mean_nodes(g, 'h') + return self.g_embed(hg).squeeze(1) + + +# GCN, but the graph representation is only the representation of the root node. +class GCNRoot(GCN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim, **kwargs) + + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = g.ndata["feat"].float() + h = h_0 + + for i in range(self.num_layers): + if i != 0: + h = self.convs[i](g, torch.cat([h, h_0], dim=1)) + else: + h = self.convs[i](g, h) + + g.ndata['h'] = h + hg = dgl.sum_nodes(g, 'h', weight='is_root') + return self.g_embed(hg).squeeze(1) + + +class GCNRootShared(GNN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim) + hidden_dim = kwargs.get('hidden_dim', 32) + num_layers = kwargs.get('num_layers', 2) + + self.num_layers = num_layers + self.linear_in = nn.Linear(input_dim, hidden_dim) + self.conv = GraphConv(2*hidden_dim, hidden_dim, activation=F.relu) + self.g_embed = nn.Linear(hidden_dim, output_dim) + + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = self.linear_in(g.ndata["feat"].float()) + h = h_0 + + # Apply convolution layers + for i in range(self.num_layers): + h = self.conv(g, torch.cat([h, h_0], dim=1)) + g.ndata['h'] = h + + hg = dgl.sum_nodes(g, 'h', weight='is_root') + return self.g_embed(hg).squeeze(1) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GNN.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GNN.py new file mode 100644 index 0000000000000000000000000000000000000000..a14c312e580b21f132bf66ef94fdb00f63316fda --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/GNN.py @@ -0,0 +1,15 @@ +import torch +import torch.nn as nn + +from .graph_registry import * + +class GNN(nn.Module): + def __init__(self, input_dim, output_dim): + super().__init__() + + def forward(self, g): + raise NotImplementedError + +def GNNMaker(gnn_type, input_dim, output_dim): + clazz = lookup(gnn_type) + return clazz["class"](input_dim, output_dim, **clazz["kwargs"]) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/RGCN.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/RGCN.py new file mode 100644 index 0000000000000000000000000000000000000000..36100ce815a8e9838d948a75748b22e3336bf73a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/RGCN.py @@ -0,0 +1,93 @@ +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F + +import dgl +from dgl.nn.pytorch.conv import RelGraphConv + +from .GNN import GNN + +from diffusion_policy.common.ast_builder import edge_types + +import pdb + +class RGCN(GNN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim) + + hidden_dims = kwargs.get('hidden_dims', [32]) + self.num_layers = len(hidden_dims) + + hidden_plus_input_dims = [hd + input_dim for hd in hidden_dims] + self.convs = nn.ModuleList([RelGraphConv(in_dim, out_dim, len(edge_types), activation=F.relu) + for (in_dim, out_dim) in zip([input_dim] + hidden_plus_input_dims[:-1], hidden_dims)]) + + self.g_embed = nn.Linear(hidden_dims[-1], output_dim) + + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = g.ndata["feat"].float() + h = h_0 + etypes = g.edata["type"].float() + + for i in range(self.num_layers): + if i != 0: + h = self.convs[i](g, torch.cat([h, h_0], dim=1), etypes) + else: + h = self.convs[i](g, h, etypes) + + g.ndata['h'] = h + # Calculate graph representation by averaging all the hidden node representations. + hg = dgl.mean_nodes(g, 'h') + return self.g_embed(hg).squeeze(1) + + +class RGCNRoot(RGCN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim, **kwargs) + + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = g.ndata["feat"].float().squeeze() + h = h_0 + etypes = g.edata["type"] + + for i in range(self.num_layers): + if i != 0: + h = self.convs[i](g, torch.cat([h, h_0], dim=1), etypes) + else: + h = self.convs[i](g, h, etypes) + + g.ndata['h'] = h # TODO: Check if this is redundant + hg = dgl.sum_nodes(g, 'h', weight='is_root') + return self.g_embed(hg).squeeze(1) + +class RGCNRootShared(GNN): + def __init__(self, input_dim, output_dim, **kwargs): + super().__init__(input_dim, output_dim) + hidden_dim = kwargs.get('hidden_dim', 32) + num_layers = kwargs.get('num_layers', 2) + + self.num_layers = num_layers + self.linear_in = nn.Linear(input_dim, hidden_dim) + self.conv = RelGraphConv(2*hidden_dim, hidden_dim, len(edge_types), activation=torch.tanh) + self.g_embed = nn.Linear(hidden_dim, output_dim) + + def forward(self, g): + g = np.array(g).reshape((1, -1)).tolist()[0] + g = dgl.batch(g) + h_0 = self.linear_in(g.ndata["feat"].float().squeeze()) + h = h_0 + etypes = g.edata["type"] + + # Apply convolution layers + for i in range(self.num_layers): + h = self.conv(g, torch.cat([h, h_0], dim=1), etypes) + g.ndata['h'] = h + + # pdb.set_trace() + hg = dgl.sum_nodes(g, 'h', weight='is_root') + return self.g_embed(hg).squeeze(1) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..57bd07535999f0fd0cc93d098fe690ea51669a16 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/__init__.py @@ -0,0 +1,33 @@ +from .graph_registry import * +from .GNN import * + + +# register(id="GCN_2x32_MEAN", entry_point="gnns.graphs.GCN.GCN", hidden_dims=[32, 32]) + +# register(id="GCN_4x32_MEAN", entry_point="gnns.graphs.GCN.GCN", hidden_dims=[32, 32, 32, 32]) + +# register(id="GCN_32_MEAN", entry_point="gnns.graphs.GCN.GCN", hidden_dims=[32]) + +# register(id="GCN_32_ROOT", entry_point="gnns.graphs.GCN.GCNRoot", hidden_dims=[32]) + +# register(id="GCN_2x32_ROOT", entry_point="gnns.graphs.GCN.GCNRoot", hidden_dims=[32, 32]) + +# register(id="GCN_4x32_ROOT", entry_point="gnns.graphs.GCN.GCNRoot", hidden_dims=[32, 32, 32, 32]) + +# register(id="GCN_2x32_ROOT_SHARED", entry_point="gnns.graphs.GCN.GCNRootShared", hidden_dim=32, num_layers=2) + +# register(id="GCN_4x32_ROOT_SHARED", entry_point="gnns.graphs.GCN.GCNRootShared", hidden_dim=32, num_layers=4) + +# register(id="RGCN_2x32_ROOT", entry_point="gnns.graphs.RGCN.RGCNRoot", hidden_dims=[32, 32]) + +# register(id="RGCN_4x16_ROOT", entry_point="gnns.graphs.RGCN.RGCNRoot", hidden_dims=[16] * 4) + +# register(id="RGCN_4x32_ROOT", entry_point="gnns.graphs.RGCN.RGCNRoot", hidden_dims=[32] * 4) + +# register(id="RGCN_8x32_ROOT", entry_point="gnns.graphs.RGCN.RGCNRoot", hidden_dims=[32] * 8) + +# register(id="RGCN_4x32_ROOT_SHARED", entry_point="gnns.graphs.RGCN.RGCNRootShared", hidden_dim=32, num_layers=4) + +# register(id="RGCN_8x32_ROOT_SHARED", entry_point="gnns.graphs.RGCN.RGCNRootShared", hidden_dim=32, num_layers=8) + +# register(id="RGCN_16x32_ROOT_SHARED", entry_point="gnns.graphs.RGCN.RGCNRootShared", hidden_dim=32, num_layers=16) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/graph_registry.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/graph_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..0da4c704119a4fcacf94d68d39e5f6cb7fff5863 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/gnns/graphs/graph_registry.py @@ -0,0 +1,19 @@ +gnn_registry = {} + +def get_class( kls ): + parts = kls.split('.') + module = ".".join(parts[:-1]) + m = __import__( module ) + for comp in parts[1:]: + m = getattr(m, comp) + + return m + +def register(id="", entry_point=None, **kwargs): + gnn_registry[id] = { + "class": get_class(entry_point), + "kwargs": kwargs + } + +def lookup(gnn_id): + return gnn_registry[gnn_id] diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/cost_guide.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/cost_guide.py new file mode 100644 index 0000000000000000000000000000000000000000..2217b2a515a24c60d627b2cce04953faf70390eb --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/cost_guide.py @@ -0,0 +1,85 @@ +import torch +import torch.nn as nn +import pdb +import omegaconf +from omegaconf import OmegaConf +from typing import Optional, Callable, Union, Tuple, List + + +class CostGuide(nn.Module): + """ + Posterior sampling guide for the Diffusion Policy. + + The guide uses a cost function to evaluate the cost. + """ + def __init__(self, cost_func: Callable=None, cost_func_param: Optional[dict] = None): + super().__init__() + self.cost_fn = cost_func + if cost_func_param is None or isinstance(cost_func_param, dict): + self.cost_func_param = cost_func_param if cost_func_param is not None else {} + else: + assert isinstance(cost_func_param, omegaconf.dictconfig.DictConfig) + self.cost_func_param = OmegaConf.to_container(cost_func_param, resolve=True) + + def set_cost_function(self, cost_func: Callable): + """Set the cost function to be used by the guide. + + Args: + cost_func (Callable): The cost function to be used. + """ + self.cost_fn = cost_func + + def get_cost_function(self) -> Optional[Callable]: + """Get the current cost function used by the guide. + + Returns: + Optional[Callable]: The current cost function, or None if not set. + """ + return self.cost_fn + + def forward(self, input_vars:Union[Tuple, List], *args, **kwargs): + # normalized = kwargs.get("normalized", False) + # custom_normalizer = kwargs.get("custom_normalizer", None) + c = self.cost_fn( + *input_vars, + # normalized=normalized, + # custom_normalizer=custom_normalizer + **self.cost_func_param, + ) + return c + + def gradients(self, input_vars:Union[Tuple, List], *args, **kwargs): + """Compute gradients of the cost function with respect to input variables. + Args: + input_vars: Input variables to compute the cost function. + *args: Variable length argument list passed to the cost function. + **kwargs: Arbitrary keyword arguments including: + normalized (bool, optional): Whether to use normalized inputs. Defaults to False. + custom_normalizer (optional): Custom normalizer to apply. Defaults to None. + with_respect_to (optional): Variables to compute gradients with respect to. + Defaults to input_vars. + Returns: + tuple: A tuple containing: + - cost (torch.Tensor): The computed cost values. + - tuple: Gradients with respect to the specified variables. + Note: + The function computes the sum of the cost tensor before computing gradients, + and allows unused variables in the gradient computation. + """ + + normalized = kwargs.get("normalized", False) + custom_normalizer = kwargs.get("custom_normalizer", None) + wrt = kwargs.get("with_respect_to", input_vars) + cost:torch.Tensor = self.forward( + input_vars, + normalized=normalized, + custom_normalizer=custom_normalizer + ) + c_sum = cost.sum() + # grads = [] + # for i, rv in enumerate(input_vars): + # grads.append(torch.autograd.grad(c_sum, rv)[0]) + grads = torch.autograd.grad(c_sum, wrt) + + return cost, grads + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/ps_guide.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/ps_guide.py new file mode 100644 index 0000000000000000000000000000000000000000..7ef4285a2e19d54c6cc0e67f83a71391cd4f9368 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/ps_guide.py @@ -0,0 +1,31 @@ +import torch +import torch.nn as nn +import pdb + +from diffusion_policy.dtl.dtl_cont_cons import DTL_Cont_Cons_Evaluator + + +class PSGuide(nn.Module): + """ + Posterior sampling guide for the Diffusion Policy. + + The guide uses the value and gradients of the DTL evaluator for guidiing. + """ + def __init__(self, evaluator: DTL_Cont_Cons_Evaluator): + super().__init__() + self.evaluator = evaluator + + def set_ltl_formula(self, ltl): + self.evaluator.set_ltl_formula(ltl) + + def forward(self, x_0, *args, **kwargs): + return self.evaluator.get_evaluations( + self.evaluator.get_assignments(x_0, *args, **kwargs)) + + def gradients(self, x_prev, x_0_hat, *args, **kwargs): + normalized = kwargs.get("normalized", False) + custom_normalizer = kwargs.get("custom_normalizer", None) + y = self(x_0_hat, normalized=normalized, + custom_normalizer=custom_normalizer) + grad = torch.autograd.grad([y.sum()], [x_prev])[0] + return y, grad diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/value_guide.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/value_guide.py new file mode 100644 index 0000000000000000000000000000000000000000..a9505614eb1062b522b1fb167faf9b14f076787a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/guide/value_guide.py @@ -0,0 +1,58 @@ +import torch +import torch.nn as nn +import pdb + +from diffusion_policy.model.diffusion.value_classifier import ConditionalValueUnet1D + + +class ValueGuide(nn.Module): + + def __init__(self, + model, + ltl=None, + graph:list=None): + super().__init__() + self.model = model + self.ltls = ltl + self.graph = graph + + def set_graph(self, graph): + self.graph = graph + + def forward(self, x, *args, **kwargs): + if self.graph is None: + output = self.model(x, *args, **kwargs) + else: + if x.shape[0] != len(self.graph): + self.graph = [self.graph[0]] * x.shape[0] + output = self.model(x, *args, formulas=self.graph, **kwargs) + return output.squeeze(dim=-1) + + def gradients(self, x, *args, **kwargs): + if isinstance(self.model, ConditionalValueUnet1D): + return self.gradients_batch(x, *args, **kwargs) + else: + return self.gradients_loop(x, *args, **kwargs) + + def gradients_batch(self, x, *args, **kwargs): + x.requires_grad_() + y = self(x, *args, **kwargs) + grad = torch.autograd.grad([y.sum()], [x])[0] + x.detach() + return y, grad + + def gradients_loop(self, x, *args): + ''' + interface for direct use of formula function, not working + ''' + bs = x.shape[0] + ys = [] + grads = [] + for i in range(bs): + xi = x[i].clone().detach() + xi.requires_grad = True + y = self.model(xi) + ys.append(y) + grads.append(torch.autograd.grad([y], [xi])[0]) + return torch.stack(ys), torch.stack(grads) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/cvxpy_projector_multiprocess.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/cvxpy_projector_multiprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..9d6b3ddf291d5be1c2d80dfa59970775eb831d58 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/cvxpy_projector_multiprocess.py @@ -0,0 +1,248 @@ +import os +os.environ.setdefault("OMP_NUM_THREADS", "1") +os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") +os.environ.setdefault("MKL_NUM_THREADS", "1") + +import cvxpy as cp +import numpy as np +import torch +import logging +from typing import Optional, Dict, Callable, List, Tuple +from concurrent.futures import ProcessPoolExecutor, as_completed + +from diffusion_policy.model.common.tensor_util import to_numpy +from diffusion_policy.model.projection.projection_operator import BaseProjectionOperator +from diffusion_policy.constraints.velocity_constraints import vel_constraint_registry + +logger = logging.getLogger(__name__) + +# --- Worker globals (one per process) --- +_PROBLEM = None +_VARIABLES = None +_PARAMETERS = None +_INPUT_KEY = None +_SOLVER = None +_SOLVER_KWARGS = None + + +def _init_worker( + variables_def: Dict[str, Tuple], + parameters_def: Dict[str, Tuple], + objective_fn: Callable, + constraints_fn: Optional[Callable], + input_key: str, + solver: str, + solver_kwargs: Dict, +): + """ + Initializer for each worker process: builds the CVXPY Problem & Parameters once. + """ + + global _PROBLEM, _PARAMETERS, _INPUT_KEY, _SOLVER, _SOLVER_KWARGS, _VARIABLES + _INPUT_KEY = input_key + _SOLVER = solver + _SOLVER_KWARGS = solver_kwargs + + # Build CVXPY variables and parameters + vars_ = {name: cp.Variable(shape, name=name) + for name, shape in variables_def.items()} + pars = {name: cp.Parameter(shape, name=name) + for name, shape in parameters_def.items()} + # Input parameter + pars[input_key] = cp.Parameter(variables_def[input_key], name=f'in_{input_key}') + + # store variable mapping for warm-start + _VARIABLES = vars_ + + # Objective and constraints + obj = objective_fn(vars_, pars) + cons = constraints_fn(vars_, pars) if constraints_fn else [] + + _PROBLEM = cp.Problem(cp.Minimize(obj), cons) + _PARAMETERS = pars + + +def _solve_worker(x_i: np.ndarray, param_dict: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]: + """ + Called inside each process: updates parameters, solves, returns variable values. + """ + # update mandatory input + _PARAMETERS[_INPUT_KEY].value = x_i + # update other parameters + for k, v in param_dict.items(): + if k in _PARAMETERS: + _PARAMETERS[k].value = v + + _VARIABLES[_INPUT_KEY].value = x_i + + _PROBLEM.solve(solver=_SOLVER, **_SOLVER_KWARGS) + if _PROBLEM.status not in ('optimal', ): + logger.warning( + f"CVXPY problem status: {_PROBLEM.status}. \n" + f"Input: {_INPUT_KEY}={x_i}, Params: {param_dict}" + ) + + return {var.name(): var.value for var in _PROBLEM.variables()} + + +class CvxPyProjector(BaseProjectionOperator): + """ + CVXPY-based projector with persistent process pool for true parallelism. + """ + _pool: ProcessPoolExecutor = None + + def __init__( + self, + variables: Dict[str, Tuple], + parameters: Optional[Dict[str, Tuple]] = None, + objective: Callable[..., cp.Expression] = None, + constraints: Optional[Callable[..., List[cp.Expression]]] = None, + parallel: bool = True, + n_jobs: int = -1, + solver: str = 'ECOS', + verbose: bool = False, + **solver_kwargs + ): + super().__init__() + if not variables or not isinstance(variables, dict): + raise ValueError("'variables' must be a non-empty dict") + if objective is None: + raise ValueError("'objective' must be provided") + + self._variables_def = variables + self._parameters_def = parameters or {} + self.objective_fn = objective + self.constraints_fn = constraints + self.solver = solver + self.solver_kwargs = solver_kwargs + self.solver_kwargs['verbose'] = verbose + self._input_key = next(iter(self._variables_def)) + + # Cache for sequential fallback + self._sequential_bundle = None + + self.parallel = parallel + if parallel: + cores = os.cpu_count() or 1 + self.n_jobs = cores if n_jobs == -1 else min(n_jobs, cores) + logger.info(f"Using {self.n_jobs} workers for parallel projection.") + else: + self.n_jobs = 1 + self._sequential_inited = False + logger.info(f"Running in sequential mode, no parallelization.") + + + def _build_single_problem_bundle(self): + vars_ = {n: cp.Variable(s, name=n) for n, s in self._variables_def.items()} + pars = {n: cp.Parameter(s, name=n) for n, s in self._parameters_def.items()} + pars[self._input_key] = cp.Parameter( + self._variables_def[self._input_key], + name=f'in_{self._input_key}' + ) + obj = self.objective_fn(vars_, pars) + cons = self.constraints_fn(vars_, pars) if self.constraints_fn else [] + prob = cp.Problem(cp.Minimize(obj), cons) + return prob, pars + + def project( + self, + x: torch.Tensor, + params: Optional[Dict[str, torch.Tensor]] = None, + ) -> torch.Tensor: + device, dtype = x.device, x.dtype + is_batched = x.ndim > 1 + batch_size = x.shape[0] if is_batched else 1 + + x_np = x.detach().cpu().numpy() + if not is_batched: + x_np = x_np[np.newaxis] + + params_np = to_numpy(params) if params else {} + param_list = [ + {k: (v[i] if v.ndim and v.shape[0] == batch_size else v) + for k, v in params_np.items()} + for i in range(batch_size) + ] + + # Parallel path + if self.parallel and batch_size > 1: + + # init pool once + if self._pool is None: + self._pool = ProcessPoolExecutor( + max_workers=self.n_jobs, + initializer=_init_worker, + initargs=( + self._variables_def, + self._parameters_def, + self.objective_fn, + self.constraints_fn, + self._input_key, + self.solver, + self.solver_kwargs, + ) + ) + + # submit jobs + futures = [ + self._pool.submit(_solve_worker, xi, pi) + for xi, pi in zip(x_np, param_list) + ] + results = [f.result() for f in futures] + + # Sequential fallback + else: + if not self._sequential_inited: + # initialize solver state in main process + _init_worker( + self._variables_def, + self._parameters_def, + self.objective_fn, + self.constraints_fn, + self._input_key, + self.solver, + self.solver_kwargs + ) + self._sequential_inited = True + + results = [ + _solve_worker(xi, pi) + for xi, pi in zip(x_np, param_list) + ] + + out = np.stack([r[self._input_key] for r in results]) + if not is_batched: + out = out.squeeze(0) + return torch.from_numpy(out).to(dtype=dtype, device=device) + + def close(self): + if self._pool is not None: + self._pool.shutdown(wait=True) + self._pool = None + + def __del__(self): + # best‐effort cleanup + self.close() + + +class MaxVelocityCvxPyProjector(CvxPyProjector): + """A specialized projector for enforcing maximum velocity constraints. + + This projector is designed to project trajectories onto a set defined by + maximum velocity constraints, ensuring that the change in position between + consecutive time steps does not exceed a specified limit. + """ + def __init__(self, horizon: int, ndim: int, proj_profile_key:str, **kwargs): + """Initializes the MaxVelocityCvxPyProjector. + + Args: + horizon: The number of time steps in the trajectory. + ndim: The dimensionality of the position space. + **kwargs: Additional keyword arguments passed to the base class. + """ + + constraint_kwargs = vel_constraint_registry[proj_profile_key](horizon, ndim) + super().__init__( + **constraint_kwargs, + **kwargs + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/projection_operator.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/projection_operator.py new file mode 100644 index 0000000000000000000000000000000000000000..db5b7e37f0103c21d307c231b610e7fd86906a2b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/projection/projection_operator.py @@ -0,0 +1,490 @@ +import torch +import numpy as np +import logging +from itertools import repeat +from typing import Optional, Union, Callable, Dict, List, Tuple + +from diffusion_policy.model.common.tensor_util import to_numpy, to_torch +from diffusion_policy.constraints.velocity_constraints import vel_constraint_registry +import time + +logger = logging.getLogger(__name__) + +class BaseProjectionOperator: + """Base class for projection operators.""" + def __init__(self): + pass + + def project(self, + x: torch.Tensor, + params: Optional[Dict[str, torch.Tensor]], + ) -> torch.Tensor: + """Projects a tensor x onto a constraint set.""" + raise NotImplementedError("The project method must be implemented by subclasses.") + + + +class ADMMProjectionOperator(BaseProjectionOperator): + """ + ADMM Projection Operator for convex constraints. + + Args: + constraint_fn: A callable that defines the convex constraint. + rho: Penalty parameter for ADMM. + max_iter: Maximum number of iterations for ADMM. + tol: Tolerance for convergence. + """ + def __init__(self, + rho: float = 1.0, + max_iter: int = 100, + tol: float = 1e-4, + convergence_check: bool = True, + verbose: bool = False + ): + super().__init__() + self.rho = rho + self.max_iter = max_iter + assert max_iter > 1, "max_iter must be greater than 1." + self.tol = tol + self.tol_sq = tol**2 # Pre-compute the squared tolerance + logger.info( + f"ADMM projector initialized with" + f"rho={rho}, max_iter={max_iter}, tol={tol}" + ) + # Cached LU factorization info + self._cached_n = None + self._cached_dtype = None + self._cached_lu = None + self._cached_pivots = None + + self.cvg_chk = convergence_check + self.verbose = verbose + + def _ensure_factorization(self, n: int, dtype, device) -> bool: + """ + Ensure LU factorization of A = I + rho*D^T D is cached for given n, dtype, device. + Recompute if n or dtype changed. + Also cache the difference matrix D for dtu calculations. + """ + if self._cached_n == n and self._cached_dtype == dtype and self._cached_lu is not None: + return True + + # Build and cache (n-1 x n) difference operator D + D = torch.zeros(n - 1, n, device=device, dtype=dtype) + idx = torch.arange(n - 1, device=device) + D[idx, idx] = -1.0 + D[idx, idx + 1] = +1.0 + self._cached_D_T_expand = D.T.unsqueeze(0) # (1, n, n-1) + + # Assemble system matrix A = I + rho * D^T D + I_n = torch.eye(n, device=device, dtype=dtype) + A = I_n + self.rho * (D.T @ D) + + # Compute and cache LU factorization + lu, pivots = torch.linalg.lu_factor(A) + self._cached_lu = lu + self._cached_pivots = pivots + self._cached_n = n + self._cached_dtype = dtype + + return False + + def project(self, + X: torch.Tensor, + params: dict, + timeit: bool = False + ) -> torch.Tensor: + """ + Batched ADMM projection for X ∈ R^{B x H x D}, enforcing + ||x_{i+1} - x_i||_2 ≤ dx_max. Optionally includes an initial + position `pos_init` before X[:,0,:]. + """ + assert 'dx_max' in params, "Parameter 'dx_max' must be provided." + alpha = params['dx_max'] + x0 = params.get('pos_init', None) + + B, H, d = X.shape + device = X.device + dtype = X.dtype + + # Timing placeholders + if timeit: + t_init = t_linalg = t_loop = t_x_update = t_z_update = t_u_update = t_conv_chk = 0.0 + t_init_start = time.perf_counter() + + with torch.no_grad(): + # Prepare augmented trajectory + x = X.detach().clone() + n = H + if x0 is not None: + assert x0.shape == (B, d), "pos_init must be (B, D)" + x = torch.cat([x0.detach().clone().to(device).unsqueeze(1), x], dim=1) + n += 1 + X_aug = x.detach().clone() + + # Ensure factorization and D are cached + self._ensure_factorization(n, dtype, device) + lu, pivots = self._cached_lu, self._cached_pivots + D = self._cached_D_T_expand + + # Initialize ADMM variables + z = torch.zeros((B, n - 1, d), device=device, dtype=dtype) + u = torch.zeros((B, n - 1, d), device=device, dtype=dtype) + + if timeit: + t_init = time.perf_counter() - t_init_start + t_loop_start = time.perf_counter() + + # ADMM iterations + for _ in repeat(None, self.max_iter): + if timeit: + t_x_update_start = time.perf_counter() + + # x-update: compute b = X_aug + rho * D^T (z - u) + b = X_aug + self.rho * D @ (z - u) # (B, n, d) + + if timeit: + toc = time.perf_counter() + t_linalg_start = toc + t_x_update += toc - t_x_update_start + + # Solve A x = b via cached LU + x_new = torch.lu_solve(b, lu, pivots) + + if timeit: + toc = time.perf_counter() + t_linalg += toc - t_linalg_start + t_z_update_start = toc + + # z-update: project onto L2 balls + diff = x_new[:, 1:] - x_new[:, :-1] + u + norms = diff.norm(dim=2, keepdim=True) + scale = torch.clamp(alpha / (norms + 1e-10), max=1.0) + z_new = diff * scale + + if timeit: + toc = time.perf_counter() + t_z_update += toc - t_z_update_start + t_u_update_start = toc + + # u-update + u += (x_new[:, 1:] - x_new[:, :-1]) - z_new + + if timeit: + toc = time.perf_counter() + t_u_update += toc - t_u_update_start + t_conv_chk_start = toc + + # --- OPTIMIZED CONVERGENCE CHECK --- + # Check convergence using squared norms to avoid sqrt + if self.cvg_chk: + r_norm_sq = (x_new[:,1:] - x_new[:,:-1] - z_new).pow(2).sum(dim=(1, 2)) + # Note: z changes in this loop, so we compute the dual residual + # with the new and old z. + s_norm_sq = self.rho * (z_new - z).pow(2).sum(dim=(1, 2)) + converged = torch.all(r_norm_sq < self.tol_sq) and torch.all(s_norm_sq < self.tol_sq) + + if timeit: + t_conv_chk += time.perf_counter() - t_conv_chk_start + + if self.cvg_chk and converged: + break + + z = z_new + #end for + + if timeit: + if X.is_cuda: torch.cuda.synchronize() + t_loop = time.perf_counter() - t_loop_start + + + # Drop initial state if added + result = x_new[:, 1:] if x0 is not None else x_new + assert result.shape == X.shape + + if timeit: + t_total = time.perf_counter() - t_init_start + return result, { + 'init': np.array([t_init]), + 'linalg': np.array([t_linalg]), + 'x_update': np.array([t_x_update]), + 'z_update': np.array([t_z_update]), + 'u_update': np.array([t_u_update]), + 'conv_chk': np.array([t_conv_chk]), + 'loop': np.array([t_loop]), + 'total': np.array([t_total]) + } + return result + + + +class ADMMProjectionOperatorCUDAGraph(ADMMProjectionOperator): + """ + ADMM projection with CUDA-graph acceleration and anchor constraint. + """ + def __init__(self, rho: float = 1.0, max_iter: int = 100, tol: float = 1e-4, + decomp: str = 'cholesky', + convergence_check: bool = True, verbose: bool = False): + super().__init__(rho=rho, max_iter=max_iter, tol=tol, convergence_check=convergence_check, verbose=verbose) + + assert decomp in ['cholesky', 'lu'], "`decomp` only supports 'cholesky' or 'lu' for now." + self.decomp:str = decomp.lower() + if decomp == 'lu': + self._solve_primal = self._solve_primal_lu + elif decomp == 'cholesky': + self._solve_primal = self._solve_primal_cholesky + else: + raise ValueError(f"Unknown decomp: {decomp}") + + self._graph_cached_D_T_expand = None + self._graph_cached_lu = None + self._graph_cached_pivots = None + self._graph_cached_cholesky = None + self._graph_cached_cholesky_T = None + self._cached_shape = None + self.graph:torch.cuda.CUDAGraph = None + self.stream: torch.cuda.Stream = None + self.static_buffers_ready:bool = False + self.static_x_penultimate = None + self.static_z_penultimate = None + self.static_b_anchor = None + + def _build_difference_operator(self, n: int, dtype, device) -> Tuple[torch.Tensor, torch.Tensor]: + # Create main diagonal (all 1s) + D = torch.diag(torch.ones(n, dtype=dtype, device=device)) + # Create sub-diagonal (all -1s, one position below main diagonal) + D += torch.diag(-1.0 * torch.ones(n-1, dtype=dtype, device=device), diagonal=-1) + D_T_expand = D.t().unsqueeze(0) + return D, D_T_expand + + def _ensure_factorization(self, n, dtype, device): + matched = (self._cached_n == n and + self._cached_dtype == dtype and + self._graph_cached_D_T_expand is not None) + + if not matched: + D, D_T_expand = self._build_difference_operator(n, dtype, device) + self._graph_cached_D_T_expand = D_T_expand.contiguous() + + + # Assemble system matrix A = I + rho * D^T D + I_n = torch.eye(n, device=device, dtype=dtype) + A = I_n + self.rho * (D.T @ D) + if self.decomp == 'lu': + # Compute and cache LU factorization + lu, pivots = torch.linalg.lu_factor(A) + self._graph_cached_lu = lu.contiguous() + self._graph_cached_pivots = pivots.contiguous() + elif self.decomp == 'cholesky': + # Use banded Cholesky: pack banded matrix or dense + self._graph_cached_cholesky = torch.linalg.cholesky(A).contiguous() + self._graph_cached_cholesky_T = self._graph_cached_cholesky.transpose(-2, -1).contiguous() + else: + raise ValueError(f"Unknown decomp: {self.decomp}") + + self._cached_n = n + self._cached_dtype = dtype + + + def _capture_graph(self, B: int, n: int, d: int, alpha: float, device: Union[torch.device, str]): + # Ensure max_iter is sufficient for penultimate capture + if self.max_iter < 2: + raise ValueError("max_iter must be at least 2 to capture penultimate iteration") + + dtype = self._cached_dtype + # Static buffers + self.static_X = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_z = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_u = torch.empty_like(self.static_z) + self.static_b_anchor = torch.empty_like(self.static_z) + self.static_rhs = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_x = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_x_penultimate = torch.empty_like(self.static_x) + self.static_z_penultimate = torch.empty_like(self.static_z) + + self.static_diff_z = torch.empty_like(self.static_z) + self.static_dtu = torch.empty((B, n, d), device=device, dtype=dtype) + self.static_rho_dtu = torch.empty_like(self.static_dtu) + self.static_Dx = torch.empty_like(self.static_z) + self.static_norm = torch.empty((B, n, 1), device=device, dtype=dtype) + self.static_scale = torch.empty_like(self.static_norm) + self.static_temp_z = torch.empty_like(self.static_z) + self.static_temp_upd = torch.empty_like(self.static_z) + # Allocate static y buffer for Cholesky solve + self.static_y = torch.empty_like(self.static_x) + + self.static_alpha = torch.tensor([alpha], device=device, dtype=dtype) + self.static_rho = torch.tensor([self.rho], device=device, dtype=dtype) + self.static_eps = torch.tensor([1e-10], device=device, dtype=dtype) + + self.graph = torch.cuda.CUDAGraph() + self.stream = torch.cuda.Stream(device=device) + torch.cuda.synchronize() + + # Warmup + with torch.cuda.stream(self.stream), torch.no_grad(): + for _ in range(3): + self._admm_iteration_static() + + # Begin CUDA graph capture + self.graph.capture_begin() + # Capture main iterations up to penultimate + for _ in range(self.max_iter - 2): + self._admm_iteration_static() + # Penultimate iteration and snapshot + self._admm_iteration_static() + self.static_x_penultimate.copy_(self.static_x) + self.static_z_penultimate.copy_(self.static_z) + # Final iteration + self._admm_iteration_static() + self.graph.capture_end() + + torch.cuda.synchronize() + self.static_buffers_ready = True + self._cached_shape = (B, n, d) + + def _solve_primal_lu(self): + torch.linalg.lu_solve( + self._graph_cached_lu, + self._graph_cached_pivots, + self.static_rhs, + out=self.static_x + ) + + def _solve_primal_cholesky(self): + # Solve L L^T x = rhs, L is lower triangular + ## Solve L y = rhs + torch.linalg.solve_triangular( + self._graph_cached_cholesky, + self.static_rhs, + left=True, + upper=False, + out=self.static_y + ) + ## Solve L^T x = y + torch.linalg.solve_triangular( + self._graph_cached_cholesky_T, + self.static_y, + left=True, + upper=True, + out=self.static_x + ) + + def _admm_iteration_static(self): + # x-update with correct sign: z + b_anchor - u + torch.add(self.static_z, self.static_b_anchor, out=self.static_diff_z) + torch.sub(self.static_diff_z, self.static_u, out=self.static_diff_z) + torch.matmul(self._graph_cached_D_T_expand, self.static_diff_z, out=self.static_dtu) + torch.mul(self.static_dtu, self.static_rho, out=self.static_rho_dtu) + torch.add(self.static_X, self.static_rho_dtu, out=self.static_rhs) + self._solve_primal() + + # z-update (proximal Euclidean-ball projection) + self.static_Dx[:, 0, :] = self.static_x[:, 0, :] + torch.sub(self.static_x[:, 1:], self.static_x[:, :-1], out=self.static_Dx[:, 1:]) + torch.sub(self.static_Dx, self.static_b_anchor, out=self.static_temp_z) + torch.add(self.static_temp_z, self.static_u, out=self.static_temp_z) + torch.norm(self.static_temp_z, dim=2, keepdim=True, out=self.static_norm) + torch.add(self.static_norm, self.static_eps, out=self.static_scale) + self.static_scale.reciprocal_() + torch.mul(self.static_scale, self.static_alpha, out=self.static_scale) + torch.clamp_max_(self.static_scale, 1.0) + torch.mul(self.static_temp_z, self.static_scale, out=self.static_z) + + # u-update + torch.sub(self.static_Dx, self.static_z, out=self.static_temp_upd) + torch.sub(self.static_temp_upd, self.static_b_anchor, out=self.static_temp_upd) + torch.add(self.static_u, self.static_temp_upd, out=self.static_u) + + def project(self, X: torch.Tensor, params: dict, timeit: bool = False) -> torch.Tensor: + alpha:float = params['dx_max'] + x0:torch.Tensor = params['pos_init'] + B, n, d = X.shape + device, dtype = X.device, X.dtype + + self._ensure_factorization(n, dtype, device) + need_new = ( + not self.static_buffers_ready or + self._cached_shape != (B, n, d) or + self._cached_dtype != dtype + ) + if need_new: + self._capture_graph(B, n, d, alpha, device) + self._cached_dtype = dtype + + cur_stream = torch.cuda.current_stream(device) + self.stream.wait_stream(cur_stream) + with torch.cuda.stream(self.stream), torch.no_grad(): + self.static_X.copy_(X) + self.static_alpha.fill_(alpha) + self.static_z.zero_() + self.static_u.zero_() + self.static_b_anchor.zero_() + self.static_b_anchor[:, 0, :] = x0.to(dtype=dtype, device=device) + self.graph.replay() + out = self.static_x.detach().clone() + + cur_stream.wait_stream(self.stream) + + # --------------------- optional convergence log ------------------------- + if self.cvg_chk: + assert self.max_iter >= 2, "max_iter must be at least 2 for convergence check." + with torch.no_grad(): + diff_x = self.static_x - self.static_x_penultimate + prim_res = self.static_Dx - self.static_z - self.static_b_anchor + diff_z = self.static_z - self.static_z_penultimate + dual_res = self.rho * torch.matmul( + self._graph_cached_D_T_expand, diff_z) + max_xchg = torch.sqrt(diff_x.pow(2).sum((-1, -2)).max()).item() + max_pr = torch.sqrt(prim_res.pow(2).sum((-1, -2)).max()).item() + max_dr = torch.sqrt(dual_res.pow(2).sum((-1, -2)).max()).item() + max_zn = self.static_z.norm(dim=2).max().item() + if self.verbose: + logger.info(f"[ADMM Convergence Check]:\n" + f"\tΔx {max_xchg:.3e} \n" + f"\tprimal: {max_pr:.3e} \n" + f"\tdual: {max_dr:.3e} \n" + f"\t||z||_∞: {max_zn:.3e} (≤ {alpha:3e})") + + if (max_xchg > self.tol or + max_pr > self.tol or + max_dr > self.tol or + max_zn >= alpha + self.tol): + logger.warning( + f"ADMM projection did NOT converge within {self.max_iter} iterations:\n" + f"\tΔx {max_xchg:.3e} \n" + f"\tprimal: {max_pr:.3e} \n" + f"\tdual: {max_dr:.3e} \n" + f"\t||z||_∞: {max_zn:.3e} (≤ {alpha:3e}) " + ) + + feasible = self.feasibility_check(out, params, eps=1e-5) + if not feasible.all(): + logger.warning( + f"ADMM projection operator returned infeasible trajectories: " + f"{torch.logical_not(feasible).sum().item()} / {feasible.numel()} NOT feasible." + ) + + return out + + + def feasibility_check(self, + x: torch.Tensor, + params: Dict[str, Union[torch.Tensor, float]], + eps: float = 1e-6 + ) -> torch.Tensor: + """ + Check feasibility of the trajectory X under the projection operator. + Returns a tensor indicating whether each trajectory is feasible. + """ + alpha:float = params['dx_max'] + x0:torch.Tensor = params['pos_init'] + + with torch.no_grad(): + ## Check x0 feasibility + diff_x0 = x[..., 0, :] - x0 + norm = torch.linalg.norm(diff_x0, dim=-1) + feasible_x0 = norm <= alpha + eps + + return feasible_x0 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/crop_randomizer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/crop_randomizer.py new file mode 100644 index 0000000000000000000000000000000000000000..907957413c206de98fa485f8aa708177e5181a03 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/crop_randomizer.py @@ -0,0 +1,288 @@ +import torch +import torch.nn as nn +import torchvision.transforms.functional as ttf +import diffusion_policy.model.common.tensor_util as tu + +class CropRandomizer(nn.Module): + """ + Randomly sample crops at input, and then average across crop features at output. + """ + def __init__( + self, + input_shape, + crop_height, + crop_width, + num_crops=1, + pos_enc=False, + ): + """ + Args: + input_shape (tuple, list): shape of input (not including batch dimension) + crop_height (int): crop height + crop_width (int): crop width + num_crops (int): number of random crops to take + pos_enc (bool): if True, add 2 channels to the output to encode the spatial + location of the cropped pixels in the source image + """ + super().__init__() + + assert len(input_shape) == 3 # (C, H, W) + assert crop_height < input_shape[1] + assert crop_width < input_shape[2] + + self.input_shape = input_shape + self.crop_height = crop_height + self.crop_width = crop_width + self.num_crops = num_crops + self.pos_enc = pos_enc + + def output_shape_in(self, input_shape=None): + """ + Function to compute output shape from inputs to this module. Corresponds to + the @forward_in operation, where raw inputs (usually observation modalities) + are passed in. + + Args: + input_shape (iterable of int): shape of input. Does not include batch dimension. + Some modules may not need this argument, if their output does not depend + on the size of the input, or if they assume fixed size input. + + Returns: + out_shape ([int]): list of integers corresponding to output shape + """ + + # outputs are shape (C, CH, CW), or maybe C + 2 if using position encoding, because + # the number of crops are reshaped into the batch dimension, increasing the batch + # size from B to B * N + out_c = self.input_shape[0] + 2 if self.pos_enc else self.input_shape[0] + return [out_c, self.crop_height, self.crop_width] + + def output_shape_out(self, input_shape=None): + """ + Function to compute output shape from inputs to this module. Corresponds to + the @forward_out operation, where processed inputs (usually encoded observation + modalities) are passed in. + + Args: + input_shape (iterable of int): shape of input. Does not include batch dimension. + Some modules may not need this argument, if their output does not depend + on the size of the input, or if they assume fixed size input. + + Returns: + out_shape ([int]): list of integers corresponding to output shape + """ + + # since the forward_out operation splits [B * N, ...] -> [B, N, ...] + # and then pools to result in [B, ...], only the batch dimension changes, + # and so the other dimensions retain their shape. + return list(input_shape) + + def forward_in(self, inputs): + """ + Samples N random crops for each input in the batch, and then reshapes + inputs to [B * N, ...]. + """ + assert len(inputs.shape) >= 3 # must have at least (C, H, W) dimensions + if self.training: + # generate random crops + out, _ = sample_random_image_crops( + images=inputs, + crop_height=self.crop_height, + crop_width=self.crop_width, + num_crops=self.num_crops, + pos_enc=self.pos_enc, + ) + # [B, N, ...] -> [B * N, ...] + return tu.join_dimensions(out, 0, 1) + else: + # take center crop during eval + out = ttf.center_crop(img=inputs, output_size=( + self.crop_height, self.crop_width)) + if self.num_crops > 1: + B,C,H,W = out.shape + out = out.unsqueeze(1).expand(B,self.num_crops,C,H,W).reshape(-1,C,H,W) + # [B * N, ...] + return out + + def forward_out(self, inputs): + """ + Splits the outputs from shape [B * N, ...] -> [B, N, ...] and then average across N + to result in shape [B, ...] to make sure the network output is consistent with + what would have happened if there were no randomization. + """ + if self.num_crops <= 1: + return inputs + else: + batch_size = (inputs.shape[0] // self.num_crops) + out = tu.reshape_dimensions(inputs, begin_axis=0, end_axis=0, + target_dims=(batch_size, self.num_crops)) + return out.mean(dim=1) + + def forward(self, inputs): + return self.forward_in(inputs) + + def __repr__(self): + """Pretty print network.""" + header = '{}'.format(str(self.__class__.__name__)) + msg = header + "(input_shape={}, crop_size=[{}, {}], num_crops={})".format( + self.input_shape, self.crop_height, self.crop_width, self.num_crops) + return msg + + +def crop_image_from_indices(images, crop_indices, crop_height, crop_width): + """ + Crops images at the locations specified by @crop_indices. Crops will be + taken across all channels. + + Args: + images (torch.Tensor): batch of images of shape [..., C, H, W] + + crop_indices (torch.Tensor): batch of indices of shape [..., N, 2] where + N is the number of crops to take per image and each entry corresponds + to the pixel height and width of where to take the crop. Note that + the indices can also be of shape [..., 2] if only 1 crop should + be taken per image. Leading dimensions must be consistent with + @images argument. Each index specifies the top left of the crop. + Values must be in range [0, H - CH - 1] x [0, W - CW - 1] where + H and W are the height and width of @images and CH and CW are + @crop_height and @crop_width. + + crop_height (int): height of crop to take + + crop_width (int): width of crop to take + + Returns: + crops (torch.Tesnor): cropped images of shape [..., C, @crop_height, @crop_width] + """ + + # make sure length of input shapes is consistent + assert crop_indices.shape[-1] == 2 + ndim_im_shape = len(images.shape) + ndim_indices_shape = len(crop_indices.shape) + assert (ndim_im_shape == ndim_indices_shape + 1) or (ndim_im_shape == ndim_indices_shape + 2) + + # maybe pad so that @crop_indices is shape [..., N, 2] + is_padded = False + if ndim_im_shape == ndim_indices_shape + 2: + crop_indices = crop_indices.unsqueeze(-2) + is_padded = True + + # make sure leading dimensions between images and indices are consistent + assert images.shape[:-3] == crop_indices.shape[:-2] + + device = images.device + image_c, image_h, image_w = images.shape[-3:] + num_crops = crop_indices.shape[-2] + + # make sure @crop_indices are in valid range + assert (crop_indices[..., 0] >= 0).all().item() + assert (crop_indices[..., 0] < (image_h - crop_height)).all().item() + assert (crop_indices[..., 1] >= 0).all().item() + assert (crop_indices[..., 1] < (image_w - crop_width)).all().item() + + # convert each crop index (ch, cw) into a list of pixel indices that correspond to the entire window. + + # 2D index array with columns [0, 1, ..., CH - 1] and shape [CH, CW] + crop_ind_grid_h = torch.arange(crop_height).to(device) + crop_ind_grid_h = tu.unsqueeze_expand_at(crop_ind_grid_h, size=crop_width, dim=-1) + # 2D index array with rows [0, 1, ..., CW - 1] and shape [CH, CW] + crop_ind_grid_w = torch.arange(crop_width).to(device) + crop_ind_grid_w = tu.unsqueeze_expand_at(crop_ind_grid_w, size=crop_height, dim=0) + # combine into shape [CH, CW, 2] + crop_in_grid = torch.cat((crop_ind_grid_h.unsqueeze(-1), crop_ind_grid_w.unsqueeze(-1)), dim=-1) + + # Add above grid with the offset index of each sampled crop to get 2d indices for each crop. + # After broadcasting, this will be shape [..., N, CH, CW, 2] and each crop has a [CH, CW, 2] + # shape array that tells us which pixels from the corresponding source image to grab. + grid_reshape = [1] * len(crop_indices.shape[:-1]) + [crop_height, crop_width, 2] + all_crop_inds = crop_indices.unsqueeze(-2).unsqueeze(-2) + crop_in_grid.reshape(grid_reshape) + + # For using @torch.gather, convert to flat indices from 2D indices, and also + # repeat across the channel dimension. To get flat index of each pixel to grab for + # each sampled crop, we just use the mapping: ind = h_ind * @image_w + w_ind + all_crop_inds = all_crop_inds[..., 0] * image_w + all_crop_inds[..., 1] # shape [..., N, CH, CW] + all_crop_inds = tu.unsqueeze_expand_at(all_crop_inds, size=image_c, dim=-3) # shape [..., N, C, CH, CW] + all_crop_inds = tu.flatten(all_crop_inds, begin_axis=-2) # shape [..., N, C, CH * CW] + + # Repeat and flatten the source images -> [..., N, C, H * W] and then use gather to index with crop pixel inds + images_to_crop = tu.unsqueeze_expand_at(images, size=num_crops, dim=-4) + images_to_crop = tu.flatten(images_to_crop, begin_axis=-2) + crops = torch.gather(images_to_crop, dim=-1, index=all_crop_inds) + # [..., N, C, CH * CW] -> [..., N, C, CH, CW] + reshape_axis = len(crops.shape) - 1 + crops = tu.reshape_dimensions(crops, begin_axis=reshape_axis, end_axis=reshape_axis, + target_dims=(crop_height, crop_width)) + + if is_padded: + # undo padding -> [..., C, CH, CW] + crops = crops.squeeze(-4) + return crops + +def sample_random_image_crops(images, crop_height, crop_width, num_crops, pos_enc=False): + """ + For each image, randomly sample @num_crops crops of size (@crop_height, @crop_width), from + @images. + + Args: + images (torch.Tensor): batch of images of shape [..., C, H, W] + + crop_height (int): height of crop to take + + crop_width (int): width of crop to take + + num_crops (n): number of crops to sample + + pos_enc (bool): if True, also add 2 channels to the outputs that gives a spatial + encoding of the original source pixel locations. This means that the + output crops will contain information about where in the source image + it was sampled from. + + Returns: + crops (torch.Tensor): crops of shape (..., @num_crops, C, @crop_height, @crop_width) + if @pos_enc is False, otherwise (..., @num_crops, C + 2, @crop_height, @crop_width) + + crop_inds (torch.Tensor): sampled crop indices of shape (..., N, 2) + """ + device = images.device + + # maybe add 2 channels of spatial encoding to the source image + source_im = images + if pos_enc: + # spatial encoding [y, x] in [0, 1] + h, w = source_im.shape[-2:] + pos_y, pos_x = torch.meshgrid(torch.arange(h), torch.arange(w)) + pos_y = pos_y.float().to(device) / float(h) + pos_x = pos_x.float().to(device) / float(w) + position_enc = torch.stack((pos_y, pos_x)) # shape [C, H, W] + + # unsqueeze and expand to match leading dimensions -> shape [..., C, H, W] + leading_shape = source_im.shape[:-3] + position_enc = position_enc[(None,) * len(leading_shape)] + position_enc = position_enc.expand(*leading_shape, -1, -1, -1) + + # concat across channel dimension with input + source_im = torch.cat((source_im, position_enc), dim=-3) + + # make sure sample boundaries ensure crops are fully within the images + image_c, image_h, image_w = source_im.shape[-3:] + max_sample_h = image_h - crop_height + max_sample_w = image_w - crop_width + + # Sample crop locations for all tensor dimensions up to the last 3, which are [C, H, W]. + # Each gets @num_crops samples - typically this will just be the batch dimension (B), so + # we will sample [B, N] indices, but this supports having more than one leading dimension, + # or possibly no leading dimension. + # + # Trick: sample in [0, 1) with rand, then re-scale to [0, M) and convert to long to get sampled ints + crop_inds_h = (max_sample_h * torch.rand(*source_im.shape[:-3], num_crops).to(device)).long() + crop_inds_w = (max_sample_w * torch.rand(*source_im.shape[:-3], num_crops).to(device)).long() + crop_inds = torch.cat((crop_inds_h.unsqueeze(-1), crop_inds_w.unsqueeze(-1)), dim=-1) # shape [..., N, 2] + + crops = crop_image_from_indices( + images=source_im, + crop_indices=crop_inds, + crop_height=crop_height, + crop_width=crop_width, + ) + + return crops, crop_inds diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/model_getter.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/model_getter.py new file mode 100644 index 0000000000000000000000000000000000000000..6cacd7349e5d574702b080b5064c12ec3e4e628d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/model_getter.py @@ -0,0 +1,28 @@ +import torch +import torchvision + +def get_resnet(name, weights=None, **kwargs): + """ + name: resnet18, resnet34, resnet50 + weights: "IMAGENET1K_V1", "r3m" + """ + # load r3m weights + if (weights == "r3m") or (weights == "R3M"): + return get_r3m(name=name, **kwargs) + + func = getattr(torchvision.models, name) + resnet = func(weights=weights, **kwargs) + resnet.fc = torch.nn.Identity() + return resnet + +def get_r3m(name, **kwargs): + """ + name: resnet18, resnet34, resnet50 + """ + import r3m + r3m.device = 'cpu' + model = r3m.load_r3m(name) + r3m_model = model.module + resnet_model = r3m_model.convnet + resnet_model = resnet_model.to('cpu') + return resnet_model diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/multi_image_obs_encoder.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/multi_image_obs_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..de6aa65893b61d05f3d546e3a2871edef4a321cc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/model/vision/multi_image_obs_encoder.py @@ -0,0 +1,195 @@ +from typing import Dict, Tuple, Union +import copy +import torch +import torch.nn as nn +import torchvision +from diffusion_policy.model.vision.crop_randomizer import CropRandomizer +from diffusion_policy.model.common.module_attr_mixin import ModuleAttrMixin +from diffusion_policy.common.pytorch_util import dict_apply, replace_submodules + + +class MultiImageObsEncoder(ModuleAttrMixin): + def __init__(self, + shape_meta: dict, + rgb_model: Union[nn.Module, Dict[str,nn.Module]], + resize_shape: Union[Tuple[int,int], Dict[str,tuple], None]=None, + crop_shape: Union[Tuple[int,int], Dict[str,tuple], None]=None, + random_crop: bool=True, + # replace BatchNorm with GroupNorm + use_group_norm: bool=False, + # use single rgb model for all rgb inputs + share_rgb_model: bool=False, + # renormalize rgb input with imagenet normalization + # assuming input in [0,1] + imagenet_norm: bool=False + ): + """ + Assumes rgb input: B,C,H,W + Assumes low_dim input: B,D + """ + super().__init__() + + rgb_keys = list() + low_dim_keys = list() + key_model_map = nn.ModuleDict() + key_transform_map = nn.ModuleDict() + key_shape_map = dict() + + # handle sharing vision backbone + if share_rgb_model: + assert isinstance(rgb_model, nn.Module) + key_model_map['rgb'] = rgb_model + + obs_shape_meta = shape_meta['obs'] + for key, attr in obs_shape_meta.items(): + shape = tuple(attr['shape']) + type = attr.get('type', 'low_dim') + key_shape_map[key] = shape + if type == 'rgb': + rgb_keys.append(key) + # configure model for this key + this_model = None + if not share_rgb_model: + if isinstance(rgb_model, dict): + # have provided model for each key + this_model = rgb_model[key] + else: + assert isinstance(rgb_model, nn.Module) + # have a copy of the rgb model + this_model = copy.deepcopy(rgb_model) + + if this_model is not None: + if use_group_norm: + this_model = replace_submodules( + root_module=this_model, + predicate=lambda x: isinstance(x, nn.BatchNorm2d), + func=lambda x: nn.GroupNorm( + num_groups=x.num_features//16, + num_channels=x.num_features) + ) + key_model_map[key] = this_model + + # configure resize + input_shape = shape + this_resizer = nn.Identity() + if resize_shape is not None: + if isinstance(resize_shape, dict): + h, w = resize_shape[key] + else: + h, w = resize_shape + this_resizer = torchvision.transforms.Resize( + size=(h,w) + ) + input_shape = (shape[0],h,w) + + # configure randomizer + this_randomizer = nn.Identity() + if crop_shape is not None: + if isinstance(crop_shape, dict): + h, w = crop_shape[key] + else: + h, w = crop_shape + if random_crop: + this_randomizer = CropRandomizer( + input_shape=input_shape, + crop_height=h, + crop_width=w, + num_crops=1, + pos_enc=False + ) + else: + this_normalizer = torchvision.transforms.CenterCrop( + size=(h,w) + ) + # configure normalizer + this_normalizer = nn.Identity() + if imagenet_norm: + this_normalizer = torchvision.transforms.Normalize( + mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + + this_transform = nn.Sequential(this_resizer, this_randomizer, this_normalizer) + key_transform_map[key] = this_transform + elif type == 'low_dim': + low_dim_keys.append(key) + else: + raise RuntimeError(f"Unsupported obs type: {type}") + rgb_keys = sorted(rgb_keys) + low_dim_keys = sorted(low_dim_keys) + + self.shape_meta = shape_meta + self.key_model_map = key_model_map + self.key_transform_map = key_transform_map + self.share_rgb_model = share_rgb_model + self.rgb_keys = rgb_keys + self.low_dim_keys = low_dim_keys + self.key_shape_map = key_shape_map + + def forward(self, obs_dict): + batch_size = None + features = list() + # process rgb input + if self.share_rgb_model: + # pass all rgb obs to rgb model + imgs = list() + for key in self.rgb_keys: + img = obs_dict[key] + if batch_size is None: + batch_size = img.shape[0] + else: + assert batch_size == img.shape[0] + assert img.shape[1:] == self.key_shape_map[key] + img = self.key_transform_map[key](img) + imgs.append(img) + # (N*B,C,H,W) + imgs = torch.cat(imgs, dim=0) + # (N*B,D) + feature = self.key_model_map['rgb'](imgs) + # (N,B,D) + feature = feature.reshape(-1,batch_size,*feature.shape[1:]) + # (B,N,D) + feature = torch.moveaxis(feature,0,1) + # (B,N*D) + feature = feature.reshape(batch_size,-1) + features.append(feature) + else: + # run each rgb obs to independent models + for key in self.rgb_keys: + img = obs_dict[key] + if batch_size is None: + batch_size = img.shape[0] + else: + assert batch_size == img.shape[0] + assert img.shape[1:] == self.key_shape_map[key] + img = self.key_transform_map[key](img) + feature = self.key_model_map[key](img) + features.append(feature) + + # process lowdim input + for key in self.low_dim_keys: + data = obs_dict[key] + if batch_size is None: + batch_size = data.shape[0] + else: + assert batch_size == data.shape[0] + assert data.shape[1:] == self.key_shape_map[key] + features.append(data) + + # concatenate all features + result = torch.cat(features, dim=-1) + return result + + @torch.no_grad() + def output_shape(self): + example_obs_dict = dict() + obs_shape_meta = self.shape_meta['obs'] + batch_size = 1 + for key, attr in obs_shape_meta.items(): + shape = tuple(attr['shape']) + this_obs = torch.zeros( + (batch_size,) + shape, + dtype=self.dtype, + device=self.device) + example_obs_dict[key] = this_obs + example_output = self.forward(example_obs_dict) + output_shape = example_output.shape[1:] + return output_shape diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/base_lowdim_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/base_lowdim_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..8bbaafc0a2152d361808fb90883dcebba934678c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/base_lowdim_policy.py @@ -0,0 +1,36 @@ +from typing import Dict +import torch +import torch.nn as nn +from diffusion_policy.model.common.module_attr_mixin import ModuleAttrMixin +from diffusion_policy.model.common.normalizer import LinearNormalizer + +class BaseLowdimPolicy(ModuleAttrMixin): + # ========= inference ============ + # also as self.device and self.dtype for inference device transfer + def predict_action(self, obs_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """ + obs_dict: + obs: B,To,Do + return: + action: B,Ta,Da + To = 3 + Ta = 4 + T = 6 + |o|o|o| + | | |a|a|a|a| + |o|o| + | |a|a|a|a|a| + | | | | |a|a| + """ + raise NotImplementedError() + + # reset state for stateful policies + def reset(self): + pass + + # ========== training =========== + # no standard training interface except setting normalizer + def set_normalizer(self, normalizer: LinearNormalizer): + raise NotImplementedError() + + \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_transformer_lowdim_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_transformer_lowdim_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..7d0eff8a322e6921decea8ef90aa2bc504a5c10e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_transformer_lowdim_policy.py @@ -0,0 +1,234 @@ +from typing import Dict, Tuple +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange, reduce +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.model.diffusion.transformer_for_diffusion import TransformerForDiffusion +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +class DiffusionTransformerLowdimPolicy(BaseLowdimPolicy): + def __init__(self, + model: TransformerForDiffusion, + noise_scheduler: DDPMScheduler, + horizon, + obs_dim, + action_dim, + n_action_steps, + n_obs_steps, + num_inference_steps=None, + obs_as_cond=False, + pred_action_steps_only=False, + # parameters passed to step + **kwargs): + super().__init__() + if pred_action_steps_only: + assert obs_as_cond + + self.model = model + self.noise_scheduler = noise_scheduler + self.mask_generator = LowdimMaskGenerator( + action_dim=action_dim, + obs_dim=0 if (obs_as_cond) else obs_dim, + max_n_obs_steps=n_obs_steps, + fix_obs_steps=True, + action_visible=False + ) + self.normalizer = LinearNormalizer() + self.horizon = horizon + self.obs_dim = obs_dim + self.action_dim = action_dim + self.n_action_steps = n_action_steps + self.n_obs_steps = n_obs_steps + self.obs_as_cond = obs_as_cond + self.pred_action_steps_only = pred_action_steps_only + self.kwargs = kwargs + + if num_inference_steps is None: + num_inference_steps = noise_scheduler.config.num_train_timesteps + self.num_inference_steps = num_inference_steps + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + cond=None, generator=None, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + + # 2. predict model output + model_output = model(trajectory, t, cond) + + # 3. compute previous image: x_t -> x_t-1 + trajectory = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ).prev_sample + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + return trajectory + + + def predict_action(self, obs_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """ + obs_dict: must include "obs" key + result: must include "action" key + """ + + assert 'obs' in obs_dict + assert 'past_action' not in obs_dict # not implemented yet + nobs = self.normalizer['obs'].normalize(obs_dict['obs']) + B, _, Do = nobs.shape + To = self.n_obs_steps + assert Do == self.obs_dim + T = self.horizon + Da = self.action_dim + + # build input + device = self.device + dtype = self.dtype + + # handle different ways of passing observation + cond = None + cond_data = None + cond_mask = None + if self.obs_as_cond: + cond = nobs[:,:To] + shape = (B, T, Da) + if self.pred_action_steps_only: + shape = (B, self.n_action_steps, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + else: + # condition through impainting + shape = (B, T, Da+Do) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + cond_data[:,:To,Da:] = nobs[:,:To] + cond_mask[:,:To,Da:] = True + + # run sampling + nsample = self.conditional_sample( + cond_data, + cond_mask, + cond=cond, + **self.kwargs) + + # unnormalize prediction + naction_pred = nsample[...,:Da] + action_pred = self.normalizer['action'].unnormalize(naction_pred) + + # get action + if self.pred_action_steps_only: + action = action_pred + else: + start = To - 1 + end = start + self.n_action_steps + action = action_pred[:,start:end] + + result = { + 'action': action, + 'action_pred': action_pred + } + if not self.obs_as_cond: + nobs_pred = nsample[...,Da:] + obs_pred = self.normalizer['obs'].unnormalize(nobs_pred) + action_obs_pred = obs_pred[:,start:end] + result['action_obs_pred'] = action_obs_pred + result['obs_pred'] = obs_pred + return result + + # ========= training ============ + def set_normalizer(self, normalizer: LinearNormalizer): + self.normalizer.load_state_dict(normalizer.state_dict()) + + def get_optimizer( + self, weight_decay: float, learning_rate: float, betas: Tuple[float, float] + ) -> torch.optim.Optimizer: + return self.model.configure_optimizers( + weight_decay=weight_decay, + learning_rate=learning_rate, + betas=tuple(betas)) + + def compute_loss(self, batch): + # normalize input + assert 'valid_mask' not in batch + nbatch = self.normalizer.normalize(batch) + obs = nbatch['obs'] + action = nbatch['action'] + + # handle different ways of passing observation + cond = None + trajectory = action + if self.obs_as_cond: + cond = obs[:,:self.n_obs_steps,:] + if self.pred_action_steps_only: + To = self.n_obs_steps + start = To - 1 + end = start + self.n_action_steps + trajectory = action[:,start:end] + else: + trajectory = torch.cat([action, obs], dim=-1) + + # generate impainting mask + if self.pred_action_steps_only: + condition_mask = torch.zeros_like(trajectory, dtype=torch.bool) + else: + condition_mask = self.mask_generator(trajectory.shape) + + # Sample noise that we'll add to the images + noise = torch.randn(trajectory.shape, device=trajectory.device) + bsz = trajectory.shape[0] + # Sample a random timestep for each image + timesteps = torch.randint( + 0, self.noise_scheduler.config.num_train_timesteps, + (bsz,), device=trajectory.device + ).long() + # Add noise to the clean images according to the noise magnitude at each timestep + # (this is the forward diffusion process) + noisy_trajectory = self.noise_scheduler.add_noise( + trajectory, noise, timesteps) + + # compute loss mask + loss_mask = ~condition_mask + + # apply conditioning + noisy_trajectory[condition_mask] = trajectory[condition_mask] + + # Predict the noise residual + pred = self.model(noisy_trajectory, timesteps, cond) + + pred_type = self.noise_scheduler.config.prediction_type + if pred_type == 'epsilon': + target = noise + elif pred_type == 'sample': + target = trajectory + else: + raise ValueError(f"Unsupported prediction type {pred_type}") + + loss = F.mse_loss(pred, target, reduction='none') + loss = loss * loss_mask.type(loss.dtype) + loss = reduce(loss, 'b ... -> b (...)', 'mean') + loss = loss.mean() + return loss diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_cost_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_cost_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..714748c119c515ebe33b039b908fc0255ab97071 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_cost_policy.py @@ -0,0 +1,177 @@ +from typing import Dict +import torch +import torch.nn as nn +import torch.nn.functional as F +import pdb +import logging +from einops import rearrange, reduce +from copy import deepcopy +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.model.guide.cost_guide import CostGuide +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +logger = logging.getLogger(__name__) + +class DiffusionUnetLowdimCostPolicy(DiffusionUnetLowdimPolicy): + def __init__(self, + guide: CostGuide, + n_guide_steps=2, + grad_scale=1., + t_stopgrad = 2, + threshold=1e-6, + point_estimate=False, + *args, + **kwargs): + super().__init__(*args, **kwargs) + + self.guide = guide + self.n_guide_steps = n_guide_steps + self.grad_scale = grad_scale + self.t_stopgrad = t_stopgrad + self.threshold = threshold + self.pt_est:bool = point_estimate + + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + guide=None, + local_cond=None, global_cond=None, + generator=None, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + if guide is None: + guide = self.guide + threshold = self.threshold + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + guide_step_cnt = 0 + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + # 2. predict model output + model_output = model( + trajectory, + t, + local_cond=local_cond, + global_cond=global_cond + ) + # 3. compute previous timestep trj: x_t -> x_t-1 + scheduler_output_t = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ) + trj_tm1 = scheduler_output_t.prev_sample + + # 4. check whether guidance is needed, if we use point estimate + if self.pt_est and t >= max(1, self.t_stopgrad): + with torch.no_grad(): + # compute the original sample trj_0 from t_2: x_t-1 -> x_0 + trj_0_from_tm1 = self.predict_x_0( + trj_tm1, + t - 1, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + cost = guide( + torch.tensor_split(trj_0_from_tm1, 2, dim=0), + ) + if cost.max() < threshold: + ## no guidance needed in this step + trajectory = trj_tm1 + continue + #end with [torch.no_grad()] + #end if [t >= ...] + + # 5. perform gradient descent for guidance + with torch.enable_grad(): + ## Get x_0_hat from timestep t: x_t -> x_0_hat + trj_t = trajectory.detach().requires_grad_() + if self.pt_est: + trj_0_hat_from_t = self.predict_x_0( + trj_t, + t, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + + ## Compute the (common) cost for two players + cost, grads = guide.gradients( + input_vars = torch.tensor_split(trj_0_hat_from_t, 2, dim=0), + with_respect_to = (trj_t,), + ) + else: + ### Not using point estimate, so we compute cost directly from x_t + cost, grads = guide.gradients( + input_vars = torch.tensor_split(trj_t, 2, dim=0), + with_respect_to = (trj_t,), + ) + #end if [self.pt_est] + grad = grads[0] # gradients with respect to the trajectory + + ## Do gradient descent for coupling via cost + for _ in range(self.n_guide_steps): + ## stop guidance near the end + if t < self.t_stopgrad: + break + + trj_tm1 = trj_tm1 - self.grad_scale * grad + guide_step_cnt += 1 + + #end for + trajectory = trj_tm1 + #end for + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + # logger.info(f"Guided steps: {guide_step_cnt}\n") + + return trajectory + + + def predict_x_0(self, + x_t, + t, + local_cond, global_cond, + scheduler: DDPMScheduler, + generator, + **kwargs): + model = self.model + model_output_t = model( + x_t, + t, + local_cond=local_cond, + global_cond=global_cond + ) + x_0 = scheduler.step( + model_output_t, + t, + x_t, + generator=generator, + **kwargs + ).pred_original_sample + + return x_0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..c01ed7a8cd2f60a1492f79842ec0db79ddde349f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_policy.py @@ -0,0 +1,252 @@ +from typing import Dict +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange, reduce +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +class DiffusionUnetLowdimPolicy(BaseLowdimPolicy): + def __init__(self, + model: ConditionalUnet1D, + noise_scheduler: DDPMScheduler, + horizon, + obs_dim, + action_dim, + n_action_steps, + n_obs_steps, + num_inference_steps=None, + obs_as_local_cond=False, + obs_as_global_cond=False, + pred_action_steps_only=False, + oa_step_convention=False, + # parameters passed to step + **kwargs): + super().__init__() + assert not (obs_as_local_cond and obs_as_global_cond) + if pred_action_steps_only: + assert obs_as_global_cond + self.model = model + self.noise_scheduler = noise_scheduler + self.mask_generator = LowdimMaskGenerator( + action_dim=action_dim, + obs_dim=0 if (obs_as_local_cond or obs_as_global_cond) else obs_dim, + max_n_obs_steps=n_obs_steps, + fix_obs_steps=True, + action_visible=False + ) + self.normalizer = LinearNormalizer() + self.horizon = horizon + self.obs_dim = obs_dim + self.action_dim = action_dim + self.n_action_steps = n_action_steps + self.n_obs_steps = n_obs_steps + self.obs_as_local_cond = obs_as_local_cond + self.obs_as_global_cond = obs_as_global_cond + self.pred_action_steps_only = pred_action_steps_only + self.oa_step_convention = oa_step_convention + self.kwargs = kwargs + + if num_inference_steps is None: + num_inference_steps = noise_scheduler.config.num_train_timesteps + self.num_inference_steps = num_inference_steps + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + local_cond=None, global_cond=None, + generator=None, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + + # 2. predict model output + model_output = model(trajectory, t, + local_cond=local_cond, global_cond=global_cond) + + # 3. compute previous image: x_t -> x_t-1 + trajectory = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ).prev_sample + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + return trajectory + + + def predict_action(self, obs_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """ + obs_dict: must include "obs" key + result: must include "action" key + """ + + assert 'obs' in obs_dict + assert 'past_action' not in obs_dict # not implemented yet + nobs = self.normalizer['obs'].normalize(obs_dict['obs']) + B, _, Do = nobs.shape + To = self.n_obs_steps + assert Do == self.obs_dim + T = self.horizon + Da = self.action_dim + + # build input + device = self.device + dtype = self.dtype + + # handle different ways of passing observation + local_cond = None + global_cond = None + if self.obs_as_local_cond: + # condition through local feature + # all zero except first To timesteps + local_cond = torch.zeros(size=(B,T,Do), device=device, dtype=dtype) + local_cond[:,:To] = nobs[:,:To] + shape = (B, T, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + elif self.obs_as_global_cond: + # condition throught global feature + global_cond = nobs[:,:To].reshape(nobs.shape[0], -1) + shape = (B, T, Da) + if self.pred_action_steps_only: + shape = (B, self.n_action_steps, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + else: + # condition through impainting + shape = (B, T, Da+Do) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + cond_data[:,:To,Da:] = nobs[:,:To] + cond_mask[:,:To,Da:] = True + + # run sampling + nsample = self.conditional_sample( + cond_data, + cond_mask, + local_cond=local_cond, + global_cond=global_cond, + **self.kwargs) + + # unnormalize prediction + naction_pred = nsample[...,:Da] + action_pred = self.normalizer['action'].unnormalize(naction_pred) + + # get action + if self.pred_action_steps_only: + action = action_pred + else: + start = To + if self.oa_step_convention: + start = To - 1 + end = start + self.n_action_steps + action = action_pred[:,start:end] + + result = { + 'action': action, + 'action_pred': action_pred + } + if not (self.obs_as_local_cond or self.obs_as_global_cond): + nobs_pred = nsample[...,Da:] + obs_pred = self.normalizer['obs'].unnormalize(nobs_pred) + action_obs_pred = obs_pred[:,start:end] + result['action_obs_pred'] = action_obs_pred + result['obs_pred'] = obs_pred + return result + + # ========= training ============ + def set_normalizer(self, normalizer: LinearNormalizer): + self.normalizer.load_state_dict(normalizer.state_dict()) + + def compute_loss(self, batch): + # normalize input + assert 'valid_mask' not in batch + nbatch = self.normalizer.normalize(batch) + obs = nbatch['obs'] + action = nbatch['action'] + + # handle different ways of passing observation + local_cond = None + global_cond = None + trajectory = action + if self.obs_as_local_cond: + # zero out observations after n_obs_steps + local_cond = obs + local_cond[:,self.n_obs_steps:,:] = 0 + elif self.obs_as_global_cond: + global_cond = obs[:,:self.n_obs_steps,:].reshape( + obs.shape[0], -1) + if self.pred_action_steps_only: + To = self.n_obs_steps + start = To + if self.oa_step_convention: + start = To - 1 + end = start + self.n_action_steps + trajectory = action[:,start:end] + else: + trajectory = torch.cat([action, obs], dim=-1) + + # generate impainting mask + if self.pred_action_steps_only: + condition_mask = torch.zeros_like(trajectory, dtype=torch.bool) + else: + condition_mask = self.mask_generator(trajectory.shape) + + # Sample noise that we'll add to the images + noise = torch.randn(trajectory.shape, device=trajectory.device) + bsz = trajectory.shape[0] + # Sample a random timestep for each image + timesteps = torch.randint( + 0, self.noise_scheduler.config.num_train_timesteps, + (bsz,), device=trajectory.device + ).long() + # Add noise to the clean images according to the noise magnitude at each timestep + # (this is the forward diffusion process) + noisy_trajectory = self.noise_scheduler.add_noise( + trajectory, noise, timesteps) + + # compute loss mask + loss_mask = ~condition_mask + + # apply conditioning + noisy_trajectory[condition_mask] = trajectory[condition_mask] + + # Predict the noise residual + pred = self.model(noisy_trajectory, timesteps, + local_cond=local_cond, global_cond=global_cond) + + pred_type = self.noise_scheduler.config.prediction_type + if pred_type == 'epsilon': + target = noise + elif pred_type == 'sample': + target = trajectory + else: + raise ValueError(f"Unsupported prediction type {pred_type}") + + loss = F.mse_loss(pred, target, reduction='none') + loss = loss * loss_mask.type(loss.dtype) + loss = reduce(loss, 'b ... -> b (...)', 'mean') + loss = loss.mean() + return loss diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_postsamp_guide.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_postsamp_guide.py new file mode 100644 index 0000000000000000000000000000000000000000..7ff4e9d53a6df6a0255b3c01fd5ec89d68c6137a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_postsamp_guide.py @@ -0,0 +1,172 @@ +from typing import Dict +import torch +import torch.nn as nn +import torch.nn.functional as F +import pdb +import logging +from einops import rearrange, reduce +from copy import deepcopy +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.model.guide.ps_guide import PSGuide +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +logger = logging.getLogger(__name__) + +class DiffusionUnetLowdimPSPolicy(DiffusionUnetLowdimPolicy): + def __init__(self, + guide: PSGuide, + n_guide_steps=2, + grad_scale=1., + t_stopgrad = 2, + *args, + **kwargs): + super().__init__(*args, **kwargs) + + self.guide = guide + self.n_guide_steps = n_guide_steps + self.grad_scale = grad_scale + self.t_stopgrad = t_stopgrad + + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + guide=None, + local_cond=None, global_cond=None, + generator=None, + threshold=0.0, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + if guide is None: + guide = self.guide + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + guide_step_cnt = 0 + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + # 2. predict model output + model_output = model( + trajectory, + t, + local_cond=local_cond, + global_cond=global_cond + ) + # 3. compute previous timestep trj: x_t -> x_t-1 + scheduler_output_t = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ) + trj_t_1 = scheduler_output_t.prev_sample + with torch.no_grad(): + if t >= max(1, self.t_stopgrad): + # compute the original sample trj_0 from t_2: x_t-1 -> x_0 + trj_0_from_t_1 = self.predict_x_0( + trj_t_1, + t - 1, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + unnormed_trj_0_from_t_1 = self.normalizer['action'].unnormalize(trj_0_from_t_1) + y = guide(unnormed_trj_0_from_t_1) + if y.min() >= threshold: + ## no guidance needed in this step + trajectory = trj_t_1 + continue + + # 4. perform gradient ascent for guidance + with torch.enable_grad(): + ## Get x_0_hat from timestep t: x_t -> x_0_hat + trj_t = trajectory.detach().requires_grad_() + trj_0_hat_from_t = self.predict_x_0( + trj_t, + t, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + y, grad = guide.gradients( + x_prev = trj_t, + x_0_hat = trj_0_hat_from_t, + normalized = True, + custom_normalizer = self.normalizer['action'] + ) + + for _ in range(self.n_guide_steps): + ## stop guidance near the end + if t < self.t_stopgrad: + break + + trj_t_1 = trj_t_1 + self.grad_scale * grad + guide_step_cnt += 1 + ## if the predicted value from t-1 satisfies, stop guidance + trj_0_from_t_1 = self.predict_x_0( + trj_t_1, + t - 1, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + unnormed_trj_0_from_t_1 = self.normalizer['action'].unnormalize(trj_0_from_t_1) + y = guide(unnormed_trj_0_from_t_1) + if y.min() >= threshold: + break + grad[y>threshold] = 0 + #end for + trajectory = trj_t_1 + #end for + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + # logger.info(f"Guided steps: {guide_step_cnt}\n") + + return trajectory + + + def predict_x_0(self, + x_t, + t, + local_cond, global_cond, + scheduler: DDPMScheduler, + generator, + **kwargs): + model = self.model + model_output_t = model( + x_t, + t, + local_cond=local_cond, + global_cond=global_cond + ) + x_0 = scheduler.step( + model_output_t, + t, + x_t, + generator=generator, + **kwargs + ).pred_original_sample + + return x_0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_coup_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_coup_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..5eccad14d30fa4df063c8f392a596c6ed5b288dd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_coup_policy.py @@ -0,0 +1,358 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import numpy as np +import pdb +import logging +import omegaconf +from omegaconf import OmegaConf +from einops import rearrange, reduce +from copy import deepcopy +from typing import Union, Dict, List, Optional +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.diffusion_unet_lowdim_cost_policy import DiffusionUnetLowdimCostPolicy +from diffusion_policy.model.projection.projection_operator import BaseProjectionOperator +from diffusion_policy.model.guide.cost_guide import CostGuide +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +logger = logging.getLogger(__name__) + +class DiffusionUnetLowdimProjCoupPolicy(DiffusionUnetLowdimCostPolicy): + def __init__(self, + guide: CostGuide, + projector: BaseProjectionOperator, + project_params: Union[Dict, omegaconf.dictconfig.DictConfig], + *args, + n_guide_steps=1, + grad_scale=1., + t_stopgrad = 2, + threshold=1e-6, + point_estimate=False, + timeit=False, + **kwargs): + super().__init__( + *args, + guide=guide, + n_guide_steps=n_guide_steps, + grad_scale=grad_scale, + t_stopgrad=t_stopgrad, + threshold=threshold, + point_estimate=point_estimate, + **kwargs + ) + ## Projection + self.projector = projector + self.project_params:Dict = {} + if isinstance(project_params, dict): + self.project_params = project_params + else: + assert isinstance(project_params, omegaconf.dictconfig.DictConfig) + self.project_params = OmegaConf.to_container(project_params, resolve=True) + + for k, v in self.project_params.items(): + self.project_params[k] = torch.as_tensor(v, dtype=torch.float32, device=self.device) + + assert not timeit, "Timeit is not supported in this policy yet." + self.timeit = False + + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + guide=None, + local_cond=None, global_cond=None, + generator=None, + # keyword arguments to scheduler.step + **kwargs + )->torch.Tensor: + model = self.model + scheduler = self.noise_scheduler + if guide is None: + guide = self.guide + threshold = self.threshold + return_grads = kwargs.pop('return_grads', False) + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + ## Prepare trajectory gradients + if return_grads: + guidance_gradients:List[torch.Tensor] = [] + + ## -------------------- Projection Preparation ------------------- + # restore observation + if self.obs_as_local_cond: + obs = local_cond + elif self.obs_as_global_cond: + # condition throught global feature + obs = global_cond.reshape( + global_cond.shape[0], self.n_obs_steps, -1) + else: + obs = condition_data[condition_mask] + assert obs.ndim == 3 + + # set projection parameters + ### Attention! obs and action uses different normalizers. + ### so projection parameters we need to use the one from the action normalizer! + obs_unnormed = self.normalizer['obs'].unnormalize(obs.detach().clone()) + nobs_in_nact = self.normalizer['action'].normalize( + obs_unnormed[:, :, -self.action_dim:] # only the last action_dim elements are used for projection, + ) + proj_params = { + 'pos_init': nobs_in_nact[:, -1, :], # initial position + 'dx_max': self.project_params['vel_max'] * self.project_params['delta_t'], + } + ## -------------------- Projection Preparation END ------------------- + + guide_step_cnt = 0 + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + # 2. predict model output + model_output = model( + trajectory, + t, + local_cond=local_cond, + global_cond=global_cond + ) + # 3. compute previous timestep trj: x_t -> x_t-1 + scheduler_output_t = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ) + + ## --------------------------- Coupling --------------------------- + trj_tm1 = scheduler_output_t.prev_sample + + # 4. check whether guidance is needed, if we use point estimate + if self.pt_est and t >= max(1, self.t_stopgrad): + with torch.no_grad(): + # compute the original sample trj_0 from t_2: x_t-1 -> x_0 + trj_0_from_tm1 = self.predict_x_0( + trj_tm1, + t - 1, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + cost = guide( + torch.tensor_split(trj_0_from_tm1, 2, dim=0), + ) + if cost.max() < threshold: + ## no guidance needed in this step + trajectory = trj_tm1 + continue + #end with [torch.no_grad()] + #end if [t >= ...] + + # 5. perform gradient descent for guidance + with torch.enable_grad(): + ## Get x_0_hat from timestep t: x_t -> x_0_hat + trj_t = trajectory.detach().requires_grad_() + if self.pt_est: + trj_0_hat_from_t = self.predict_x_0( + trj_t, + t, + local_cond=local_cond, + global_cond=global_cond, + scheduler=scheduler, + generator=generator, + **kwargs + ) + + ## Compute the (common) cost for two players + cost, grads = guide.gradients( + input_vars = torch.tensor_split(trj_0_hat_from_t, 2, dim=0), + with_respect_to = (trj_t,), + ) + else: + ### Not using point estimate, so we compute cost directly from x_t + cost, grads = guide.gradients( + input_vars = torch.tensor_split(trj_t, 2, dim=0), + with_respect_to = (trj_t,), + ) + #end if [self.pt_est] + grad = grads[0] # gradients with respect to the trajectory + + ## prep the scaled gradient + beta_t = scheduler.betas[t] # scalar tensor + alpha_t = scheduler.alphas[t] # scalar tensor + grad_scl = self.grad_scale * beta_t / torch.sqrt(alpha_t) + scaled_grad = grad_scl * grad + + ## Do gradient descent for coupling via cost + for _ in range(self.n_guide_steps): + ## stop guidance near the end + if t < self.t_stopgrad: + break + + trj_tm1 = trj_tm1 - scaled_grad + guide_step_cnt += 1 + if return_grads: + guidance_gradients.append(grad.clone().detach().to('cpu')) + #end for + trajectory = trj_tm1 + ## -------------------------- Coupling END ----------------------------- + + ## -------------------------- Projection ------------------------------- + # Project guided trajectory onto sets + h_start = self.n_obs_steps - int(self.oa_step_convention) + traj_to_proj = trajectory[..., h_start:, :] + if self.timeit: + raise NotImplementedError( + "Timeit is not supported in this policy yet.") + else: + projected_traj = self.projector.project( + traj_to_proj, + params = proj_params, + ) + ## We put back the projected part without modifying the first n_obs_steps(-1) steps + ## which will be discarded later anyway + trajectory[..., h_start:, :] = projected_traj + ## Optionally, we can do inpainting on the first n_obs_steps(-1) steps + ## this might provide a "smoother" trajectory instead of one that is partially noisy and + ## partially projected ) + if h_start > 0: + trajectory[..., :h_start, :] = nobs_in_nact[:, -h_start:,:] + ## -------------------------- Projection END --------------------------- + #end for [t] loop + + # Check projected trajecoties + # self.sanity_check(projected_traj, proj_params['pos_init'], proj_params['dx_max'], eps=1e-4) + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + # logger.info(f"Guided steps: {guide_step_cnt}\n") + if return_grads: + # guidance_gradients is a list of tensors, each tensor is of shape [B, H, D] + # where B is the batch size, T is the number of timesteps, H is the horizon, and D is dimension + # we stack them along the second dimension to get a tensor of shape [B, T_X, H, D] + # if n_guide_steps == 1 and t_stopgrad == 1, then it will be of shape [B, T, H, D] + guidance_gradients = torch.stack(guidance_gradients, dim=1) # [B, T, H, D] if n_guide_steps==1 && t_stopgrad==1 + + if return_grads: + return trajectory, guidance_gradients + + return trajectory + + + def sanity_check(self, traj, x0, d_max, eps=1e-6): + with torch.no_grad(): + dx0 = traj[:, 0, :] - x0 + dx0_norm = torch.linalg.norm(dx0, dim=-1) + violation:torch.Tensor = dx0_norm - d_max + max_violation = violation.max().item() + if max_violation > eps: + logger.warning( + f"Trajectory contains steps exceeding max distance {d_max}; " + f" Max violation: {max_violation:.3e} (> {eps:.3e})" + ) + return False + else: + return True + + + def predict_action(self, + obs_dict: Dict[str, torch.Tensor], + return_grads: bool = False, + ) -> Dict[str, torch.Tensor]: + """ + obs_dict: must include "obs" key + result: must include "action" key + """ + + assert 'obs' in obs_dict + assert 'past_action' not in obs_dict # not implemented yet + nobs = self.normalizer['obs'].normalize(obs_dict['obs']) + B, _, Do = nobs.shape + To = self.n_obs_steps + assert Do == self.obs_dim + T = self.horizon + Da = self.action_dim + + # build input + device = self.device + dtype = self.dtype + + # handle different ways of passing observation + local_cond = None + global_cond = None + if self.obs_as_local_cond: + # condition through local feature + # all zero except first To timesteps + local_cond = torch.zeros(size=(B,T,Do), device=device, dtype=dtype) + local_cond[:,:To] = nobs[:,:To] + shape = (B, T, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + elif self.obs_as_global_cond: + # condition throught global feature + global_cond = nobs[:,:To].reshape(nobs.shape[0], -1) + shape = (B, T, Da) + if self.pred_action_steps_only: + shape = (B, self.n_action_steps, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + else: + # condition through impainting + shape = (B, T, Da+Do) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + cond_data[:,:To,Da:] = nobs[:,:To] + cond_mask[:,:To,Da:] = True + + # run sampling + nsample = self.conditional_sample( + cond_data, + cond_mask, + local_cond=local_cond, + global_cond=global_cond, + return_grads=return_grads, + **self.kwargs) + if return_grads: + nsample, guidance_grads = nsample + + # unnormalize prediction + naction_pred = nsample[...,:Da] + action_pred = self.normalizer['action'].unnormalize(naction_pred) + if return_grads: + # unnormalize gradients + norm_scl:torch.Tensor = self.normalizer['action'].params_dict['scale'].data + guidance_grads /= norm_scl.to(device=guidance_grads.device, dtype=guidance_grads.dtype) + + # get action + if self.pred_action_steps_only: + action = action_pred + else: + start = To + if self.oa_step_convention: + start = To - 1 + end = start + self.n_action_steps + action = action_pred[:,start:end] + if return_grads: + guidance_grads = guidance_grads[:,:,start:end] + + result = { + 'action': action, + 'action_pred': action_pred + } + if return_grads: + result['guidance_grads'] = guidance_grads + if not (self.obs_as_local_cond or self.obs_as_global_cond): + nobs_pred = nsample[...,Da:] + obs_pred = self.normalizer['obs'].unnormalize(nobs_pred) + action_obs_pred = obs_pred[:,start:end] + result['action_obs_pred'] = action_obs_pred + result['obs_pred'] = obs_pred + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_policy.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..f14177a93a32d861c578fdf27982e1ca214bb233 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_proj_policy.py @@ -0,0 +1,254 @@ +from typing import Dict +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import pdb +import logging +import omegaconf +from omegaconf import OmegaConf +from einops import rearrange, reduce +from copy import deepcopy +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.model.projection.projection_operator import BaseProjectionOperator +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator +from diffusion_policy.common.pytorch_util import dict_apply + +logger = logging.getLogger(__name__) + +class DiffusionUnetLowdimProjPolicy(DiffusionUnetLowdimPolicy): + def __init__(self, + projector: BaseProjectionOperator, + project_params: Dict = None, + timeit: bool = False, + *args, + **kwargs): + super().__init__(*args, **kwargs) + + self.projector = projector + if project_params is None or isinstance(project_params, dict): + self.project_params = project_params if project_params is not None else {} + else: + assert isinstance(project_params, omegaconf.dictconfig.DictConfig) + self.project_params = OmegaConf.to_container(project_params, resolve=True) + + for k, v in self.project_params.items(): + self.project_params[k] = torch.as_tensor(v, dtype=torch.float32, device=self.device) + + self.timeit = timeit + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + local_cond=None, global_cond=None, + generator=None, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + # restore observation + if self.obs_as_local_cond: + obs = local_cond + elif self.obs_as_global_cond: + # condition throught global feature + obs = global_cond.reshape(global_cond.shape[0], self.n_obs_steps, -1) + else: + obs = condition_data[condition_mask] + assert obs.ndim == 3 + + # set projection parameters + ### Attention! obs and action uses different normalizers. + ### so projection parameters we need to use the one from the action normalizer! + obs_unnormed = self.normalizer['obs'].unnormalize(obs.detach().clone()) + nobs_in_nact = self.normalizer['action'].normalize( + obs_unnormed[:, :, -self.action_dim:] # only the last action_dim elements are used for projection, + ) + proj_params = { + 'pos_init': nobs_in_nact[:, -1, :], # initial position + 'dx_max': self.project_params['vel_max'] * self.project_params['delta_t'], + } + + # pdb.set_trace() + if self.timeit: + tbench = {} + + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + + # 2. predict model output + model_output = model(trajectory, t, + local_cond=local_cond, + global_cond=global_cond + ) + + # 3. compute previous image: x_t -> x_t-1 + trajectory = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ).prev_sample + + + # 4. project trajectory onto sets + h_start = self.n_obs_steps - int(self.oa_step_convention) + traj_to_proj = trajectory[..., h_start:, :] + if self.timeit: + projected_traj, tbench_this_step = self.projector.project( + traj_to_proj, + params = proj_params, + timeit=True, + ) + if len(tbench) == 0: + tbench.update(tbench_this_step) + else: + for k, v in tbench_this_step.items(): + if k in tbench: + tbench[k] = np.concatenate([tbench[k], v], axis=0) + else: + tbench[k] = v + else: + projected_traj = self.projector.project( + traj_to_proj, + params = proj_params, + ) + ## We put back the projected part without modifying the first n_obs_steps(-1) steps + ## which will be discarded later anyway + trajectory[..., h_start:, :] = projected_traj + ## Optionally, we can do inpainting on the first n_obs_steps(-1) steps + ## this might provide a "smoother" trajectory but not the one that is partially noisy and partially projected + if h_start > 0: + trajectory[..., :h_start, :] = nobs_in_nact[:, -h_start:,:] + + # pdb.set_trace() + pass + #end for t + + + if self.timeit: + tbench = dict_apply( + tbench, + lambda _x: _x.mean() + ) + t_sum = tbench['total'].mean() + for k, v in tbench.items(): + logger.info(f"Projection time {k}: {v:.4f} s ({v / t_sum:.2%} of total)") + + self.sanity_check(projected_traj, proj_params['pos_init'], proj_params['dx_max']) + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + return trajectory + + def sanity_check(self, traj, x0, d_max, eps=1e-6): + with torch.no_grad(): + dx0 = traj[:, 0, :] - x0 + dx0_norm = torch.linalg.norm(dx0, dim=-1) + violation:torch.Tensor = dx0_norm - d_max + max_violation = violation.max().item() + if max_violation > eps: + logger.warning( + f"Trajectory contains steps exceeding max distance {d_max}; " + f" Max violation: {max_violation:.3e} (> {eps:.3e})" + ) + return False + else: + return True + + + ## For debug only! + def predict_action(self, obs_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """ + obs_dict: must include "obs" key + result: must include "action" key + """ + + assert 'obs' in obs_dict + assert 'past_action' not in obs_dict # not implemented yet + nobs = self.normalizer['obs'].normalize(obs_dict['obs']) + B, _, Do = nobs.shape + To = self.n_obs_steps + assert Do == self.obs_dim + T = self.horizon + Da = self.action_dim + + # build input + device = self.device + dtype = self.dtype + + # handle different ways of passing observation + local_cond = None + global_cond = None + if self.obs_as_local_cond: + # condition through local feature + # all zero except first To timesteps + local_cond = torch.zeros(size=(B,T,Do), device=device, dtype=dtype) + local_cond[:,:To] = nobs[:,:To] + shape = (B, T, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + elif self.obs_as_global_cond: + # condition throught global feature + global_cond = nobs[:,:To].reshape(nobs.shape[0], -1) + shape = (B, T, Da) + if self.pred_action_steps_only: + shape = (B, self.n_action_steps, Da) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + else: + # condition through impainting + shape = (B, T, Da+Do) + cond_data = torch.zeros(size=shape, device=device, dtype=dtype) + cond_mask = torch.zeros_like(cond_data, dtype=torch.bool) + cond_data[:,:To,Da:] = nobs[:,:To] + cond_mask[:,:To,Da:] = True + + # run sampling + nsample = self.conditional_sample( + cond_data, + cond_mask, + local_cond=local_cond, + global_cond=global_cond, + **self.kwargs) + + # unnormalize prediction + naction_pred = nsample[...,:Da] + action_pred = self.normalizer['action'].unnormalize(naction_pred) + + # get action + if self.pred_action_steps_only: + action = action_pred + else: + start = To + if self.oa_step_convention: + start = To - 1 + end = start + self.n_action_steps + action = action_pred[:,start:end] + + result = { + 'action': action, + 'action_pred': action_pred + } + if not (self.obs_as_local_cond or self.obs_as_global_cond): + nobs_pred = nsample[...,Da:] + obs_pred = self.normalizer['obs'].unnormalize(nobs_pred) + action_obs_pred = obs_pred[:,start:end] + result['action_obs_pred'] = action_obs_pred + result['obs_pred'] = obs_pred + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_regression_guide.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_regression_guide.py new file mode 100644 index 0000000000000000000000000000000000000000..73e38d4b8c7d911edbdad9c5c42036d483338fd5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_lowdim_regression_guide.py @@ -0,0 +1,96 @@ +from typing import Dict +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange, reduce +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.model.guide.value_guide import ValueGuide +from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +class DiffusionUnetLowdimRGPolicy(DiffusionUnetLowdimPolicy): + def __init__(self, + guide: ValueGuide, + n_guide_steps=2, + grad_scale=1., + t_stopgrad = 2, + *args, + **kwargs): + super().__init__(*args, **kwargs) + + self.guide = guide + self.n_guide_steps = n_guide_steps + self.grad_scale = grad_scale + self.t_stopgrad = t_stopgrad + + + # ========= inference ============ + def conditional_sample(self, + condition_data, condition_mask, + guide=None, + local_cond=None, global_cond=None, + generator=None, + # keyword arguments to scheduler.step + **kwargs + ): + model = self.model + scheduler = self.noise_scheduler + if guide is None: + guide = self.guide + + trajectory = torch.randn( + size=condition_data.shape, + dtype=condition_data.dtype, + device=condition_data.device, + generator=generator) + + # set step values + scheduler.set_timesteps(self.num_inference_steps) + + for t in scheduler.timesteps: + # 1. apply conditioning + trajectory[condition_mask] = condition_data[condition_mask] + + # 1.1 perform gradient ascent for guidance + for _ in range(self.n_guide_steps): + ## stop guidance near the end + if t < self.t_stopgrad: + break + + with torch.enable_grad(): + y, grad = guide.gradients( + trajectory, + t, + local_cond=local_cond, + global_cond=global_cond + ) + ## if the predicted value satisfies, stop guidance + if y.min()>=0: + break + grad[y>0] = 0 + trajectory = trajectory + self.grad_scale * grad + #end for + # 2. predict model output + model_output = model( + trajectory, + t, + local_cond=local_cond, + global_cond=global_cond + ) + + # 3. compute previous image: x_t -> x_t-1 + trajectory = scheduler.step( + model_output, t, trajectory, + generator=generator, + **kwargs + ).prev_sample + + # finally make sure conditioning is enforced + trajectory[condition_mask] = condition_data[condition_mask] + + return trajectory + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_ltl_value.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_ltl_value.py new file mode 100644 index 0000000000000000000000000000000000000000..b111d18398b43fba5837665f03300f8ddaf07828 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/policy/diffusion_unet_ltl_value.py @@ -0,0 +1,156 @@ +from typing import Dict +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange, reduce +from diffusers.schedulers.scheduling_ddpm import DDPMScheduler + +from diffusion_policy.model.common.normalizer import LinearNormalizer +from diffusion_policy.policy.base_lowdim_policy import BaseLowdimPolicy +from diffusion_policy.model.diffusion.value_classifier import ConditionalValueUnet1D +from diffusion_policy.model.diffusion.mask_generator import LowdimMaskGenerator + +class DiffusionUnetLTLValue(BaseLowdimPolicy): + def __init__(self, + model: ConditionalValueUnet1D, + horizon, + obs_dim, + action_dim, + n_action_steps, + n_obs_steps, + noise_scheduler: DDPMScheduler, + num_inference_steps=None, + obs_as_local_cond=False, + obs_as_global_cond=False, + pred_action_steps_only=False, + oa_step_convention=False, + # parameters passed to step + **kwargs): + super().__init__() + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + assert not (obs_as_local_cond and obs_as_global_cond) + if pred_action_steps_only: + assert obs_as_global_cond + self.model = model.to(device) + self.mask_generator = LowdimMaskGenerator( + action_dim=action_dim, + obs_dim=0 if (obs_as_local_cond or obs_as_global_cond) else obs_dim, + max_n_obs_steps=n_obs_steps, + fix_obs_steps=True, + action_visible=False + ) + self.normalizer = LinearNormalizer() + self.horizon = horizon + self.obs_dim = obs_dim + self.action_dim = action_dim + self.n_action_steps = n_action_steps + self.n_obs_steps = n_obs_steps + self.obs_as_local_cond = obs_as_local_cond + self.obs_as_global_cond = obs_as_global_cond + self.pred_action_steps_only = pred_action_steps_only + self.oa_step_convention = oa_step_convention + self.noise_scheduler = noise_scheduler + self.kwargs = kwargs + + self.num_inference_steps = num_inference_steps + + + # ========= inference ============ + def predict_value(self, x, cond, t, formulas): + val = self.model( + sample = x, + timestep = t, + global_cond = cond, + formulas=formulas + ) + return val + + + # ========= training ============ + def set_normalizer(self, normalizer: LinearNormalizer): + self.normalizer.load_state_dict(normalizer.state_dict()) + + + def compute_loss(self, batch, formula, **kwargs): + # normalize input + assert 'valid_mask' not in batch + ## Normalize issues with value and formula: + ## Formulas don't need to be normalized + ## Take the value out first, don't normalize it for now + target = batch['value'] + nbatch = self.normalizer.normalize({key: batch[key] for key in ['obs', 'action']}) + obs = nbatch['obs'] + action = nbatch['action'] + # formula =nbatch['formula'] + + # handle different ways of passing observation + local_cond = None + global_cond = None + trajectory = action + if self.obs_as_local_cond: + # zero out observations after n_obs_steps + local_cond = obs + local_cond[:,self.n_obs_steps:,:] = 0 + elif self.obs_as_global_cond: + global_cond = obs[:,:self.n_obs_steps,:].reshape( + obs.shape[0], -1) + if self.pred_action_steps_only: + To = self.n_obs_steps + start = To + if self.oa_step_convention: + start = To - 1 + end = start + self.n_action_steps + trajectory = action[:,start:end] + else: + trajectory = torch.cat([action, obs], dim=-1) + + # generate impainting mask + if self.pred_action_steps_only: + condition_mask = torch.zeros_like(trajectory, dtype=torch.bool) + else: + condition_mask = self.mask_generator(trajectory.shape) + + + # raise NotImplementedError + + # Sample noise that we'll add to the images + noise = torch.randn(trajectory.shape, device=trajectory.device) + bsz = trajectory.shape[0] + # Sample a random timestep for each image + timesteps = torch.randint( + 0, self.noise_scheduler.config.num_train_timesteps, + (bsz,), device=trajectory.device + ).long() + # Add noise to the clean images according to the noise magnitude at each timestep + # (this is the forward diffusion process) + noisy_trajectory = self.noise_scheduler.add_noise( + trajectory, noise, timesteps) + + # compute loss mask + loss_mask = ~condition_mask + + # apply conditioning + noisy_trajectory[condition_mask] = trajectory[condition_mask] + + # Predict the value from a noisy trajectory + pred = self.model( + sample = noisy_trajectory, + timestep = timesteps, + formulas = formula, + local_cond=local_cond, + global_cond=global_cond + ) + + # pred_type = self.noise_scheduler.config.prediction_type + # if pred_type == 'epsilon': + # target = noise + # elif pred_type == 'sample': + # target = trajectory + # else: + # raise ValueError(f"Unsupported prediction type {pred_type}") + + loss = F.mse_loss(pred, target, reduction='none') + # loss = loss * loss_mask.type(loss.dtype) + loss = reduce(loss, 'b ... -> b (...)', 'mean') + loss = loss.mean() + return loss diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/keystroke_counter.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/keystroke_counter.py new file mode 100644 index 0000000000000000000000000000000000000000..90147136de65f2be2e7e212cbfed2c873c4e2244 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/keystroke_counter.py @@ -0,0 +1,45 @@ +from pynput.keyboard import Key, KeyCode, Listener +from collections import defaultdict +from threading import Lock + +class KeystrokeCounter(Listener): + def __init__(self): + self.key_count_map = defaultdict(lambda:0) + self.key_press_list = list() + self.lock = Lock() + super().__init__(on_press=self.on_press, on_release=self.on_release) + + def on_press(self, key): + with self.lock: + self.key_count_map[key] += 1 + self.key_press_list.append(key) + + def on_release(self, key): + pass + + def clear(self): + with self.lock: + self.key_count_map = defaultdict(lambda:0) + self.key_press_list = list() + + def __getitem__(self, key): + with self.lock: + return self.key_count_map[key] + + def get_press_events(self): + with self.lock: + events = list(self.key_press_list) + self.key_press_list = list() + return events + +if __name__ == '__main__': + import time + with KeystrokeCounter() as counter: + try: + while True: + print('Space:', counter[Key.space]) + print('q:', counter[KeyCode(char='q')]) + time.sleep(1/60) + except KeyboardInterrupt: + events = counter.get_press_events() + print(events) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_camera_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_camera_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..84cbf99227f0ee7b6c6c942d5cee506c5c782934 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_camera_visualizer.py @@ -0,0 +1,74 @@ +import time +import multiprocessing as mp +import numpy as np +import cv2 +from threadpoolctl import threadpool_limits +from diffusion_policy.real_world.multi_realsense import MultiRealsense + +class MultiCameraVisualizer(mp.Process): + def __init__(self, + realsense: MultiRealsense, + row, col, + window_name='Multi Cam Vis', + vis_fps=60, + fill_value=0, + rgb_to_bgr=True + ): + super().__init__() + self.row = row + self.col = col + self.window_name = window_name + self.vis_fps = vis_fps + self.fill_value = fill_value + self.rgb_to_bgr=rgb_to_bgr + self.realsense = realsense + # shared variables + self.stop_event = mp.Event() + + def start(self, wait=False): + super().start() + + def stop(self, wait=False): + self.stop_event.set() + if wait: + self.stop_wait() + + def start_wait(self): + pass + + def stop_wait(self): + self.join() + + def run(self): + cv2.setNumThreads(1) + threadpool_limits(1) + channel_slice = slice(None) + if self.rgb_to_bgr: + channel_slice = slice(None,None,-1) + + vis_data = None + vis_img = None + while not self.stop_event.is_set(): + vis_data = self.realsense.get_vis(out=vis_data) + color = vis_data['color'] + N, H, W, C = color.shape + assert C == 3 + oh = H * self.row + ow = W * self.col + if vis_img is None: + vis_img = np.full((oh, ow, 3), + fill_value=self.fill_value, dtype=np.uint8) + for row in range(self.row): + for col in range(self.col): + idx = col + row * self.col + h_start = H * row + h_end = h_start + H + w_start = W * col + w_end = w_start + W + if idx < N: + # opencv uses bgr + vis_img[h_start:h_end,w_start:w_end + ] = color[idx,:,:,channel_slice] + cv2.imshow(self.window_name, vis_img) + cv2.pollKey() + time.sleep(1 / self.vis_fps) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_realsense.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_realsense.py new file mode 100644 index 0000000000000000000000000000000000000000..3b29ad8db2063464dc596dcb3c603a94d3ecc44a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/multi_realsense.py @@ -0,0 +1,224 @@ +from typing import List, Optional, Union, Dict, Callable +import numbers +import time +import pathlib +from multiprocessing.managers import SharedMemoryManager +import numpy as np +import pyrealsense2 as rs +from diffusion_policy.real_world.single_realsense import SingleRealsense +from diffusion_policy.real_world.video_recorder import VideoRecorder + +class MultiRealsense: + def __init__(self, + serial_numbers: Optional[List[str]]=None, + shm_manager: Optional[SharedMemoryManager]=None, + resolution=(1280,720), + capture_fps=30, + put_fps=None, + put_downsample=True, + record_fps=None, + enable_color=True, + enable_depth=False, + enable_infrared=False, + get_max_k=30, + advanced_mode_config: Optional[Union[dict, List[dict]]]=None, + transform: Optional[Union[Callable[[Dict], Dict], List[Callable]]]=None, + vis_transform: Optional[Union[Callable[[Dict], Dict], List[Callable]]]=None, + recording_transform: Optional[Union[Callable[[Dict], Dict], List[Callable]]]=None, + video_recorder: Optional[Union[VideoRecorder, List[VideoRecorder]]]=None, + verbose=False + ): + if shm_manager is None: + shm_manager = SharedMemoryManager() + shm_manager.start() + if serial_numbers is None: + serial_numbers = SingleRealsense.get_connected_devices_serial() + n_cameras = len(serial_numbers) + + advanced_mode_config = repeat_to_list( + advanced_mode_config, n_cameras, dict) + transform = repeat_to_list( + transform, n_cameras, Callable) + vis_transform = repeat_to_list( + vis_transform, n_cameras, Callable) + recording_transform = repeat_to_list( + recording_transform, n_cameras, Callable) + + video_recorder = repeat_to_list( + video_recorder, n_cameras, VideoRecorder) + + cameras = dict() + for i, serial in enumerate(serial_numbers): + cameras[serial] = SingleRealsense( + shm_manager=shm_manager, + serial_number=serial, + resolution=resolution, + capture_fps=capture_fps, + put_fps=put_fps, + put_downsample=put_downsample, + record_fps=record_fps, + enable_color=enable_color, + enable_depth=enable_depth, + enable_infrared=enable_infrared, + get_max_k=get_max_k, + advanced_mode_config=advanced_mode_config[i], + transform=transform[i], + vis_transform=vis_transform[i], + recording_transform=recording_transform[i], + video_recorder=video_recorder[i], + verbose=verbose + ) + + self.cameras = cameras + self.shm_manager = shm_manager + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + @property + def n_cameras(self): + return len(self.cameras) + + @property + def is_ready(self): + is_ready = True + for camera in self.cameras.values(): + if not camera.is_ready: + is_ready = False + return is_ready + + def start(self, wait=True, put_start_time=None): + if put_start_time is None: + put_start_time = time.time() + for camera in self.cameras.values(): + camera.start(wait=False, put_start_time=put_start_time) + + if wait: + self.start_wait() + + def stop(self, wait=True): + for camera in self.cameras.values(): + camera.stop(wait=False) + + if wait: + self.stop_wait() + + def start_wait(self): + for camera in self.cameras.values(): + camera.start_wait() + + def stop_wait(self): + for camera in self.cameras.values(): + camera.join() + + def get(self, k=None, out=None) -> Dict[int, Dict[str, np.ndarray]]: + """ + Return order T,H,W,C + { + 0: { + 'rgb': (T,H,W,C), + 'timestamp': (T,) + }, + 1: ... + } + """ + if out is None: + out = dict() + for i, camera in enumerate(self.cameras.values()): + this_out = None + if i in out: + this_out = out[i] + this_out = camera.get(k=k, out=this_out) + out[i] = this_out + return out + + def get_vis(self, out=None): + results = list() + for i, camera in enumerate(self.cameras.values()): + this_out = None + if out is not None: + this_out = dict() + for key, v in out.items(): + # use the slicing trick to maintain the array + # when v is 1D + this_out[key] = v[i:i+1].reshape(v.shape[1:]) + this_out = camera.get_vis(out=this_out) + if out is None: + results.append(this_out) + if out is None: + out = dict() + for key in results[0].keys(): + out[key] = np.stack([x[key] for x in results]) + return out + + def set_color_option(self, option, value): + n_camera = len(self.cameras) + value = repeat_to_list(value, n_camera, numbers.Number) + for i, camera in enumerate(self.cameras.values()): + camera.set_color_option(option, value[i]) + + def set_exposure(self, exposure=None, gain=None): + """ + exposure: (1, 10000) 100us unit. (0.1 ms, 1/10000s) + gain: (0, 128) + """ + + if exposure is None and gain is None: + # auto exposure + self.set_color_option(rs.option.enable_auto_exposure, 1.0) + else: + # manual exposure + self.set_color_option(rs.option.enable_auto_exposure, 0.0) + if exposure is not None: + self.set_color_option(rs.option.exposure, exposure) + if gain is not None: + self.set_color_option(rs.option.gain, gain) + + def set_white_balance(self, white_balance=None): + if white_balance is None: + self.set_color_option(rs.option.enable_auto_white_balance, 1.0) + else: + self.set_color_option(rs.option.enable_auto_white_balance, 0.0) + self.set_color_option(rs.option.white_balance, white_balance) + + def get_intrinsics(self): + return np.array([c.get_intrinsics() for c in self.cameras.values()]) + + def get_depth_scale(self): + return np.array([c.get_depth_scale() for c in self.cameras.values()]) + + def start_recording(self, video_path: Union[str, List[str]], start_time: float): + if isinstance(video_path, str): + # directory + video_dir = pathlib.Path(video_path) + assert video_dir.parent.is_dir() + video_dir.mkdir(parents=True, exist_ok=True) + video_path = list() + for i in range(self.n_cameras): + video_path.append( + str(video_dir.joinpath(f'{i}.mp4').absolute())) + assert len(video_path) == self.n_cameras + + for i, camera in enumerate(self.cameras.values()): + camera.start_recording(video_path[i], start_time) + + def stop_recording(self): + for i, camera in enumerate(self.cameras.values()): + camera.stop_recording() + + def restart_put(self, start_time): + for camera in self.cameras.values(): + camera.restart_put(start_time) + + +def repeat_to_list(x, n: int, cls): + if x is None: + x = [None] * n + if isinstance(x, cls): + x = [x] * n + assert len(x) == n + return x diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_data_conversion.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_data_conversion.py new file mode 100644 index 0000000000000000000000000000000000000000..a75e4ccf9efe7f45d3477597b73fd41d265876a8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_data_conversion.py @@ -0,0 +1,194 @@ +from typing import Sequence, Tuple, Dict, Optional, Union +import os +import pathlib +import numpy as np +import av +import zarr +import numcodecs +import multiprocessing +import concurrent.futures +from tqdm import tqdm +from diffusion_policy.common.replay_buffer import ReplayBuffer, get_optimal_chunks +from diffusion_policy.common.cv2_util import get_image_transform +from diffusion_policy.real_world.video_recorder import read_video +from diffusion_policy.codecs.imagecodecs_numcodecs import ( + register_codecs, + Jpeg2k +) +register_codecs() + + +def real_data_to_replay_buffer( + dataset_path: str, + out_store: Optional[zarr.ABSStore]=None, + out_resolutions: Union[None, tuple, Dict[str,tuple]]=None, # (width, height) + lowdim_keys: Optional[Sequence[str]]=None, + image_keys: Optional[Sequence[str]]=None, + lowdim_compressor: Optional[numcodecs.abc.Codec]=None, + image_compressor: Optional[numcodecs.abc.Codec]=None, + n_decoding_threads: int=multiprocessing.cpu_count(), + n_encoding_threads: int=multiprocessing.cpu_count(), + max_inflight_tasks: int=multiprocessing.cpu_count()*5, + verify_read: bool=True + ) -> ReplayBuffer: + """ + It is recommended to use before calling this function + to avoid CPU oversubscription + cv2.setNumThreads(1) + threadpoolctl.threadpool_limits(1) + + out_resolution: + if None: + use video resolution + if (width, height) e.g. (1280, 720) + if dict: + camera_0: (1280, 720) + image_keys: ['camera_0', 'camera_1'] + """ + if out_store is None: + out_store = zarr.MemoryStore() + if n_decoding_threads <= 0: + n_decoding_threads = multiprocessing.cpu_count() + if n_encoding_threads <= 0: + n_encoding_threads = multiprocessing.cpu_count() + if image_compressor is None: + image_compressor = Jpeg2k(level=50) + + # verify input + input = pathlib.Path(os.path.expanduser(dataset_path)) + in_zarr_path = input.joinpath('replay_buffer.zarr') + in_video_dir = input.joinpath('videos') + assert in_zarr_path.is_dir() + assert in_video_dir.is_dir() + + in_replay_buffer = ReplayBuffer.create_from_path(str(in_zarr_path.absolute()), mode='r') + + # save lowdim data to single chunk + chunks_map = dict() + compressor_map = dict() + for key, value in in_replay_buffer.data.items(): + chunks_map[key] = value.shape + compressor_map[key] = lowdim_compressor + + print('Loading lowdim data') + out_replay_buffer = ReplayBuffer.copy_from_store( + src_store=in_replay_buffer.root.store, + store=out_store, + keys=lowdim_keys, + chunks=chunks_map, + compressors=compressor_map + ) + + # worker function + def put_img(zarr_arr, zarr_idx, img): + try: + zarr_arr[zarr_idx] = img + # make sure we can successfully decode + if verify_read: + _ = zarr_arr[zarr_idx] + return True + except Exception as e: + return False + + + n_cameras = 0 + camera_idxs = set() + if image_keys is not None: + n_cameras = len(image_keys) + camera_idxs = set(int(x.split('_')[-1]) for x in image_keys) + else: + # estimate number of cameras + episode_video_dir = in_video_dir.joinpath(str(0)) + episode_video_paths = sorted(episode_video_dir.glob('*.mp4'), key=lambda x: int(x.stem)) + camera_idxs = set(int(x.stem) for x in episode_video_paths) + n_cameras = len(episode_video_paths) + + n_steps = in_replay_buffer.n_steps + episode_starts = in_replay_buffer.episode_ends[:] - in_replay_buffer.episode_lengths[:] + episode_lengths = in_replay_buffer.episode_lengths + timestamps = in_replay_buffer['timestamp'][:] + dt = timestamps[1] - timestamps[0] + + with tqdm(total=n_steps*n_cameras, desc="Loading image data", mininterval=1.0) as pbar: + # one chunk per thread, therefore no synchronization needed + with concurrent.futures.ThreadPoolExecutor(max_workers=n_encoding_threads) as executor: + futures = set() + for episode_idx, episode_length in enumerate(episode_lengths): + episode_video_dir = in_video_dir.joinpath(str(episode_idx)) + episode_start = episode_starts[episode_idx] + + episode_video_paths = sorted(episode_video_dir.glob('*.mp4'), key=lambda x: int(x.stem)) + this_camera_idxs = set(int(x.stem) for x in episode_video_paths) + if image_keys is None: + for i in this_camera_idxs - camera_idxs: + print(f"Unexpected camera {i} at episode {episode_idx}") + for i in camera_idxs - this_camera_idxs: + print(f"Missing camera {i} at episode {episode_idx}") + if image_keys is not None: + raise RuntimeError(f"Missing camera {i} at episode {episode_idx}") + + for video_path in episode_video_paths: + camera_idx = int(video_path.stem) + if image_keys is not None: + # if image_keys provided, skip not used cameras + if camera_idx not in camera_idxs: + continue + + # read resolution + with av.open(str(video_path.absolute())) as container: + video = container.streams.video[0] + vcc = video.codec_context + this_res = (vcc.width, vcc.height) + in_img_res = this_res + + arr_name = f'camera_{camera_idx}' + # figure out save resolution + out_img_res = in_img_res + if isinstance(out_resolutions, dict): + if arr_name in out_resolutions: + out_img_res = tuple(out_resolutions[arr_name]) + elif out_resolutions is not None: + out_img_res = tuple(out_resolutions) + + # allocate array + if arr_name not in out_replay_buffer: + ow, oh = out_img_res + _ = out_replay_buffer.data.require_dataset( + name=arr_name, + shape=(n_steps,oh,ow,3), + chunks=(1,oh,ow,3), + compressor=image_compressor, + dtype=np.uint8 + ) + arr = out_replay_buffer[arr_name] + + image_tf = get_image_transform( + input_res=in_img_res, output_res=out_img_res, bgr_to_rgb=False) + for step_idx, frame in enumerate(read_video( + video_path=str(video_path), + dt=dt, + img_transform=image_tf, + thread_type='FRAME', + thread_count=n_decoding_threads + )): + if len(futures) >= max_inflight_tasks: + # limit number of inflight tasks + completed, futures = concurrent.futures.wait(futures, + return_when=concurrent.futures.FIRST_COMPLETED) + for f in completed: + if not f.result(): + raise RuntimeError('Failed to encode image!') + pbar.update(len(completed)) + + global_idx = episode_start + step_idx + futures.add(executor.submit(put_img, arr, global_idx, frame)) + + if step_idx == (episode_length - 1): + break + completed, futures = concurrent.futures.wait(futures) + for f in completed: + if not f.result(): + raise RuntimeError('Failed to encode image!') + pbar.update(len(completed)) + return out_replay_buffer + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_env.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_env.py new file mode 100644 index 0000000000000000000000000000000000000000..b731205f1466d4d4064d6eaf0058f7086d69ec4f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_env.py @@ -0,0 +1,435 @@ +from typing import Optional +import pathlib +import numpy as np +import time +import shutil +import math +from multiprocessing.managers import SharedMemoryManager +from diffusion_policy.real_world.rtde_interpolation_controller import RTDEInterpolationController +from diffusion_policy.real_world.multi_realsense import MultiRealsense, SingleRealsense +from diffusion_policy.real_world.video_recorder import VideoRecorder +from diffusion_policy.common.timestamp_accumulator import ( + TimestampObsAccumulator, + TimestampActionAccumulator, + align_timestamps +) +from diffusion_policy.real_world.multi_camera_visualizer import MultiCameraVisualizer +from diffusion_policy.common.replay_buffer import ReplayBuffer +from diffusion_policy.common.cv2_util import ( + get_image_transform, optimal_row_cols) + +DEFAULT_OBS_KEY_MAP = { + # robot + 'ActualTCPPose': 'robot_eef_pose', + 'ActualTCPSpeed': 'robot_eef_pose_vel', + 'ActualQ': 'robot_joint', + 'ActualQd': 'robot_joint_vel', + # timestamps + 'step_idx': 'step_idx', + 'timestamp': 'timestamp' +} + +class RealEnv: + def __init__(self, + # required params + output_dir, + robot_ip, + # env params + frequency=10, + n_obs_steps=2, + # obs + obs_image_resolution=(640,480), + max_obs_buffer_size=30, + camera_serial_numbers=None, + obs_key_map=DEFAULT_OBS_KEY_MAP, + obs_float32=False, + # action + max_pos_speed=0.25, + max_rot_speed=0.6, + # robot + tcp_offset=0.13, + init_joints=False, + # video capture params + video_capture_fps=30, + video_capture_resolution=(1280,720), + # saving params + record_raw_video=True, + thread_per_video=2, + video_crf=21, + # vis params + enable_multi_cam_vis=True, + multi_cam_vis_resolution=(1280,720), + # shared memory + shm_manager=None + ): + assert frequency <= video_capture_fps + output_dir = pathlib.Path(output_dir) + assert output_dir.parent.is_dir() + video_dir = output_dir.joinpath('videos') + video_dir.mkdir(parents=True, exist_ok=True) + zarr_path = str(output_dir.joinpath('replay_buffer.zarr').absolute()) + replay_buffer = ReplayBuffer.create_from_path( + zarr_path=zarr_path, mode='a') + + if shm_manager is None: + shm_manager = SharedMemoryManager() + shm_manager.start() + if camera_serial_numbers is None: + camera_serial_numbers = SingleRealsense.get_connected_devices_serial() + + color_tf = get_image_transform( + input_res=video_capture_resolution, + output_res=obs_image_resolution, + # obs output rgb + bgr_to_rgb=True) + color_transform = color_tf + if obs_float32: + color_transform = lambda x: color_tf(x).astype(np.float32) / 255 + + def transform(data): + data['color'] = color_transform(data['color']) + return data + + rw, rh, col, row = optimal_row_cols( + n_cameras=len(camera_serial_numbers), + in_wh_ratio=obs_image_resolution[0]/obs_image_resolution[1], + max_resolution=multi_cam_vis_resolution + ) + vis_color_transform = get_image_transform( + input_res=video_capture_resolution, + output_res=(rw,rh), + bgr_to_rgb=False + ) + def vis_transform(data): + data['color'] = vis_color_transform(data['color']) + return data + + recording_transfrom = None + recording_fps = video_capture_fps + recording_pix_fmt = 'bgr24' + if not record_raw_video: + recording_transfrom = transform + recording_fps = frequency + recording_pix_fmt = 'rgb24' + + video_recorder = VideoRecorder.create_h264( + fps=recording_fps, + codec='h264', + input_pix_fmt=recording_pix_fmt, + crf=video_crf, + thread_type='FRAME', + thread_count=thread_per_video) + + realsense = MultiRealsense( + serial_numbers=camera_serial_numbers, + shm_manager=shm_manager, + resolution=video_capture_resolution, + capture_fps=video_capture_fps, + put_fps=video_capture_fps, + # send every frame immediately after arrival + # ignores put_fps + put_downsample=False, + record_fps=recording_fps, + enable_color=True, + enable_depth=False, + enable_infrared=False, + get_max_k=max_obs_buffer_size, + transform=transform, + vis_transform=vis_transform, + recording_transform=recording_transfrom, + video_recorder=video_recorder, + verbose=False + ) + + multi_cam_vis = None + if enable_multi_cam_vis: + multi_cam_vis = MultiCameraVisualizer( + realsense=realsense, + row=row, + col=col, + rgb_to_bgr=False + ) + + cube_diag = np.linalg.norm([1,1,1]) + j_init = np.array([0,-90,-90,-90,90,0]) / 180 * np.pi + if not init_joints: + j_init = None + + robot = RTDEInterpolationController( + shm_manager=shm_manager, + robot_ip=robot_ip, + frequency=125, # UR5 CB3 RTDE + lookahead_time=0.1, + gain=300, + max_pos_speed=max_pos_speed*cube_diag, + max_rot_speed=max_rot_speed*cube_diag, + launch_timeout=3, + tcp_offset_pose=[0,0,tcp_offset,0,0,0], + payload_mass=None, + payload_cog=None, + joints_init=j_init, + joints_init_speed=1.05, + soft_real_time=False, + verbose=False, + receive_keys=None, + get_max_k=max_obs_buffer_size + ) + self.realsense = realsense + self.robot = robot + self.multi_cam_vis = multi_cam_vis + self.video_capture_fps = video_capture_fps + self.frequency = frequency + self.n_obs_steps = n_obs_steps + self.max_obs_buffer_size = max_obs_buffer_size + self.max_pos_speed = max_pos_speed + self.max_rot_speed = max_rot_speed + self.obs_key_map = obs_key_map + # recording + self.output_dir = output_dir + self.video_dir = video_dir + self.replay_buffer = replay_buffer + # temp memory buffers + self.last_realsense_data = None + # recording buffers + self.obs_accumulator = None + self.action_accumulator = None + self.stage_accumulator = None + + self.start_time = None + + # ======== start-stop API ============= + @property + def is_ready(self): + return self.realsense.is_ready and self.robot.is_ready + + def start(self, wait=True): + self.realsense.start(wait=False) + self.robot.start(wait=False) + if self.multi_cam_vis is not None: + self.multi_cam_vis.start(wait=False) + if wait: + self.start_wait() + + def stop(self, wait=True): + self.end_episode() + if self.multi_cam_vis is not None: + self.multi_cam_vis.stop(wait=False) + self.robot.stop(wait=False) + self.realsense.stop(wait=False) + if wait: + self.stop_wait() + + def start_wait(self): + self.realsense.start_wait() + self.robot.start_wait() + if self.multi_cam_vis is not None: + self.multi_cam_vis.start_wait() + + def stop_wait(self): + self.robot.stop_wait() + self.realsense.stop_wait() + if self.multi_cam_vis is not None: + self.multi_cam_vis.stop_wait() + + # ========= context manager =========== + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + # ========= async env API =========== + def get_obs(self) -> dict: + "observation dict" + assert self.is_ready + + # get data + # 30 Hz, camera_receive_timestamp + k = math.ceil(self.n_obs_steps * (self.video_capture_fps / self.frequency)) + self.last_realsense_data = self.realsense.get( + k=k, + out=self.last_realsense_data) + + # 125 hz, robot_receive_timestamp + last_robot_data = self.robot.get_all_state() + # both have more than n_obs_steps data + + # align camera obs timestamps + dt = 1 / self.frequency + last_timestamp = np.max([x['timestamp'][-1] for x in self.last_realsense_data.values()]) + obs_align_timestamps = last_timestamp - (np.arange(self.n_obs_steps)[::-1] * dt) + + camera_obs = dict() + for camera_idx, value in self.last_realsense_data.items(): + this_timestamps = value['timestamp'] + this_idxs = list() + for t in obs_align_timestamps: + is_before_idxs = np.nonzero(this_timestamps < t)[0] + this_idx = 0 + if len(is_before_idxs) > 0: + this_idx = is_before_idxs[-1] + this_idxs.append(this_idx) + # remap key + camera_obs[f'camera_{camera_idx}'] = value['color'][this_idxs] + + # align robot obs + robot_timestamps = last_robot_data['robot_receive_timestamp'] + this_timestamps = robot_timestamps + this_idxs = list() + for t in obs_align_timestamps: + is_before_idxs = np.nonzero(this_timestamps < t)[0] + this_idx = 0 + if len(is_before_idxs) > 0: + this_idx = is_before_idxs[-1] + this_idxs.append(this_idx) + + robot_obs_raw = dict() + for k, v in last_robot_data.items(): + if k in self.obs_key_map: + robot_obs_raw[self.obs_key_map[k]] = v + + robot_obs = dict() + for k, v in robot_obs_raw.items(): + robot_obs[k] = v[this_idxs] + + # accumulate obs + if self.obs_accumulator is not None: + self.obs_accumulator.put( + robot_obs_raw, + robot_timestamps + ) + + # return obs + obs_data = dict(camera_obs) + obs_data.update(robot_obs) + obs_data['timestamp'] = obs_align_timestamps + return obs_data + + def exec_actions(self, + actions: np.ndarray, + timestamps: np.ndarray, + stages: Optional[np.ndarray]=None): + assert self.is_ready + if not isinstance(actions, np.ndarray): + actions = np.array(actions) + if not isinstance(timestamps, np.ndarray): + timestamps = np.array(timestamps) + if stages is None: + stages = np.zeros_like(timestamps, dtype=np.int64) + elif not isinstance(stages, np.ndarray): + stages = np.array(stages, dtype=np.int64) + + # convert action to pose + receive_time = time.time() + is_new = timestamps > receive_time + new_actions = actions[is_new] + new_timestamps = timestamps[is_new] + new_stages = stages[is_new] + + # schedule waypoints + for i in range(len(new_actions)): + self.robot.schedule_waypoint( + pose=new_actions[i], + target_time=new_timestamps[i] + ) + + # record actions + if self.action_accumulator is not None: + self.action_accumulator.put( + new_actions, + new_timestamps + ) + if self.stage_accumulator is not None: + self.stage_accumulator.put( + new_stages, + new_timestamps + ) + + def get_robot_state(self): + return self.robot.get_state() + + # recording API + def start_episode(self, start_time=None): + "Start recording and return first obs" + if start_time is None: + start_time = time.time() + self.start_time = start_time + + assert self.is_ready + + # prepare recording stuff + episode_id = self.replay_buffer.n_episodes + this_video_dir = self.video_dir.joinpath(str(episode_id)) + this_video_dir.mkdir(parents=True, exist_ok=True) + n_cameras = self.realsense.n_cameras + video_paths = list() + for i in range(n_cameras): + video_paths.append( + str(this_video_dir.joinpath(f'{i}.mp4').absolute())) + + # start recording on realsense + self.realsense.restart_put(start_time=start_time) + self.realsense.start_recording(video_path=video_paths, start_time=start_time) + + # create accumulators + self.obs_accumulator = TimestampObsAccumulator( + start_time=start_time, + dt=1/self.frequency + ) + self.action_accumulator = TimestampActionAccumulator( + start_time=start_time, + dt=1/self.frequency + ) + self.stage_accumulator = TimestampActionAccumulator( + start_time=start_time, + dt=1/self.frequency + ) + print(f'Episode {episode_id} started!') + + def end_episode(self): + "Stop recording" + assert self.is_ready + + # stop video recorder + self.realsense.stop_recording() + + if self.obs_accumulator is not None: + # recording + assert self.action_accumulator is not None + assert self.stage_accumulator is not None + + # Since the only way to accumulate obs and action is by calling + # get_obs and exec_actions, which will be in the same thread. + # We don't need to worry new data come in here. + obs_data = self.obs_accumulator.data + obs_timestamps = self.obs_accumulator.timestamps + + actions = self.action_accumulator.actions + action_timestamps = self.action_accumulator.timestamps + stages = self.stage_accumulator.actions + n_steps = min(len(obs_timestamps), len(action_timestamps)) + if n_steps > 0: + episode = dict() + episode['timestamp'] = obs_timestamps[:n_steps] + episode['action'] = actions[:n_steps] + episode['stage'] = stages[:n_steps] + for key, value in obs_data.items(): + episode[key] = value[:n_steps] + self.replay_buffer.add_episode(episode, compressors='disk') + episode_id = self.replay_buffer.n_episodes - 1 + print(f'Episode {episode_id} saved!') + + self.obs_accumulator = None + self.action_accumulator = None + self.stage_accumulator = None + + def drop_episode(self): + self.end_episode() + self.replay_buffer.drop_episode() + episode_id = self.replay_buffer.n_episodes + this_video_dir = self.video_dir.joinpath(str(episode_id)) + if this_video_dir.exists(): + shutil.rmtree(str(this_video_dir)) + print(f'Episode {episode_id} dropped!') + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_inference_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_inference_util.py new file mode 100644 index 0000000000000000000000000000000000000000..4a262fa956b63b2392639008b00cc7588a8f60b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/real_inference_util.py @@ -0,0 +1,52 @@ +from typing import Dict, Callable, Tuple +import numpy as np +from diffusion_policy.common.cv2_util import get_image_transform + +def get_real_obs_dict( + env_obs: Dict[str, np.ndarray], + shape_meta: dict, + ) -> Dict[str, np.ndarray]: + obs_dict_np = dict() + obs_shape_meta = shape_meta['obs'] + for key, attr in obs_shape_meta.items(): + type = attr.get('type', 'low_dim') + shape = attr.get('shape') + if type == 'rgb': + this_imgs_in = env_obs[key] + t,hi,wi,ci = this_imgs_in.shape + co,ho,wo = shape + assert ci == co + out_imgs = this_imgs_in + if (ho != hi) or (wo != wi) or (this_imgs_in.dtype == np.uint8): + tf = get_image_transform( + input_res=(wi,hi), + output_res=(wo,ho), + bgr_to_rgb=False) + out_imgs = np.stack([tf(x) for x in this_imgs_in]) + if this_imgs_in.dtype == np.uint8: + out_imgs = out_imgs.astype(np.float32) / 255 + # THWC to TCHW + obs_dict_np[key] = np.moveaxis(out_imgs,-1,1) + elif type == 'low_dim': + this_data_in = env_obs[key] + if 'pose' in key and shape == (2,): + # take X,Y coordinates + this_data_in = this_data_in[...,[0,1]] + obs_dict_np[key] = this_data_in + return obs_dict_np + + +def get_real_obs_resolution( + shape_meta: dict + ) -> Tuple[int, int]: + out_res = None + obs_shape_meta = shape_meta['obs'] + for key, attr in obs_shape_meta.items(): + type = attr.get('type', 'low_dim') + shape = attr.get('shape') + if type == 'rgb': + co,ho,wo = shape + if out_res is None: + out_res = (wo, ho) + assert out_res == (wo, ho) + return out_res diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/415_high_accuracy_mode.json b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/415_high_accuracy_mode.json new file mode 100644 index 0000000000000000000000000000000000000000..a3c00b4d2949cb4aaf2bb9d48e3468103cfad3f7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/415_high_accuracy_mode.json @@ -0,0 +1,95 @@ +{ + "aux-param-autoexposure-setpoint": "400", + "aux-param-colorcorrection1": "0.461914", + "aux-param-colorcorrection10": "-0.553711", + "aux-param-colorcorrection11": "-0.553711", + "aux-param-colorcorrection12": "0.0458984", + "aux-param-colorcorrection2": "0.540039", + "aux-param-colorcorrection3": "0.540039", + "aux-param-colorcorrection4": "0.208008", + "aux-param-colorcorrection5": "-0.332031", + "aux-param-colorcorrection6": "-0.212891", + "aux-param-colorcorrection7": "-0.212891", + "aux-param-colorcorrection8": "0.68457", + "aux-param-colorcorrection9": "0.930664", + "aux-param-depthclampmax": "65535", + "aux-param-depthclampmin": "0", + "aux-param-disparityshift": "0", + "controls-autoexposure-auto": "True", + "controls-autoexposure-manual": "33000", + "controls-color-autoexposure-auto": "True", + "controls-color-autoexposure-manual": "100", + "controls-color-backlight-compensation": "0", + "controls-color-brightness": "0", + "controls-color-contrast": "50", + "controls-color-gain": "100", + "controls-color-gamma": "300", + "controls-color-hue": "0", + "controls-color-power-line-frequency": "3", + "controls-color-saturation": "64", + "controls-color-sharpness": "50", + "controls-color-white-balance-auto": "True", + "controls-color-white-balance-manual": "4600", + "controls-depth-gain": "16", + "controls-depth-white-balance-auto": "False", + "controls-laserpower": "150", + "controls-laserstate": "on", + "ignoreSAD": "0", + "param-amplitude-factor": "0", + "param-autoexposure-setpoint": "400", + "param-censusenablereg-udiameter": "9", + "param-censusenablereg-vdiameter": "3", + "param-censususize": "9", + "param-censusvsize": "3", + "param-depthclampmax": "65535", + "param-depthclampmin": "0", + "param-depthunits": "1000", + "param-disableraucolor": "0", + "param-disablesadcolor": "0", + "param-disablesadnormalize": "0", + "param-disablesloleftcolor": "0", + "param-disableslorightcolor": "1", + "param-disparitymode": "0", + "param-disparityshift": "0", + "param-lambdaad": "751", + "param-lambdacensus": "6", + "param-leftrightthreshold": "10", + "param-maxscorethreshb": "2893", + "param-medianthreshold": "796", + "param-minscorethresha": "4", + "param-neighborthresh": "108", + "param-raumine": "6", + "param-rauminn": "3", + "param-rauminnssum": "7", + "param-raumins": "2", + "param-rauminw": "2", + "param-rauminwesum": "12", + "param-regioncolorthresholdb": "0.785714", + "param-regioncolorthresholdg": "0.565558", + "param-regioncolorthresholdr": "0.985323", + "param-regionshrinku": "3", + "param-regionshrinkv": "0", + "param-robbinsmonrodecrement": "25", + "param-robbinsmonroincrement": "2", + "param-rsmdiffthreshold": "1.65625", + "param-rsmrauslodiffthreshold": "0.71875", + "param-rsmremovethreshold": "0.809524", + "param-scanlineedgetaub": "13", + "param-scanlineedgetaug": "15", + "param-scanlineedgetaur": "30", + "param-scanlinep1": "155", + "param-scanlinep1onediscon": "160", + "param-scanlinep1twodiscon": "59", + "param-scanlinep2": "190", + "param-scanlinep2onediscon": "507", + "param-scanlinep2twodiscon": "493", + "param-secondpeakdelta": "647", + "param-texturecountthresh": "0", + "param-texturedifferencethresh": "1722", + "param-usersm": "1", + "param-zunits": "1000", + "stream-depth-format": "Z16", + "stream-fps": "30", + "stream-height": "480", + "stream-width": "640" +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/435_high_accuracy_mode.json b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/435_high_accuracy_mode.json new file mode 100644 index 0000000000000000000000000000000000000000..8b88e5f13d8f595970ce7e02ef26b437dd4af841 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/realsense_config/435_high_accuracy_mode.json @@ -0,0 +1,94 @@ +{ + "aux-param-autoexposure-setpoint": "1536", + "aux-param-colorcorrection1": "0.298828", + "aux-param-colorcorrection10": "-0", + "aux-param-colorcorrection11": "-0", + "aux-param-colorcorrection12": "-0", + "aux-param-colorcorrection2": "0.293945", + "aux-param-colorcorrection3": "0.293945", + "aux-param-colorcorrection4": "0.114258", + "aux-param-colorcorrection5": "-0", + "aux-param-colorcorrection6": "-0", + "aux-param-colorcorrection7": "-0", + "aux-param-colorcorrection8": "-0", + "aux-param-colorcorrection9": "-0", + "aux-param-depthclampmax": "65536", + "aux-param-depthclampmin": "0", + "aux-param-disparityshift": "0", + "controls-autoexposure-auto": "True", + "controls-autoexposure-manual": "8500", + "controls-color-autoexposure-auto": "True", + "controls-color-autoexposure-manual": "100", + "controls-color-backlight-compensation": "0", + "controls-color-brightness": "0", + "controls-color-contrast": "50", + "controls-color-gain": "100", + "controls-color-gamma": "300", + "controls-color-hue": "0", + "controls-color-power-line-frequency": "3", + "controls-color-saturation": "64", + "controls-color-sharpness": "50", + "controls-color-white-balance-auto": "True", + "controls-color-white-balance-manual": "4600", + "controls-depth-gain": "16", + "controls-laserpower": "150", + "controls-laserstate": "on", + "ignoreSAD": "0", + "param-amplitude-factor": "0", + "param-autoexposure-setpoint": "1536", + "param-censusenablereg-udiameter": "9", + "param-censusenablereg-vdiameter": "9", + "param-censususize": "9", + "param-censusvsize": "9", + "param-depthclampmax": "65536", + "param-depthclampmin": "0", + "param-depthunits": "1000", + "param-disableraucolor": "0", + "param-disablesadcolor": "0", + "param-disablesadnormalize": "0", + "param-disablesloleftcolor": "0", + "param-disableslorightcolor": "1", + "param-disparitymode": "0", + "param-disparityshift": "0", + "param-lambdaad": "751", + "param-lambdacensus": "6", + "param-leftrightthreshold": "10", + "param-maxscorethreshb": "2893", + "param-medianthreshold": "796", + "param-minscorethresha": "4", + "param-neighborthresh": "108", + "param-raumine": "6", + "param-rauminn": "3", + "param-rauminnssum": "7", + "param-raumins": "2", + "param-rauminw": "2", + "param-rauminwesum": "12", + "param-regioncolorthresholdb": "0.785714", + "param-regioncolorthresholdg": "0.565558", + "param-regioncolorthresholdr": "0.985323", + "param-regionshrinku": "3", + "param-regionshrinkv": "0", + "param-robbinsmonrodecrement": "25", + "param-robbinsmonroincrement": "2", + "param-rsmdiffthreshold": "1.65625", + "param-rsmrauslodiffthreshold": "0.71875", + "param-rsmremovethreshold": "0.809524", + "param-scanlineedgetaub": "13", + "param-scanlineedgetaug": "15", + "param-scanlineedgetaur": "30", + "param-scanlinep1": "155", + "param-scanlinep1onediscon": "160", + "param-scanlinep1twodiscon": "59", + "param-scanlinep2": "190", + "param-scanlinep2onediscon": "507", + "param-scanlinep2twodiscon": "493", + "param-secondpeakdelta": "647", + "param-texturecountthresh": "0", + "param-texturedifferencethresh": "1722", + "param-usersm": "1", + "param-zunits": "1000", + "stream-depth-format": "Z16", + "stream-fps": "30", + "stream-height": "480", + "stream-width": "848" +} \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/rtde_interpolation_controller.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/rtde_interpolation_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..af27b2ed4a5ec568519835b94954d58d5197e0d6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/rtde_interpolation_controller.py @@ -0,0 +1,361 @@ +import os +import time +import enum +import multiprocessing as mp +from multiprocessing.managers import SharedMemoryManager +import scipy.interpolate as si +import scipy.spatial.transform as st +import numpy as np +from rtde_control import RTDEControlInterface +from rtde_receive import RTDEReceiveInterface +from diffusion_policy.shared_memory.shared_memory_queue import ( + SharedMemoryQueue, Empty) +from diffusion_policy.shared_memory.shared_memory_ring_buffer import SharedMemoryRingBuffer +from diffusion_policy.common.pose_trajectory_interpolator import PoseTrajectoryInterpolator + +class Command(enum.Enum): + STOP = 0 + SERVOL = 1 + SCHEDULE_WAYPOINT = 2 + + +class RTDEInterpolationController(mp.Process): + """ + To ensure sending command to the robot with predictable latency + this controller need its separate process (due to python GIL) + """ + + + def __init__(self, + shm_manager: SharedMemoryManager, + robot_ip, + frequency=125, + lookahead_time=0.1, + gain=300, + max_pos_speed=0.25, # 5% of max speed + max_rot_speed=0.16, # 5% of max speed + launch_timeout=3, + tcp_offset_pose=None, + payload_mass=None, + payload_cog=None, + joints_init=None, + joints_init_speed=1.05, + soft_real_time=False, + verbose=False, + receive_keys=None, + get_max_k=128, + ): + """ + frequency: CB2=125, UR3e=500 + lookahead_time: [0.03, 0.2]s smoothens the trajectory with this lookahead time + gain: [100, 2000] proportional gain for following target position + max_pos_speed: m/s + max_rot_speed: rad/s + tcp_offset_pose: 6d pose + payload_mass: float + payload_cog: 3d position, center of gravity + soft_real_time: enables round-robin scheduling and real-time priority + requires running scripts/rtprio_setup.sh before hand. + + """ + # verify + assert 0 < frequency <= 500 + assert 0.03 <= lookahead_time <= 0.2 + assert 100 <= gain <= 2000 + assert 0 < max_pos_speed + assert 0 < max_rot_speed + if tcp_offset_pose is not None: + tcp_offset_pose = np.array(tcp_offset_pose) + assert tcp_offset_pose.shape == (6,) + if payload_mass is not None: + assert 0 <= payload_mass <= 5 + if payload_cog is not None: + payload_cog = np.array(payload_cog) + assert payload_cog.shape == (3,) + assert payload_mass is not None + if joints_init is not None: + joints_init = np.array(joints_init) + assert joints_init.shape == (6,) + + super().__init__(name="RTDEPositionalController") + self.robot_ip = robot_ip + self.frequency = frequency + self.lookahead_time = lookahead_time + self.gain = gain + self.max_pos_speed = max_pos_speed + self.max_rot_speed = max_rot_speed + self.launch_timeout = launch_timeout + self.tcp_offset_pose = tcp_offset_pose + self.payload_mass = payload_mass + self.payload_cog = payload_cog + self.joints_init = joints_init + self.joints_init_speed = joints_init_speed + self.soft_real_time = soft_real_time + self.verbose = verbose + + # build input queue + example = { + 'cmd': Command.SERVOL.value, + 'target_pose': np.zeros((6,), dtype=np.float64), + 'duration': 0.0, + 'target_time': 0.0 + } + input_queue = SharedMemoryQueue.create_from_examples( + shm_manager=shm_manager, + examples=example, + buffer_size=256 + ) + + # build ring buffer + if receive_keys is None: + receive_keys = [ + 'ActualTCPPose', + 'ActualTCPSpeed', + 'ActualQ', + 'ActualQd', + + 'TargetTCPPose', + 'TargetTCPSpeed', + 'TargetQ', + 'TargetQd' + ] + rtde_r = RTDEReceiveInterface(hostname=robot_ip) + example = dict() + for key in receive_keys: + example[key] = np.array(getattr(rtde_r, 'get'+key)()) + example['robot_receive_timestamp'] = time.time() + ring_buffer = SharedMemoryRingBuffer.create_from_examples( + shm_manager=shm_manager, + examples=example, + get_max_k=get_max_k, + get_time_budget=0.2, + put_desired_frequency=frequency + ) + + self.ready_event = mp.Event() + self.input_queue = input_queue + self.ring_buffer = ring_buffer + self.receive_keys = receive_keys + + # ========= launch method =========== + def start(self, wait=True): + super().start() + if wait: + self.start_wait() + if self.verbose: + print(f"[RTDEPositionalController] Controller process spawned at {self.pid}") + + def stop(self, wait=True): + message = { + 'cmd': Command.STOP.value + } + self.input_queue.put(message) + if wait: + self.stop_wait() + + def start_wait(self): + self.ready_event.wait(self.launch_timeout) + assert self.is_alive() + + def stop_wait(self): + self.join() + + @property + def is_ready(self): + return self.ready_event.is_set() + + # ========= context manager =========== + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + # ========= command methods ============ + def servoL(self, pose, duration=0.1): + """ + duration: desired time to reach pose + """ + assert self.is_alive() + assert(duration >= (1/self.frequency)) + pose = np.array(pose) + assert pose.shape == (6,) + + message = { + 'cmd': Command.SERVOL.value, + 'target_pose': pose, + 'duration': duration + } + self.input_queue.put(message) + + def schedule_waypoint(self, pose, target_time): + assert target_time > time.time() + pose = np.array(pose) + assert pose.shape == (6,) + + message = { + 'cmd': Command.SCHEDULE_WAYPOINT.value, + 'target_pose': pose, + 'target_time': target_time + } + self.input_queue.put(message) + + # ========= receive APIs ============= + def get_state(self, k=None, out=None): + if k is None: + return self.ring_buffer.get(out=out) + else: + return self.ring_buffer.get_last_k(k=k,out=out) + + def get_all_state(self): + return self.ring_buffer.get_all() + + # ========= main loop in process ============ + def run(self): + # enable soft real-time + if self.soft_real_time: + os.sched_setscheduler( + 0, os.SCHED_RR, os.sched_param(20)) + + # start rtde + robot_ip = self.robot_ip + rtde_c = RTDEControlInterface(hostname=robot_ip) + rtde_r = RTDEReceiveInterface(hostname=robot_ip) + + try: + if self.verbose: + print(f"[RTDEPositionalController] Connect to robot: {robot_ip}") + + # set parameters + if self.tcp_offset_pose is not None: + rtde_c.setTcp(self.tcp_offset_pose) + if self.payload_mass is not None: + if self.payload_cog is not None: + assert rtde_c.setPayload(self.payload_mass, self.payload_cog) + else: + assert rtde_c.setPayload(self.payload_mass) + + # init pose + if self.joints_init is not None: + assert rtde_c.moveJ(self.joints_init, self.joints_init_speed, 1.4) + + # main loop + dt = 1. / self.frequency + curr_pose = rtde_r.getActualTCPPose() + # use monotonic time to make sure the control loop never go backward + curr_t = time.monotonic() + last_waypoint_time = curr_t + pose_interp = PoseTrajectoryInterpolator( + times=[curr_t], + poses=[curr_pose] + ) + + iter_idx = 0 + keep_running = True + while keep_running: + # start control iteration + t_start = rtde_c.initPeriod() + + # send command to robot + t_now = time.monotonic() + # diff = t_now - pose_interp.times[-1] + # if diff > 0: + # print('extrapolate', diff) + pose_command = pose_interp(t_now) + vel = 0.5 + acc = 0.5 + assert rtde_c.servoL(pose_command, + vel, acc, # dummy, not used by ur5 + dt, + self.lookahead_time, + self.gain) + + # update robot state + state = dict() + for key in self.receive_keys: + state[key] = np.array(getattr(rtde_r, 'get'+key)()) + state['robot_receive_timestamp'] = time.time() + self.ring_buffer.put(state) + + # fetch command from queue + try: + commands = self.input_queue.get_all() + n_cmd = len(commands['cmd']) + except Empty: + n_cmd = 0 + + # execute commands + for i in range(n_cmd): + command = dict() + for key, value in commands.items(): + command[key] = value[i] + cmd = command['cmd'] + + if cmd == Command.STOP.value: + keep_running = False + # stop immediately, ignore later commands + break + elif cmd == Command.SERVOL.value: + # since curr_pose always lag behind curr_target_pose + # if we start the next interpolation with curr_pose + # the command robot receive will have discontinouity + # and cause jittery robot behavior. + target_pose = command['target_pose'] + duration = float(command['duration']) + curr_time = t_now + dt + t_insert = curr_time + duration + pose_interp = pose_interp.drive_to_waypoint( + pose=target_pose, + time=t_insert, + curr_time=curr_time, + max_pos_speed=self.max_pos_speed, + max_rot_speed=self.max_rot_speed + ) + last_waypoint_time = t_insert + if self.verbose: + print("[RTDEPositionalController] New pose target:{} duration:{}s".format( + target_pose, duration)) + elif cmd == Command.SCHEDULE_WAYPOINT.value: + target_pose = command['target_pose'] + target_time = float(command['target_time']) + # translate global time to monotonic time + target_time = time.monotonic() - time.time() + target_time + curr_time = t_now + dt + pose_interp = pose_interp.schedule_waypoint( + pose=target_pose, + time=target_time, + max_pos_speed=self.max_pos_speed, + max_rot_speed=self.max_rot_speed, + curr_time=curr_time, + last_waypoint_time=last_waypoint_time + ) + last_waypoint_time = target_time + else: + keep_running = False + break + + # regulate frequency + rtde_c.waitPeriod(t_start) + + # first loop successful, ready to receive command + if iter_idx == 0: + self.ready_event.set() + iter_idx += 1 + + if self.verbose: + print(f"[RTDEPositionalController] Actual frequency {1/(time.perf_counter() - t_start)}") + + finally: + # manditory cleanup + # decelerate + rtde_c.servoStop() + + # terminate + rtde_c.stopScript() + rtde_c.disconnect() + rtde_r.disconnect() + self.ready_event.set() + + if self.verbose: + print(f"[RTDEPositionalController] Disconnected from robot: {robot_ip}") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/single_realsense.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/single_realsense.py new file mode 100644 index 0000000000000000000000000000000000000000..7a8443b96fe05a309452cbd1d0974380efebd791 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/single_realsense.py @@ -0,0 +1,480 @@ +from typing import Optional, Callable, Dict +import os +import enum +import time +import json +import numpy as np +import pyrealsense2 as rs +import multiprocessing as mp +import cv2 +from threadpoolctl import threadpool_limits +from multiprocessing.managers import SharedMemoryManager +from diffusion_policy.common.timestamp_accumulator import get_accumulate_timestamp_idxs +from diffusion_policy.shared_memory.shared_ndarray import SharedNDArray +from diffusion_policy.shared_memory.shared_memory_ring_buffer import SharedMemoryRingBuffer +from diffusion_policy.shared_memory.shared_memory_queue import SharedMemoryQueue, Full, Empty +from diffusion_policy.real_world.video_recorder import VideoRecorder + +class Command(enum.Enum): + SET_COLOR_OPTION = 0 + SET_DEPTH_OPTION = 1 + START_RECORDING = 2 + STOP_RECORDING = 3 + RESTART_PUT = 4 + +class SingleRealsense(mp.Process): + MAX_PATH_LENGTH = 4096 # linux path has a limit of 4096 bytes + + def __init__( + self, + shm_manager: SharedMemoryManager, + serial_number, + resolution=(1280,720), + capture_fps=30, + put_fps=None, + put_downsample=True, + record_fps=None, + enable_color=True, + enable_depth=False, + enable_infrared=False, + get_max_k=30, + advanced_mode_config=None, + transform: Optional[Callable[[Dict], Dict]] = None, + vis_transform: Optional[Callable[[Dict], Dict]] = None, + recording_transform: Optional[Callable[[Dict], Dict]] = None, + video_recorder: Optional[VideoRecorder] = None, + verbose=False + ): + super().__init__() + + if put_fps is None: + put_fps = capture_fps + if record_fps is None: + record_fps = capture_fps + + # create ring buffer + resolution = tuple(resolution) + shape = resolution[::-1] + examples = dict() + if enable_color: + examples['color'] = np.empty( + shape=shape+(3,), dtype=np.uint8) + if enable_depth: + examples['depth'] = np.empty( + shape=shape, dtype=np.uint16) + if enable_infrared: + examples['infrared'] = np.empty( + shape=shape, dtype=np.uint8) + examples['camera_capture_timestamp'] = 0.0 + examples['camera_receive_timestamp'] = 0.0 + examples['timestamp'] = 0.0 + examples['step_idx'] = 0 + + vis_ring_buffer = SharedMemoryRingBuffer.create_from_examples( + shm_manager=shm_manager, + examples=examples if vis_transform is None + else vis_transform(dict(examples)), + get_max_k=1, + get_time_budget=0.2, + put_desired_frequency=capture_fps + ) + + ring_buffer = SharedMemoryRingBuffer.create_from_examples( + shm_manager=shm_manager, + examples=examples if transform is None + else transform(dict(examples)), + get_max_k=get_max_k, + get_time_budget=0.2, + put_desired_frequency=put_fps + ) + + # create command queue + examples = { + 'cmd': Command.SET_COLOR_OPTION.value, + 'option_enum': rs.option.exposure.value, + 'option_value': 0.0, + 'video_path': np.array('a'*self.MAX_PATH_LENGTH), + 'recording_start_time': 0.0, + 'put_start_time': 0.0 + } + + command_queue = SharedMemoryQueue.create_from_examples( + shm_manager=shm_manager, + examples=examples, + buffer_size=128 + ) + + # create shared array for intrinsics + intrinsics_array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(7,), + dtype=np.float64) + intrinsics_array.get()[:] = 0 + + # create video recorder + if video_recorder is None: + # realsense uses bgr24 pixel format + # default thread_type to FRAEM + # i.e. each frame uses one core + # instead of all cores working on all frames. + # this prevents CPU over-subpscription and + # improves performance significantly + video_recorder = VideoRecorder.create_h264( + fps=record_fps, + codec='h264', + input_pix_fmt='bgr24', + crf=18, + thread_type='FRAME', + thread_count=1) + + # copied variables + self.serial_number = serial_number + self.resolution = resolution + self.capture_fps = capture_fps + self.put_fps = put_fps + self.put_downsample = put_downsample + self.record_fps = record_fps + self.enable_color = enable_color + self.enable_depth = enable_depth + self.enable_infrared = enable_infrared + self.advanced_mode_config = advanced_mode_config + self.transform = transform + self.vis_transform = vis_transform + self.recording_transform = recording_transform + self.video_recorder = video_recorder + self.verbose = verbose + self.put_start_time = None + + # shared variables + self.stop_event = mp.Event() + self.ready_event = mp.Event() + self.ring_buffer = ring_buffer + self.vis_ring_buffer = vis_ring_buffer + self.command_queue = command_queue + self.intrinsics_array = intrinsics_array + + @staticmethod + def get_connected_devices_serial(): + serials = list() + for d in rs.context().devices: + if d.get_info(rs.camera_info.name).lower() != 'platform camera': + serial = d.get_info(rs.camera_info.serial_number) + product_line = d.get_info(rs.camera_info.product_line) + if product_line == 'D400': + # only works with D400 series + serials.append(serial) + serials = sorted(serials) + return serials + + # ========= context manager =========== + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + # ========= user API =========== + def start(self, wait=True, put_start_time=None): + self.put_start_time = put_start_time + super().start() + if wait: + self.start_wait() + + def stop(self, wait=True): + self.stop_event.set() + if wait: + self.end_wait() + + def start_wait(self): + self.ready_event.wait() + + def end_wait(self): + self.join() + + @property + def is_ready(self): + return self.ready_event.is_set() + + def get(self, k=None, out=None): + if k is None: + return self.ring_buffer.get(out=out) + else: + return self.ring_buffer.get_last_k(k, out=out) + + def get_vis(self, out=None): + return self.vis_ring_buffer.get(out=out) + + # ========= user API =========== + def set_color_option(self, option: rs.option, value: float): + self.command_queue.put({ + 'cmd': Command.SET_COLOR_OPTION.value, + 'option_enum': option.value, + 'option_value': value + }) + + def set_exposure(self, exposure=None, gain=None): + """ + exposure: (1, 10000) 100us unit. (0.1 ms, 1/10000s) + gain: (0, 128) + """ + + if exposure is None and gain is None: + # auto exposure + self.set_color_option(rs.option.enable_auto_exposure, 1.0) + else: + # manual exposure + self.set_color_option(rs.option.enable_auto_exposure, 0.0) + if exposure is not None: + self.set_color_option(rs.option.exposure, exposure) + if gain is not None: + self.set_color_option(rs.option.gain, gain) + + def set_white_balance(self, white_balance=None): + if white_balance is None: + self.set_color_option(rs.option.enable_auto_white_balance, 1.0) + else: + self.set_color_option(rs.option.enable_auto_white_balance, 0.0) + self.set_color_option(rs.option.white_balance, white_balance) + + def get_intrinsics(self): + assert self.ready_event.is_set() + fx, fy, ppx, ppy = self.intrinsics_array.get()[:4] + mat = np.eye(3) + mat[0,0] = fx + mat[1,1] = fy + mat[0,2] = ppx + mat[1,2] = ppy + return mat + + def get_depth_scale(self): + assert self.ready_event.is_set() + scale = self.intrinsics_array.get()[-1] + return scale + + def start_recording(self, video_path: str, start_time: float=-1): + assert self.enable_color + + path_len = len(video_path.encode('utf-8')) + if path_len > self.MAX_PATH_LENGTH: + raise RuntimeError('video_path too long.') + self.command_queue.put({ + 'cmd': Command.START_RECORDING.value, + 'video_path': video_path, + 'recording_start_time': start_time + }) + + def stop_recording(self): + self.command_queue.put({ + 'cmd': Command.STOP_RECORDING.value + }) + + def restart_put(self, start_time): + self.command_queue.put({ + 'cmd': Command.RESTART_PUT.value, + 'put_start_time': start_time + }) + + # ========= interval API =========== + def run(self): + # limit threads + threadpool_limits(1) + cv2.setNumThreads(1) + + w, h = self.resolution + fps = self.capture_fps + align = rs.align(rs.stream.color) + # Enable the streams from all the intel realsense devices + rs_config = rs.config() + if self.enable_color: + rs_config.enable_stream(rs.stream.color, + w, h, rs.format.bgr8, fps) + if self.enable_depth: + rs_config.enable_stream(rs.stream.depth, + w, h, rs.format.z16, fps) + if self.enable_infrared: + rs_config.enable_stream(rs.stream.infrared, + w, h, rs.format.y8, fps) + + try: + rs_config.enable_device(self.serial_number) + + # start pipeline + pipeline = rs.pipeline() + pipeline_profile = pipeline.start(rs_config) + + # report global time + # https://github.com/IntelRealSense/librealsense/pull/3909 + d = pipeline_profile.get_device().first_color_sensor() + d.set_option(rs.option.global_time_enabled, 1) + + # setup advanced mode + if self.advanced_mode_config is not None: + json_text = json.dumps(self.advanced_mode_config) + device = pipeline_profile.get_device() + advanced_mode = rs.rs400_advanced_mode(device) + advanced_mode.load_json(json_text) + + # get + color_stream = pipeline_profile.get_stream(rs.stream.color) + intr = color_stream.as_video_stream_profile().get_intrinsics() + order = ['fx', 'fy', 'ppx', 'ppy', 'height', 'width'] + for i, name in enumerate(order): + self.intrinsics_array.get()[i] = getattr(intr, name) + + if self.enable_depth: + depth_sensor = pipeline_profile.get_device().first_depth_sensor() + depth_scale = depth_sensor.get_depth_scale() + self.intrinsics_array.get()[-1] = depth_scale + + # one-time setup (intrinsics etc, ignore for now) + if self.verbose: + print(f'[SingleRealsense {self.serial_number}] Main loop started.') + + # put frequency regulation + put_idx = None + put_start_time = self.put_start_time + if put_start_time is None: + put_start_time = time.time() + + iter_idx = 0 + t_start = time.time() + while not self.stop_event.is_set(): + # wait for frames to come in + frameset = pipeline.wait_for_frames() + receive_time = time.time() + # align frames to color + frameset = align.process(frameset) + + # grab data + data = dict() + data['camera_receive_timestamp'] = receive_time + # realsense report in ms + data['camera_capture_timestamp'] = frameset.get_timestamp() / 1000 + if self.enable_color: + color_frame = frameset.get_color_frame() + data['color'] = np.asarray(color_frame.get_data()) + t = color_frame.get_timestamp() / 1000 + data['camera_capture_timestamp'] = t + # print('device', time.time() - t) + # print(color_frame.get_frame_timestamp_domain()) + if self.enable_depth: + data['depth'] = np.asarray( + frameset.get_depth_frame().get_data()) + if self.enable_infrared: + data['infrared'] = np.asarray( + frameset.get_infrared_frame().get_data()) + + # apply transform + put_data = data + if self.transform is not None: + put_data = self.transform(dict(data)) + + if self.put_downsample: + # put frequency regulation + local_idxs, global_idxs, put_idx \ + = get_accumulate_timestamp_idxs( + timestamps=[receive_time], + start_time=put_start_time, + dt=1/self.put_fps, + # this is non in first iteration + # and then replaced with a concrete number + next_global_idx=put_idx, + # continue to pump frames even if not started. + # start_time is simply used to align timestamps. + allow_negative=True + ) + + for step_idx in global_idxs: + put_data['step_idx'] = step_idx + # put_data['timestamp'] = put_start_time + step_idx / self.put_fps + put_data['timestamp'] = receive_time + # print(step_idx, data['timestamp']) + self.ring_buffer.put(put_data, wait=False) + else: + step_idx = int((receive_time - put_start_time) * self.put_fps) + put_data['step_idx'] = step_idx + put_data['timestamp'] = receive_time + self.ring_buffer.put(put_data, wait=False) + + # signal ready + if iter_idx == 0: + self.ready_event.set() + + # put to vis + vis_data = data + if self.vis_transform == self.transform: + vis_data = put_data + elif self.vis_transform is not None: + vis_data = self.vis_transform(dict(data)) + self.vis_ring_buffer.put(vis_data, wait=False) + + # record frame + rec_data = data + if self.recording_transform == self.transform: + rec_data = put_data + elif self.recording_transform is not None: + rec_data = self.recording_transform(dict(data)) + + if self.video_recorder.is_ready(): + self.video_recorder.write_frame(rec_data['color'], + frame_time=receive_time) + + # perf + t_end = time.time() + duration = t_end - t_start + frequency = np.round(1 / duration, 1) + t_start = t_end + if self.verbose: + print(f'[SingleRealsense {self.serial_number}] FPS {frequency}') + + # fetch command from queue + try: + commands = self.command_queue.get_all() + n_cmd = len(commands['cmd']) + except Empty: + n_cmd = 0 + + # execute commands + for i in range(n_cmd): + command = dict() + for key, value in commands.items(): + command[key] = value[i] + cmd = command['cmd'] + if cmd == Command.SET_COLOR_OPTION.value: + sensor = pipeline_profile.get_device().first_color_sensor() + option = rs.option(command['option_enum']) + value = float(command['option_value']) + sensor.set_option(option, value) + # print('auto', sensor.get_option(rs.option.enable_auto_exposure)) + # print('exposure', sensor.get_option(rs.option.exposure)) + # print('gain', sensor.get_option(rs.option.gain)) + elif cmd == Command.SET_DEPTH_OPTION.value: + sensor = pipeline_profile.get_device().first_depth_sensor() + option = rs.option(command['option_enum']) + value = float(command['option_value']) + sensor.set_option(option, value) + elif cmd == Command.START_RECORDING.value: + video_path = str(command['video_path']) + start_time = command['recording_start_time'] + if start_time < 0: + start_time = None + self.video_recorder.start(video_path, start_time=start_time) + elif cmd == Command.STOP_RECORDING.value: + self.video_recorder.stop() + # stop need to flush all in-flight frames to disk, which might take longer than dt. + # soft-reset put to drop frames to prevent ring buffer overflow. + put_idx = None + elif cmd == Command.RESTART_PUT.value: + put_idx = None + put_start_time = command['put_start_time'] + # self.ring_buffer.clear() + + iter_idx += 1 + finally: + self.video_recorder.stop() + rs_config.disable_all_streams() + self.ready_event.set() + + if self.verbose: + print(f'[SingleRealsense {self.serial_number}] Exiting worker process.') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse.py new file mode 100644 index 0000000000000000000000000000000000000000..c13a505550ee0f0520a7324dd04f521cc8bc4c82 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse.py @@ -0,0 +1,108 @@ +from spnav import spnav_open, spnav_poll_event, spnav_close, SpnavMotionEvent, SpnavButtonEvent +from threading import Thread, Event +from collections import defaultdict +import numpy as np +import time + + +class Spacemouse(Thread): + def __init__(self, max_value=500, deadzone=(0,0,0,0,0,0), dtype=np.float32): + """ + Continuously listen to 3D connection space naviagtor events + and update the latest state. + + max_value: {300, 500} 300 for wired version and 500 for wireless + deadzone: [0,1], number or tuple, axis with value lower than this value will stay at 0 + + front + z + ^ _ + | (O) space mouse + | + *----->x right + y + """ + if np.issubdtype(type(deadzone), np.number): + deadzone = np.full(6, fill_value=deadzone, dtype=dtype) + else: + deadzone = np.array(deadzone, dtype=dtype) + assert (deadzone >= 0).all() + + super().__init__() + self.stop_event = Event() + self.max_value = max_value + self.dtype = dtype + self.deadzone = deadzone + self.motion_event = SpnavMotionEvent([0,0,0], [0,0,0], 0) + self.button_state = defaultdict(lambda: False) + self.tx_zup_spnav = np.array([ + [0,0,-1], + [1,0,0], + [0,1,0] + ], dtype=dtype) + + def get_motion_state(self): + me = self.motion_event + state = np.array(me.translation + me.rotation, + dtype=self.dtype) / self.max_value + is_dead = (-self.deadzone < state) & (state < self.deadzone) + state[is_dead] = 0 + return state + + def get_motion_state_transformed(self): + """ + Return in right-handed coordinate + z + *------>y right + | _ + | (O) space mouse + v + x + back + + """ + state = self.get_motion_state() + tf_state = np.zeros_like(state) + tf_state[:3] = self.tx_zup_spnav @ state[:3] + tf_state[3:] = self.tx_zup_spnav @ state[3:] + return tf_state + + def is_button_pressed(self, button_id): + return self.button_state[button_id] + + def stop(self): + self.stop_event.set() + self.join() + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + def run(self): + spnav_open() + try: + while not self.stop_event.is_set(): + event = spnav_poll_event() + if isinstance(event, SpnavMotionEvent): + self.motion_event = event + elif isinstance(event, SpnavButtonEvent): + self.button_state[event.bnum] = event.press + else: + time.sleep(1/200) + finally: + spnav_close() + + +def test(): + with Spacemouse(deadzone=0.3) as sm: + for i in range(2000): + # print(sm.get_motion_state()) + print(sm.get_motion_state_transformed()) + print(sm.is_button_pressed(0)) + time.sleep(1/100) + +if __name__ == '__main__': + test() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse_shared_memory.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse_shared_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..06102fdb25cea0f5da153f0bdc3f7a3eb7675918 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/spacemouse_shared_memory.py @@ -0,0 +1,160 @@ +import multiprocessing as mp +import numpy as np +import time +from spnav import spnav_open, spnav_poll_event, spnav_close, SpnavMotionEvent, SpnavButtonEvent +from diffusion_policy.shared_memory.shared_memory_ring_buffer import SharedMemoryRingBuffer + +class Spacemouse(mp.Process): + def __init__(self, + shm_manager, + get_max_k=30, + frequency=200, + max_value=500, + deadzone=(0,0,0,0,0,0), + dtype=np.float32, + n_buttons=2, + ): + """ + Continuously listen to 3D connection space naviagtor events + and update the latest state. + + max_value: {300, 500} 300 for wired version and 500 for wireless + deadzone: [0,1], number or tuple, axis with value lower than this value will stay at 0 + + front + z + ^ _ + | (O) space mouse + | + *----->x right + y + """ + super().__init__() + if np.issubdtype(type(deadzone), np.number): + deadzone = np.full(6, fill_value=deadzone, dtype=dtype) + else: + deadzone = np.array(deadzone, dtype=dtype) + assert (deadzone >= 0).all() + + # copied variables + self.frequency = frequency + self.max_value = max_value + self.dtype = dtype + self.deadzone = deadzone + self.n_buttons = n_buttons + # self.motion_event = SpnavMotionEvent([0,0,0], [0,0,0], 0) + # self.button_state = defaultdict(lambda: False) + self.tx_zup_spnav = np.array([ + [0,0,-1], + [1,0,0], + [0,1,0] + ], dtype=dtype) + + example = { + # 3 translation, 3 rotation, 1 period + 'motion_event': np.zeros((7,), dtype=np.int64), + # left and right button + 'button_state': np.zeros((n_buttons,), dtype=bool), + 'receive_timestamp': time.time() + } + ring_buffer = SharedMemoryRingBuffer.create_from_examples( + shm_manager=shm_manager, + examples=example, + get_max_k=get_max_k, + get_time_budget=0.2, + put_desired_frequency=frequency + ) + + # shared variables + self.ready_event = mp.Event() + self.stop_event = mp.Event() + self.ring_buffer = ring_buffer + + # ======= get state APIs ========== + + def get_motion_state(self): + state = self.ring_buffer.get() + state = np.array(state['motion_event'][:6], + dtype=self.dtype) / self.max_value + is_dead = (-self.deadzone < state) & (state < self.deadzone) + state[is_dead] = 0 + return state + + def get_motion_state_transformed(self): + """ + Return in right-handed coordinate + z + *------>y right + | _ + | (O) space mouse + v + x + back + + """ + state = self.get_motion_state() + tf_state = np.zeros_like(state) + tf_state[:3] = self.tx_zup_spnav @ state[:3] + tf_state[3:] = self.tx_zup_spnav @ state[3:] + return tf_state + + def get_button_state(self): + state = self.ring_buffer.get() + return state['button_state'] + + def is_button_pressed(self, button_id): + return self.get_button_state()[button_id] + + #========== start stop API =========== + + def start(self, wait=True): + super().start() + if wait: + self.ready_event.wait() + + def stop(self, wait=True): + self.stop_event.set() + if wait: + self.join() + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + # ========= main loop ========== + def run(self): + spnav_open() + try: + motion_event = np.zeros((7,), dtype=np.int64) + button_state = np.zeros((self.n_buttons,), dtype=bool) + # send one message immediately so client can start reading + self.ring_buffer.put({ + 'motion_event': motion_event, + 'button_state': button_state, + 'receive_timestamp': time.time() + }) + self.ready_event.set() + + while not self.stop_event.is_set(): + event = spnav_poll_event() + receive_timestamp = time.time() + if isinstance(event, SpnavMotionEvent): + motion_event[:3] = event.translation + motion_event[3:6] = event.rotation + motion_event[6] = event.period + elif isinstance(event, SpnavButtonEvent): + button_state[event.bnum] = event.press + else: + # finish integrating this round of events + # before sending over + self.ring_buffer.put({ + 'motion_event': motion_event, + 'button_state': button_state, + 'receive_timestamp': receive_timestamp + }) + time.sleep(1/self.frequency) + finally: + spnav_close() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/video_recorder.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/video_recorder.py new file mode 100644 index 0000000000000000000000000000000000000000..434605f82d38752206bbcac85e86590a1b3b0107 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/real_world/video_recorder.py @@ -0,0 +1,161 @@ +from typing import Optional, Callable, Generator +import numpy as np +import av +from diffusion_policy.common.timestamp_accumulator import get_accumulate_timestamp_idxs + +def read_video( + video_path: str, dt: float, + video_start_time: float=0.0, + start_time: float=0.0, + img_transform: Optional[Callable[[np.ndarray], np.ndarray]]=None, + thread_type: str="AUTO", + thread_count: int=0, + max_pad_frames: int=10 + ) -> Generator[np.ndarray, None, None]: + frame = None + with av.open(video_path) as container: + stream = container.streams.video[0] + stream.thread_type = thread_type + stream.thread_count = thread_count + next_global_idx = 0 + for frame_idx, frame in enumerate(container.decode(stream)): + # The presentation time in seconds for this frame. + since_start = frame.time + frame_time = video_start_time + since_start + local_idxs, global_idxs, next_global_idx \ + = get_accumulate_timestamp_idxs( + # only one timestamp + timestamps=[frame_time], + start_time=start_time, + dt=dt, + next_global_idx=next_global_idx + ) + if len(global_idxs) > 0: + array = frame.to_ndarray(format='rgb24') + img = array + if img_transform is not None: + img = img_transform(array) + for global_idx in global_idxs: + yield img + # repeat last frame max_pad_frames times + array = frame.to_ndarray(format='rgb24') + img = array + if img_transform is not None: + img = img_transform(array) + for i in range(max_pad_frames): + yield img + +class VideoRecorder: + def __init__(self, + fps, + codec, + input_pix_fmt, + # options for codec + **kwargs + ): + """ + input_pix_fmt: rgb24, bgr24 see https://github.com/PyAV-Org/PyAV/blob/bc4eedd5fc474e0f25b22102b2771fe5a42bb1c7/av/video/frame.pyx#L352 + """ + + self.fps = fps + self.codec = codec + self.input_pix_fmt = input_pix_fmt + self.kwargs = kwargs + # runtime set + self._reset_state() + + def _reset_state(self): + self.container = None + self.stream = None + self.shape = None + self.dtype = None + self.start_time = None + self.next_global_idx = 0 + + @classmethod + def create_h264(cls, + fps, + codec='h264', + input_pix_fmt='rgb24', + output_pix_fmt='yuv420p', + crf=18, + profile='high', + **kwargs + ): + obj = cls( + fps=fps, + codec=codec, + input_pix_fmt=input_pix_fmt, + pix_fmt=output_pix_fmt, + options={ + 'crf': str(crf), + 'profile': profile + }, + **kwargs + ) + return obj + + + def __del__(self): + self.stop() + + def is_ready(self): + return self.stream is not None + + def start(self, file_path, start_time=None): + if self.is_ready(): + # if still recording, stop first and start anew. + self.stop() + + self.container = av.open(file_path, mode='w') + self.stream = self.container.add_stream(self.codec, rate=self.fps) + codec_context = self.stream.codec_context + for k, v in self.kwargs.items(): + setattr(codec_context, k, v) + self.start_time = start_time + + def write_frame(self, img: np.ndarray, frame_time=None): + if not self.is_ready(): + raise RuntimeError('Must run start() before writing!') + + n_repeats = 1 + if self.start_time is not None: + local_idxs, global_idxs, self.next_global_idx \ + = get_accumulate_timestamp_idxs( + # only one timestamp + timestamps=[frame_time], + start_time=self.start_time, + dt=1/self.fps, + next_global_idx=self.next_global_idx + ) + # number of appearance means repeats + n_repeats = len(local_idxs) + + if self.shape is None: + self.shape = img.shape + self.dtype = img.dtype + h,w,c = img.shape + self.stream.width = w + self.stream.height = h + assert img.shape == self.shape + assert img.dtype == self.dtype + + frame = av.VideoFrame.from_ndarray( + img, format=self.input_pix_fmt) + for i in range(n_repeats): + for packet in self.stream.encode(frame): + self.container.mux(packet) + + def stop(self): + if not self.is_ready(): + return + + # Flush stream + for packet in self.stream.encode(): + self.container.mux(packet) + + # Close the file + self.container.close() + + # reset runtime parameters + self._reset_state() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/episode_lengths.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/episode_lengths.py new file mode 100644 index 0000000000000000000000000000000000000000..91b2ddaaedaa94bc17f5fff99f6c5c2f5eb54ad7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/episode_lengths.py @@ -0,0 +1,29 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + +import click +import numpy as np +import json +from diffusion_policy.common.replay_buffer import ReplayBuffer + +@click.command() +@click.option('--input', '-i', required=True) +@click.option('--dt', default=0.1, type=float) +def main(input, dt): + buffer = ReplayBuffer.create_from_path(input) + lengths = buffer.episode_lengths + durations = lengths * dt + result = { + 'duration/mean': np.mean(durations) + } + + text = json.dumps(result, indent=2) + print(text) + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_dataset_conversion.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_dataset_conversion.py new file mode 100644 index 0000000000000000000000000000000000000000..3e4b0f9c082a69d6ada3ac6a76ad2c203850496e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_dataset_conversion.py @@ -0,0 +1,60 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + +import os +import click +import pathlib +import zarr +import cv2 +import threadpoolctl +from diffusion_policy.real_world.real_data_conversion import real_data_to_replay_buffer + +@click.command() +@click.option('--input', '-i', required=True) +@click.option('--output', '-o', default=None) +@click.option('--resolution', '-r', default='640x480') +@click.option('--n_decoding_threads', '-nd', default=-1, type=int) +@click.option('--n_encoding_threads', '-ne', default=-1, type=int) +def main(input, output, resolution, n_decoding_threads, n_encoding_threads): + out_resolution = tuple(int(x) for x in resolution.split('x')) + input = pathlib.Path(os.path.expanduser(input)) + in_zarr_path = input.joinpath('replay_buffer.zarr') + in_video_dir = input.joinpath('videos') + assert in_zarr_path.is_dir() + assert in_video_dir.is_dir() + if output is None: + output = input.joinpath(resolution + '.zarr.zip') + else: + output = pathlib.Path(os.path.expanduser(output)) + + if output.exists(): + click.confirm('Output path already exists! Overrite?', abort=True) + + cv2.setNumThreads(1) + with threadpoolctl.threadpool_limits(1): + replay_buffer = real_data_to_replay_buffer( + dataset_path=str(input), + out_resolutions=out_resolution, + n_decoding_threads=n_decoding_threads, + n_encoding_threads=n_encoding_threads + ) + + print('Saving to disk') + if output.suffix == '.zip': + with zarr.ZipStore(output) as zip_store: + replay_buffer.save_to_store( + store=zip_store + ) + else: + with zarr.DirectoryStore(output) as store: + replay_buffer.save_to_store( + store=store + ) + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_metrics.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..cdb05ef713fda85d36e84fc0f62349712ad58b09 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_metrics.py @@ -0,0 +1,151 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + +import os +import click +import av +import cv2 +import collections +import multiprocessing as mp +import numpy as np +from tqdm import tqdm +import threadpoolctl +from matplotlib import pyplot as plt +import json + +def get_t_mask(img, hsv_ranges=None): + if hsv_ranges is None: + hsv_ranges = [ + [0,255], + [130,216], + [150,230] + ] + hsv_img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV) + mask = np.ones(img.shape[:2], dtype=bool) + for c in range(len(hsv_ranges)): + l, h = hsv_ranges[c] + mask &= (l <= hsv_img[...,c]) + mask &= (hsv_img[...,c] <= h) + return mask + +def get_mask_metrics(target_mask, mask): + total = np.sum(target_mask) + i = np.sum(target_mask & mask) + u = np.sum(target_mask | mask) + iou = i / u + coverage = i / total + result = { + 'iou': iou, + 'coverage': coverage + } + return result + +def get_video_metrics(video_path, target_mask, use_tqdm=True): + threadpoolctl.threadpool_limits(1) + cv2.setNumThreads(1) + + metrics = collections.defaultdict(list) + with av.open(video_path) as container: + stream = container.streams.video[0] + iterator = None + if use_tqdm: + iterator = tqdm(container.decode(stream), total=stream.frames) + else: + iterator = container.decode(stream) + for frame in iterator: + img = frame.to_ndarray(format='rgb24') + mask = get_t_mask(img) + metric = get_mask_metrics( + target_mask=target_mask, mask=mask) + for k, v in metric.items(): + metrics[k].append(v) + return metrics + +def worker(x): + return get_video_metrics(*x) + +@click.command() +@click.option( + '--reference', '-r', required=True, + help="Reference video whose last frame will define goal.") +@click.option( + '--input', '-i', required=True, + help='Dataset path to evaluate.') +@click.option( + '--camera_idx', '-ci', default=0, type=int, + help="Camera index to compute metrics") +@click.option('--n_workers', '-n', default=20, type=int) +def main(reference, input, camera_idx, n_workers): + # read last frame of the reference video to get target mask + last_frame = None + with av.open(reference) as container: + stream = container.streams.video[0] + for frame in tqdm( + container.decode(stream), + total=stream.frames): + last_frame = frame + + last_img = last_frame.to_ndarray(format='rgb24') + target_mask = get_t_mask(last_img) + + # path = '/home/ubuntu/dev/diffusion_policy/data/pusht_real/eval_20230109/diffusion_hybrid_ep136/videos/4/0.mp4' + # last_frame = None + # with av.open(path) as container: + # stream = container.streams.video[0] + # for frame in tqdm( + # container.decode(stream), + # total=stream.frames): + # last_frame = frame + # img = last_frame.to_ndarray(format='rgb24') + # mask = get_t_mask(img) + + # get metrics for each episode + episode_video_path_map = dict() + input_dir = pathlib.Path(input) + input_video_dir = input_dir.joinpath('videos') + for vid_dir in input_video_dir.glob("*/"): + episode_idx = int(vid_dir.stem) + video_path = vid_dir.joinpath(f'{camera_idx}.mp4') + if video_path.exists(): + episode_video_path_map[episode_idx] = str(video_path.absolute()) + + episode_idxs = sorted(episode_video_path_map.keys()) + print(f"Found video for following episodes: {episode_idxs}") + + # run + with mp.Pool(n_workers) as pool: + args = list() + for idx in episode_idxs: + args.append((episode_video_path_map[idx], target_mask)) + results = pool.map(worker, args) + episode_metric_map = dict() + for idx, result in zip(episode_idxs, results): + episode_metric_map[idx] = result + + # aggregate metrics + agg_map = collections.defaultdict(list) + for idx, metric in episode_metric_map.items(): + for key, value in metric.items(): + agg_map['max/'+key].append(np.max(value)) + agg_map['last/'+key].append(value[-1]) + + final_metric = dict() + for key, value in agg_map.items(): + final_metric[key] = np.mean(value) + + # save metrics + print('Saving metrics!') + with input_dir.joinpath('metrics_agg.json').open('w') as f: + json.dump(final_metric, f, sort_keys=True, indent=2) + + with input_dir.joinpath('metrics_raw.json').open('w') as f: + json.dump(episode_metric_map, f, sort_keys=True, indent=2) + print('Done!') + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_successrate.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_successrate.py new file mode 100644 index 0000000000000000000000000000000000000000..a5900faadfaefe3452e424921b07c51e9c17fbd0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/scripts/real_pusht_successrate.py @@ -0,0 +1,69 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + +import os +import click +import collections +import numpy as np +from tqdm import tqdm +import json + +@click.command() +@click.option( + '--reference', '-r', required=True, + help='Reference metrics_raw.json from demonstration dataset.' +) +@click.option( + '--input', '-i', required=True, + help='Data search path' +) +def main(reference, input): + # compute the min last metric for demo metrics + demo_metrics = json.load(open(reference, 'r')) + demo_min_metrics = collections.defaultdict(lambda:float('inf')) + for episode_idx, metrics in demo_metrics.items(): + for key, value in metrics.items(): + last_value = value[-1] + demo_min_metrics[key] = min(demo_min_metrics[key], last_value) + print(demo_min_metrics) + + # find all metric + name = 'metrics_raw.json' + search_dir = pathlib.Path(input) + success_rate_map = dict() + for json_path in search_dir.glob('**/'+name): + rel_path = json_path.relative_to(search_dir) + rel_name = str(rel_path.parent) + this_metrics = json.load(json_path.open('r')) + metric_success_idxs = collections.defaultdict(list) + metric_failure_idxs = collections.defaultdict(list) + for episode_idx, metrics in this_metrics.items(): + for key, value in metrics.items(): + last_value = value[-1] + # print(episode_idx, key, last_value) + demo_min = demo_min_metrics[key] + if last_value >= demo_min: + # success + metric_success_idxs[key].append(episode_idx) + else: + metric_failure_idxs[key].append(episode_idx) + # in case of no success + _ = metric_success_idxs[key] + _ = metric_failure_idxs[key] + metric_success_rate = dict() + n_episodes = len(this_metrics) + for key, value in metric_success_idxs.items(): + metric_success_rate[key] = len(value) / n_episodes + # metric_success_rate['failured_idxs'] = metric_failure_idxs + success_rate_map[rel_name] = metric_success_rate + + text = json.dumps(success_rate_map, indent=2) + print(text) + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_queue.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f316386a8dbabff5998c04794cf7a90b28bdf9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_queue.py @@ -0,0 +1,187 @@ +from typing import Dict, List, Union +import numbers +from queue import (Empty, Full) +from multiprocessing.managers import SharedMemoryManager +import numpy as np +from diffusion_policy.shared_memory.shared_memory_util import ArraySpec, SharedAtomicCounter +from diffusion_policy.shared_memory.shared_ndarray import SharedNDArray + + +class SharedMemoryQueue: + """ + A Lock-Free FIFO Shared Memory Data Structure. + Stores a sequence of dict of numpy arrays. + """ + + def __init__(self, + shm_manager: SharedMemoryManager, + array_specs: List[ArraySpec], + buffer_size: int + ): + + # create atomic counter + write_counter = SharedAtomicCounter(shm_manager) + read_counter = SharedAtomicCounter(shm_manager) + + # allocate shared memory + shared_arrays = dict() + for spec in array_specs: + key = spec.name + assert key not in shared_arrays + array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,) + tuple(spec.shape), + dtype=spec.dtype) + shared_arrays[key] = array + + self.buffer_size = buffer_size + self.array_specs = array_specs + self.write_counter = write_counter + self.read_counter = read_counter + self.shared_arrays = shared_arrays + + @classmethod + def create_from_examples(cls, + shm_manager: SharedMemoryManager, + examples: Dict[str, Union[np.ndarray, numbers.Number]], + buffer_size: int + ): + specs = list() + for key, value in examples.items(): + shape = None + dtype = None + if isinstance(value, np.ndarray): + shape = value.shape + dtype = value.dtype + assert dtype != np.dtype('O') + elif isinstance(value, numbers.Number): + shape = tuple() + dtype = np.dtype(type(value)) + else: + raise TypeError(f'Unsupported type {type(value)}') + + spec = ArraySpec( + name=key, + shape=shape, + dtype=dtype + ) + specs.append(spec) + + obj = cls( + shm_manager=shm_manager, + array_specs=specs, + buffer_size=buffer_size + ) + return obj + + def qsize(self): + read_count = self.read_counter.load() + write_count = self.write_counter.load() + n_data = write_count - read_count + return n_data + + def empty(self): + n_data = self.qsize() + return n_data <= 0 + + def clear(self): + self.read_counter.store(self.write_counter.load()) + + def put(self, data: Dict[str, Union[np.ndarray, numbers.Number]]): + read_count = self.read_counter.load() + write_count = self.write_counter.load() + n_data = write_count - read_count + if n_data >= self.buffer_size: + raise Full() + + next_idx = write_count % self.buffer_size + + # write to shared memory + for key, value in data.items(): + arr: np.ndarray + arr = self.shared_arrays[key].get() + if isinstance(value, np.ndarray): + arr[next_idx] = value + else: + arr[next_idx] = np.array(value, dtype=arr.dtype) + + # update idx + self.write_counter.add(1) + + def get(self, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + + if out is None: + out = self._allocate_empty() + + next_idx = read_count % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + np.copyto(out[key], arr[next_idx]) + + # update idx + self.read_counter.add(1) + return out + + def get_k(self, k, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + assert k <= n_data + + out = self._get_k_impl(k, read_count, out=out) + self.read_counter.add(k) + return out + + def get_all(self, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + + out = self._get_k_impl(n_data, read_count, out=out) + self.read_counter.add(n_data) + return out + + def _get_k_impl(self, k, read_count, out=None) -> Dict[str, np.ndarray]: + if out is None: + out = self._allocate_empty(k) + + curr_idx = read_count % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + target = out[key] + + start = curr_idx + end = min(start + k, self.buffer_size) + target_start = 0 + target_end = (end - start) + target[target_start: target_end] = arr[start:end] + + remainder = k - (end - start) + if remainder > 0: + # wrap around + start = 0 + end = start + remainder + target_start = target_end + target_end = k + target[target_start: target_end] = arr[start:end] + + return out + + def _allocate_empty(self, k=None): + result = dict() + for spec in self.array_specs: + shape = spec.shape + if k is not None: + shape = (k,) + shape + result[spec.name] = np.empty( + shape=shape, dtype=spec.dtype) + return result diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_ring_buffer.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_ring_buffer.py new file mode 100644 index 0000000000000000000000000000000000000000..23feef0b14f742fa3a3cacd38e9dde7914c6336a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_ring_buffer.py @@ -0,0 +1,219 @@ +from typing import Dict, List, Union + +from queue import Empty +import numbers +import time +from multiprocessing.managers import SharedMemoryManager +import numpy as np + +from diffusion_policy.shared_memory.shared_ndarray import SharedNDArray +from diffusion_policy.shared_memory.shared_memory_util import ArraySpec, SharedAtomicCounter + +class SharedMemoryRingBuffer: + """ + A Lock-Free FILO Shared Memory Data Structure. + Stores a sequence of dict of numpy arrays. + """ + + def __init__(self, + shm_manager: SharedMemoryManager, + array_specs: List[ArraySpec], + get_max_k: int, + get_time_budget: float, + put_desired_frequency: float, + safety_margin: float=1.5 + ): + """ + shm_manager: Manages the life cycle of share memories + across processes. Remember to run .start() before passing. + array_specs: Name, shape and type of arrays for a single time step. + get_max_k: The maxmum number of items can be queried at once. + get_time_budget: The maxmum amount of time spent copying data from + shared memory to local memory. Increase this number for larger arrays. + put_desired_frequency: The maximum frequency that .put() can be called. + This influces the buffer size. + """ + + # create atomic counter + counter = SharedAtomicCounter(shm_manager) + + # compute buffer size + # At any given moment, the past get_max_k items should never + # be touched (to be read freely). Assuming the reading is reading + # these k items, which takes maximum of get_time_budget seconds, + # we need enough empty slots to make sure put_desired_frequency Hz + # of put can be sustaied. + buffer_size = int(np.ceil( + put_desired_frequency * get_time_budget + * safety_margin)) + get_max_k + + # allocate shared memory + shared_arrays = dict() + for spec in array_specs: + key = spec.name + assert key not in shared_arrays + array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,) + tuple(spec.shape), + dtype=spec.dtype) + shared_arrays[key] = array + + # allocate timestamp array + timestamp_array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,), + dtype=np.float64) + timestamp_array.get()[:] = -np.inf + + self.buffer_size = buffer_size + self.array_specs = array_specs + self.counter = counter + self.shared_arrays = shared_arrays + self.timestamp_array = timestamp_array + self.get_time_budget = get_time_budget + self.get_max_k = get_max_k + self.put_desired_frequency = put_desired_frequency + + + @property + def count(self): + return self.counter.load() + + @classmethod + def create_from_examples(cls, + shm_manager: SharedMemoryManager, + examples: Dict[str, Union[np.ndarray, numbers.Number]], + get_max_k: int=32, + get_time_budget: float=0.01, + put_desired_frequency: float=60 + ): + specs = list() + for key, value in examples.items(): + shape = None + dtype = None + if isinstance(value, np.ndarray): + shape = value.shape + dtype = value.dtype + assert dtype != np.dtype('O') + elif isinstance(value, numbers.Number): + shape = tuple() + dtype = np.dtype(type(value)) + else: + raise TypeError(f'Unsupported type {type(value)}') + + spec = ArraySpec( + name=key, + shape=shape, + dtype=dtype + ) + specs.append(spec) + + obj = cls( + shm_manager=shm_manager, + array_specs=specs, + get_max_k=get_max_k, + get_time_budget=get_time_budget, + put_desired_frequency=put_desired_frequency + ) + return obj + + def clear(self): + self.counter.store(0) + + def put(self, data: Dict[str, Union[np.ndarray, numbers.Number]], wait: bool=True): + count = self.counter.load() + next_idx = count % self.buffer_size + # Make sure the next self.get_max_k elements in the ring buffer have at least + # self.get_time_budget seconds untouched after written, so that + # get_last_k can safely read k elements from any count location. + # Sanity check: when get_max_k == 1, the element pointed by next_idx + # should be rewritten at minimum self.get_time_budget seconds later. + timestamp_lookahead_idx = (next_idx + self.get_max_k - 1) % self.buffer_size + old_timestamp = self.timestamp_array.get()[timestamp_lookahead_idx] + t = time.monotonic() + if (t - old_timestamp) < self.get_time_budget: + deltat = t - old_timestamp + if wait: + # sleep the remaining time to be safe + time.sleep(self.get_time_budget - deltat) + else: + # throw an error + past_iters = self.buffer_size - self.get_max_k + hz = past_iters / deltat + raise TimeoutError( + 'Put executed too fast {}items/{:.4f}s ~= {}Hz'.format( + past_iters, deltat,hz)) + + # write to shared memory + for key, value in data.items(): + arr: np.ndarray + arr = self.shared_arrays[key].get() + if isinstance(value, np.ndarray): + arr[next_idx] = value + else: + arr[next_idx] = np.array(value, dtype=arr.dtype) + + # update timestamp + self.timestamp_array.get()[next_idx] = time.monotonic() + self.counter.add(1) + + def _allocate_empty(self, k=None): + result = dict() + for spec in self.array_specs: + shape = spec.shape + if k is not None: + shape = (k,) + shape + result[spec.name] = np.empty( + shape=shape, dtype=spec.dtype) + return result + + def get(self, out=None) -> Dict[str, np.ndarray]: + if out is None: + out = self._allocate_empty() + start_time = time.monotonic() + count = self.counter.load() + curr_idx = (count - 1) % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + np.copyto(out[key], arr[curr_idx]) + end_time = time.monotonic() + dt = end_time - start_time + if dt > self.get_time_budget: + raise TimeoutError(f'Get time out {dt} vs {self.get_time_budget}') + return out + + def get_last_k(self, k:int, out=None) -> Dict[str, np.ndarray]: + assert k <= self.get_max_k + if out is None: + out = self._allocate_empty(k) + start_time = time.monotonic() + count = self.counter.load() + assert k <= count + curr_idx = (count - 1) % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + target = out[key] + + end = curr_idx + 1 + start = max(0, end - k) + target_end = k + target_start = target_end - (end - start) + target[target_start: target_end] = arr[start:end] + + remainder = k - (end - start) + if remainder > 0: + # wrap around + end = self.buffer_size + start = end - remainder + target_start = 0 + target_end = end - start + target[target_start: target_end] = arr[start:end] + end_time = time.monotonic() + dt = end_time - start_time + if dt > self.get_time_budget: + raise TimeoutError(f'Get time out {dt} vs {self.get_time_budget}') + return out + + def get_all(self) -> Dict[str, np.ndarray]: + k = min(self.count, self.get_max_k) + return self.get_last_k(k=k) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_util.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_util.py new file mode 100644 index 0000000000000000000000000000000000000000..24ee7df3574a46c2ed5b4dff61a56ecaa92a1240 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_memory_util.py @@ -0,0 +1,39 @@ +from typing import Tuple +from dataclasses import dataclass +import numpy as np +from multiprocessing.managers import SharedMemoryManager +from atomics import atomicview, MemoryOrder, UINT + +@dataclass +class ArraySpec: + name: str + shape: Tuple[int] + dtype: np.dtype + + +class SharedAtomicCounter: + def __init__(self, + shm_manager: SharedMemoryManager, + size :int=8 # 64bit int + ): + shm = shm_manager.SharedMemory(size=size) + self.shm = shm + self.size = size + self.store(0) # initialize + + @property + def buf(self): + return self.shm.buf[:self.size] + + def load(self) -> int: + with atomicview(buffer=self.buf, atype=UINT) as a: + value = a.load(order=MemoryOrder.ACQUIRE) + return value + + def store(self, value: int): + with atomicview(buffer=self.buf, atype=UINT) as a: + a.store(value, order=MemoryOrder.RELEASE) + + def add(self, value: int): + with atomicview(buffer=self.buf, atype=UINT) as a: + a.add(value, order=MemoryOrder.ACQ_REL) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_ndarray.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_ndarray.py new file mode 100644 index 0000000000000000000000000000000000000000..cbe5c9f489f720ad285c7fff5c87dffbe56eb9d6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/shared_memory/shared_ndarray.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import multiprocessing +import multiprocessing.synchronize +from multiprocessing.managers import SharedMemoryManager +from multiprocessing.shared_memory import SharedMemory +from typing import Any, TYPE_CHECKING, Generic, Optional, Tuple, TypeVar, Union + +import numpy as np +import numpy.typing as npt +from diffusion_policy.common.nested_dict_util import (nested_dict_check, nested_dict_map) + + +SharedMemoryLike = Union[str, SharedMemory] # shared memory or name of shared memory +SharedT = TypeVar("SharedT", bound=np.generic) + + +class SharedNDArray(Generic[SharedT]): + """Class to keep track of and retrieve the data in a shared array + Attributes + ---------- + shm + SharedMemory object containing the data of the array + shape + Shape of the NumPy array + dtype + Type of the NumPy array. Anything that may be passed to the `dtype=` argument in `np.ndarray`. + lock + (Optional) multiprocessing.Lock to manage access to the SharedNDArray. This is only created if + lock=True is passed to the constructor, otherwise it is set to `None`. + A SharedNDArray object may be created either directly with a preallocated shared memory object plus the + dtype and shape of the numpy array it represents: + >>> from multiprocessing.shared_memory import SharedMemory + >>> import numpy as np + >>> from shared_ndarray2 import SharedNDArray + >>> x = np.array([1, 2, 3]) + >>> shm = SharedMemory(name="x", create=True, size=x.nbytes) + >>> arr = SharedNDArray(shm, x.shape, x.dtype) + >>> arr[:] = x[:] # copy x into the array + >>> print(arr[:]) + [1 2 3] + >>> shm.close() + >>> shm.unlink() + Or using a SharedMemoryManager either from an existing array or from arbitrary shape and nbytes: + >>> from multiprocessing.managers import SharedMemoryManager + >>> mem_mgr = SharedMemoryManager() + >>> mem_mgr.start() # Better yet, use SharedMemoryManager context manager + >>> arr = SharedNDArray.from_shape(mem_mgr, x.shape, x.dtype) + >>> arr[:] = x[:] # copy x into the array + >>> print(arr[:]) + [1 2 3] + >>> # -or in one step- + >>> arr = SharedNDArray.from_array(mem_mgr, x) + >>> print(arr[:]) + [1 2 3] + `SharedNDArray` does not subclass numpy.ndarray but rather generates an ndarray on-the-fly in get(), + which is used in __getitem__ and __setitem__. Thus to access the data and/or use any ndarray methods + get() or __getitem__ or __setitem__ must be used + >>> arr.max() # ERROR: SharedNDArray has no `max` method. + Traceback (most recent call last): + .... + AttributeError: SharedNDArray object has no attribute 'max'. To access NumPy ndarray object use .get() method. + >>> arr.get().max() # (or arr[:].max()) OK: This gets an ndarray on which we can operate + 3 + >>> y = np.zeros(3) + >>> y[:] = arr # ERROR: Cannot broadcast-assign a SharedNDArray to ndarray `y` + Traceback (most recent call last): + ... + ValueError: setting an array element with a sequence. + >>> y[:] = arr[:] # OK: This gets an ndarray that can be copied element-wise to `y` + >>> mem_mgr.shutdown() + """ + + shm: SharedMemory + # shape: Tuple[int, ...] # is a property + dtype: np.dtype + lock: Optional[multiprocessing.synchronize.Lock] + + def __init__( + self, shm: SharedMemoryLike, shape: Tuple[int, ...], dtype: npt.DTypeLike): + """Initialize a SharedNDArray object from existing shared memory, object shape, and dtype. + To initialize a SharedNDArray object from a memory manager and data or shape, use the `from_array() + or `from_shape()` classmethods. + Parameters + ---------- + shm + `multiprocessing.shared_memory.SharedMemory` object or name for connecting to an existing block + of shared memory (using SharedMemory constructor) + shape + Shape of the NumPy array to be represented in the shared memory + dtype + Data type for the NumPy array to be represented in shared memory. Any valid argument for + `np.dtype` may be used as it will be converted to an actual `dtype` object. + lock : bool, optional + If True, create a multiprocessing.Lock object accessible with the `.lock` attribute, by default + False. If passing the `SharedNDArray` as an argument to a `multiprocessing.Pool` function this + should not be used -- see this comment to a Stack Overflow question about `multiprocessing.Lock`: + https://stackoverflow.com/questions/25557686/python-sharing-a-lock-between-processes#comment72803059_25558333 + Raises + ------ + ValueError + The SharedMemory size (number of bytes) does not match the product of the shape and dtype + itemsize. + """ + if isinstance(shm, str): + shm = SharedMemory(name=shm, create=False) + dtype = np.dtype(dtype) # Try to convert to dtype + assert shm.size >= (dtype.itemsize * np.prod(shape)) + self.shm = shm + self.dtype = dtype + self._shape: Tuple[int, ...] = shape + + def __repr__(self): + # Like numpy's ndarray repr + cls_name = self.__class__.__name__ + nspaces = len(cls_name) + 1 + array_repr = str(self.get()) + array_repr = array_repr.replace("\n", "\n" + " " * nspaces) + return f"{cls_name}({array_repr}, dtype={self.dtype})" + + @classmethod + def create_from_array( + cls, mem_mgr: SharedMemoryManager, arr: npt.NDArray[SharedT] + ) -> SharedNDArray[SharedT]: + """Create a SharedNDArray from a SharedMemoryManager and an existing numpy array. + Parameters + ---------- + mem_mgr + Running `multiprocessing.managers.SharedMemoryManager` instance from which to create the + SharedMemory for the SharedNDArray + arr + NumPy `ndarray` object to copy into the created SharedNDArray upon initialization. + """ + # Simply use from_shape() to create the SharedNDArray and copy the data into it. + shared_arr = cls.create_from_shape(mem_mgr, arr.shape, arr.dtype) + shared_arr.get()[:] = arr[:] + return shared_arr + + @classmethod + def create_from_shape( + cls, mem_mgr: SharedMemoryManager, shape: Tuple, dtype: npt.DTypeLike) -> SharedNDArray: + """Create a SharedNDArray directly from a SharedMemoryManager + Parameters + ---------- + mem_mgr + SharedMemoryManager instance that has been started + shape + Shape of the array + dtype + Data type for the NumPy array to be represented in shared memory. Any valid argument for + `np.dtype` may be used as it will be converted to an actual `dtype` object. + """ + dtype = np.dtype(dtype) # Convert to dtype if possible + shm = mem_mgr.SharedMemory(np.prod(shape) * dtype.itemsize) + return cls(shm=shm, shape=shape, dtype=dtype) + + @property + def shape(self) -> Tuple[int, ...]: + return self._shape + + + def get(self) -> npt.NDArray[SharedT]: + """Get a numpy array with access to the shared memory""" + return np.ndarray(self.shape, dtype=self.dtype, buffer=self.shm.buf) + + def __del__(self): + self.shm.close() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/base_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/base_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..1352404a3b4975d9d983685d33298851b5426130 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/base_workspace.py @@ -0,0 +1,145 @@ +from typing import Optional +import os +import pathlib +import hydra +import copy +from hydra.core.hydra_config import HydraConfig +from omegaconf import OmegaConf +import dill +import torch +import threading + + +class BaseWorkspace: + include_keys = tuple() + exclude_keys = tuple() + + def __init__(self, cfg: OmegaConf, output_dir: Optional[str]=None): + self.cfg = cfg + self._output_dir = output_dir + self._saving_thread = None + + @property + def output_dir(self): + output_dir = self._output_dir + if output_dir is None: + output_dir = HydraConfig.get().runtime.output_dir + return output_dir + + def run(self): + """ + Create any resource shouldn't be serialized as local variables + """ + pass + + def save_checkpoint(self, path=None, tag='latest', + exclude_keys=None, + include_keys=None, + use_thread=True): + if path is None: + path = pathlib.Path(self.output_dir).joinpath('checkpoints', f'{tag}.ckpt') + else: + path = pathlib.Path(path) + if exclude_keys is None: + exclude_keys = tuple(self.exclude_keys) + if include_keys is None: + include_keys = tuple(self.include_keys) + ('_output_dir',) + + path.parent.mkdir(parents=False, exist_ok=True) + payload = { + 'cfg': self.cfg, + 'state_dicts': dict(), + 'pickles': dict() + } + + for key, value in self.__dict__.items(): + if hasattr(value, 'state_dict') and hasattr(value, 'load_state_dict'): + # modules, optimizers and samplers etc + if key not in exclude_keys: + if use_thread: + payload['state_dicts'][key] = _copy_to_cpu(value.state_dict()) + else: + payload['state_dicts'][key] = value.state_dict() + elif key in include_keys: + payload['pickles'][key] = dill.dumps(value) + if use_thread: + self._saving_thread = threading.Thread( + target=lambda : torch.save(payload, path.open('wb'), pickle_module=dill)) + self._saving_thread.start() + else: + torch.save(payload, path.open('wb'), pickle_module=dill) + return str(path.absolute()) + + def get_checkpoint_path(self, tag='latest'): + return pathlib.Path(self.output_dir).joinpath('checkpoints', f'{tag}.ckpt') + + def load_payload(self, payload, exclude_keys=None, include_keys=None, **kwargs): + if exclude_keys is None: + exclude_keys = tuple() + if include_keys is None: + include_keys = payload['pickles'].keys() + + for key, value in payload['state_dicts'].items(): + if key not in exclude_keys: + self.__dict__[key].load_state_dict(value, **kwargs) + for key in include_keys: + if key in payload['pickles']: + self.__dict__[key] = dill.loads(payload['pickles'][key]) + + def load_checkpoint(self, path=None, tag='latest', + exclude_keys=None, + include_keys=None, + **kwargs): + if path is None: + path = self.get_checkpoint_path(tag=tag) + else: + path = pathlib.Path(path) + payload = torch.load(path.open('rb'), pickle_module=dill, **kwargs) + self.load_payload(payload, + exclude_keys=exclude_keys, + include_keys=include_keys) + return payload + + @classmethod + def create_from_checkpoint(cls, path, + exclude_keys=None, + include_keys=None, + **kwargs): + payload = torch.load(open(path, 'rb'), pickle_module=dill) + instance = cls(payload['cfg']) + instance.load_payload( + payload=payload, + exclude_keys=exclude_keys, + include_keys=include_keys, + **kwargs) + return instance + + def save_snapshot(self, tag='latest'): + """ + Quick loading and saving for reserach, saves full state of the workspace. + + However, loading a snapshot assumes the code stays exactly the same. + Use save_checkpoint for long-term storage. + """ + path = pathlib.Path(self.output_dir).joinpath('snapshots', f'{tag}.pkl') + path.parent.mkdir(parents=False, exist_ok=True) + torch.save(self, path.open('wb'), pickle_module=dill) + return str(path.absolute()) + + @classmethod + def create_from_snapshot(cls, path): + return torch.load(open(path, 'rb'), pickle_module=dill) + + +def _copy_to_cpu(x): + if isinstance(x, torch.Tensor): + return x.detach().to('cpu') + elif isinstance(x, dict): + result = dict() + for k, v in x.items(): + result[k] = _copy_to_cpu(v) + return result + elif isinstance(x, list): + return [_copy_to_cpu(k) for k in x] + else: + return copy.deepcopy(x) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_ps_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_ps_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..bbc8962125244a96d4a449fb6c083d1d2a144b9f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_ps_workspace.py @@ -0,0 +1,67 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random +import wandb +import tqdm +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_postsamp_guide import DiffusionUnetLowdimPSPolicy +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class EvalLTLDiffuserPSWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimPSPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimPSPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + self.global_step = 0 + self.epoch = 0 + + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = EvalLTLDiffuserPSWorkspace(cfg) + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_rg_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_rg_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..c333f18c9ad0162f232ab7b559928e853629631f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_ltl_diffuser_rg_workspace.py @@ -0,0 +1,68 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random +import wandb +import tqdm +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_regression_guide import DiffusionUnetLowdimRGPolicy +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class EvalLTLDiffuserRGWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimRGPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimRGPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + self.global_step = 0 + self.epoch = 0 + + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = EvalLTLDiffuserRGWorkspace(cfg) + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_cc_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_cc_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..9898a86f2aac11762eae81836c7fb24a2e2545b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_cc_workspace.py @@ -0,0 +1,70 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random +import wandb +import tqdm +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_cost_policy import DiffusionUnetLowdimCostPolicy +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class EvalPCDiffCCWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimCostPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimCostPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + self.global_step = 0 + self.epoch = 0 + + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = EvalPCDiffCCWorkspace(cfg) + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pcd_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pcd_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..a43e8c58238e8dfd96b85d07ee2cdc90a7fffd4a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pcd_workspace.py @@ -0,0 +1,63 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_proj_coup_policy import DiffusionUnetLowdimProjCoupPolicy +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class EvalPCDiffWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimProjCoupPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimProjCoupPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + self.global_step = 0 + self.epoch = 0 + + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = EvalPCDiffWorkspace(cfg) + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pj_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pj_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..771b750437a960b613aed20b96b6d73e4bf0d0b2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/eval_pcdiff_pj_workspace.py @@ -0,0 +1,63 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_proj_policy import DiffusionUnetLowdimProjPolicy +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class EvalPCDiffPJWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimProjPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimProjPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + self.global_step = 0 + self.epoch = 0 + + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = EvalPCDiffPJWorkspace(cfg) + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_transformer_lowdim_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_transformer_lowdim_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..cbfa54b5dbef1f11be541f8108d510a0698b0c58 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_transformer_lowdim_workspace.py @@ -0,0 +1,303 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import random +import wandb +import tqdm +import numpy as np +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_transformer_lowdim_policy import DiffusionTransformerLowdimPolicy +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class TrainDiffusionTransformerLowdimWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf): + super().__init__(cfg) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionTransformerLowdimPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionTransformerLowdimPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + # configure training state + self.optimizer = self.model.get_optimizer(**cfg.optimizer) + + self.global_step = 0 + self.epoch = 0 + + def run(self): + cfg = copy.deepcopy(self.cfg) + + # resume training + if cfg.training.resume: + lastest_ckpt_path = self.get_checkpoint_path() + if lastest_ckpt_path.is_file(): + print(f"Resuming from checkpoint {lastest_ckpt_path}") + self.load_checkpoint(path=lastest_ckpt_path) + + # configure dataset + dataset: BaseLowdimDataset + dataset = hydra.utils.instantiate(cfg.task.dataset) + assert isinstance(dataset, BaseLowdimDataset) + train_dataloader = DataLoader(dataset, **cfg.dataloader) + normalizer = dataset.get_normalizer() + + # configure validation dataset + val_dataset = dataset.get_validation_dataset() + val_dataloader = DataLoader(val_dataset, **cfg.val_dataloader) + + self.model.set_normalizer(normalizer) + if cfg.training.use_ema: + self.ema_model.set_normalizer(normalizer) + + # configure lr scheduler + lr_scheduler = get_scheduler( + cfg.training.lr_scheduler, + optimizer=self.optimizer, + num_warmup_steps=cfg.training.lr_warmup_steps, + num_training_steps=( + len(train_dataloader) * cfg.training.num_epochs) \ + // cfg.training.gradient_accumulate_every, + # pytorch assumes stepping LRScheduler every epoch + # however huggingface diffusers steps it every batch + last_epoch=self.global_step-1 + ) + + # configure ema + ema: EMAModel = None + if cfg.training.use_ema: + ema = hydra.utils.instantiate( + cfg.ema, + model=self.ema_model) + + # configure env runner + env_runner: BaseLowdimRunner + env_runner = hydra.utils.instantiate( + cfg.task.env_runner, + output_dir=self.output_dir) + assert isinstance(env_runner, BaseLowdimRunner) + + # configure logging + wandb_run = wandb.init( + dir=str(self.output_dir), + config=OmegaConf.to_container(cfg, resolve=True), + **cfg.logging + ) + wandb.config.update( + { + "output_dir": self.output_dir, + } + ) + + # configure checkpoint + topk_manager = TopKCheckpointManager( + save_dir=os.path.join(self.output_dir, 'checkpoints'), + **cfg.checkpoint.topk + ) + + # device transfer + device = torch.device(cfg.training.device) + self.model.to(device) + if self.ema_model is not None: + self.ema_model.to(device) + optimizer_to(self.optimizer, device) + + # save batch for sampling + train_sampling_batch = None + + if cfg.training.debug: + cfg.training.num_epochs = 2 + cfg.training.max_train_steps = 3 + cfg.training.max_val_steps = 3 + cfg.training.rollout_every = 1 + cfg.training.checkpoint_every = 1 + cfg.training.val_every = 1 + cfg.training.sample_every = 1 + + # training loop + log_path = os.path.join(self.output_dir, 'logs.json.txt') + with JsonLogger(log_path) as json_logger: + for local_epoch_idx in range(cfg.training.num_epochs): + step_log = dict() + # ========= train for this epoch ========== + train_losses = list() + with tqdm.tqdm(train_dataloader, desc=f"Training epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + # device transfer + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + if train_sampling_batch is None: + train_sampling_batch = batch + + # compute loss + raw_loss = self.model.compute_loss(batch) + loss = raw_loss / cfg.training.gradient_accumulate_every + loss.backward() + + # step optimizer + if self.global_step % cfg.training.gradient_accumulate_every == 0: + self.optimizer.step() + self.optimizer.zero_grad() + lr_scheduler.step() + + # update ema + if cfg.training.use_ema: + ema.step(self.model) + + # logging + raw_loss_cpu = raw_loss.item() + tepoch.set_postfix(loss=raw_loss_cpu, refresh=False) + train_losses.append(raw_loss_cpu) + step_log = { + 'train_loss': raw_loss_cpu, + 'global_step': self.global_step, + 'epoch': self.epoch, + 'lr': lr_scheduler.get_last_lr()[0] + } + + is_last_batch = (batch_idx == (len(train_dataloader)-1)) + if not is_last_batch: + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + + if (cfg.training.max_train_steps is not None) \ + and batch_idx >= (cfg.training.max_train_steps-1): + break + + # at the end of each epoch + # replace train_loss with epoch average + train_loss = np.mean(train_losses) + step_log['train_loss'] = train_loss + + # ========= eval for this epoch ========== + policy = self.model + if cfg.training.use_ema: + policy = self.ema_model + policy.eval() + + # run rollout + if (self.epoch % cfg.training.rollout_every) == 0: + runner_log = env_runner.run(policy) + # log all + step_log.update(runner_log) + + # run validation + if (self.epoch % cfg.training.val_every) == 0: + with torch.no_grad(): + val_losses = list() + with tqdm.tqdm(val_dataloader, desc=f"Validation epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + loss = self.model.compute_loss(batch) + val_losses.append(loss) + if (cfg.training.max_val_steps is not None) \ + and batch_idx >= (cfg.training.max_val_steps-1): + break + if len(val_losses) > 0: + val_loss = torch.mean(torch.tensor(val_losses)).item() + # log epoch average validation loss + step_log['val_loss'] = val_loss + + # run diffusion sampling on a training batch + if (self.epoch % cfg.training.sample_every) == 0: + with torch.no_grad(): + # sample trajectory from training set, and evaluate difference + batch = dict_apply(train_sampling_batch, lambda x: x.to(device, non_blocking=True)) + obs_dict = {'obs': batch['obs']} + gt_action = batch['action'] + + result = policy.predict_action(obs_dict) + if cfg.pred_action_steps_only: + pred_action = result['action'] + start = cfg.n_obs_steps - 1 + end = start + cfg.n_action_steps + gt_action = gt_action[:,start:end] + else: + pred_action = result['action_pred'] + mse = torch.nn.functional.mse_loss(pred_action, gt_action) + step_log['train_action_mse_error'] = mse.item() + del batch + del obs_dict + del gt_action + del result + del pred_action + del mse + + # checkpoint + if (self.epoch % cfg.training.checkpoint_every) == 0: + # checkpointing + if cfg.checkpoint.save_last_ckpt: + self.save_checkpoint() + if cfg.checkpoint.save_last_snapshot: + self.save_snapshot() + + # sanitize metric names + metric_dict = dict() + for key, value in step_log.items(): + new_key = key.replace('/', '_') + metric_dict[new_key] = value + + # We can't copy the last checkpoint here + # since save_checkpoint uses threads. + # therefore at this point the file might have been empty! + topk_ckpt_path = topk_manager.get_ckpt_path(metric_dict) + + if topk_ckpt_path is not None: + self.save_checkpoint(path=topk_ckpt_path) + # ========= eval end for this epoch ========== + policy.train() + + # end of epoch + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + self.epoch += 1 + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = TrainDiffusionTransformerLowdimWorkspace(cfg) + workspace.run() + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_unet_lowdim_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_unet_lowdim_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..617b8aeb2004ac30ef50839530168b8c2c0fa2bf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_diffusion_unet_lowdim_workspace.py @@ -0,0 +1,306 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random +import wandb +import tqdm +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class TrainDiffusionUnetLowdimWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + self.model: DiffusionUnetLowdimPolicy + self.model = hydra.utils.instantiate(cfg.policy) + + self.ema_model: DiffusionUnetLowdimPolicy = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + # configure training state + self.optimizer = hydra.utils.instantiate( + cfg.optimizer, params=self.model.parameters()) + + self.global_step = 0 + self.epoch = 0 + + def run(self): + cfg = copy.deepcopy(self.cfg) + + # resume training + if cfg.training.resume: + lastest_ckpt_path = self.get_checkpoint_path() + if lastest_ckpt_path.is_file(): + print(f"Resuming from checkpoint {lastest_ckpt_path}") + self.load_checkpoint(path=lastest_ckpt_path) + + # configure dataset + dataset: BaseLowdimDataset + dataset = hydra.utils.instantiate(cfg.task.dataset) + assert isinstance(dataset, BaseLowdimDataset) + train_dataloader = DataLoader(dataset, **cfg.dataloader) + normalizer = dataset.get_normalizer() + + # configure validation dataset + val_dataset = dataset.get_validation_dataset() + val_dataloader = DataLoader(val_dataset, **cfg.val_dataloader) + + self.model.set_normalizer(normalizer) + if cfg.training.use_ema: + self.ema_model.set_normalizer(normalizer) + + # configure lr scheduler + lr_scheduler = get_scheduler( + cfg.training.lr_scheduler, + optimizer=self.optimizer, + num_warmup_steps=cfg.training.lr_warmup_steps, + num_training_steps=( + len(train_dataloader) * cfg.training.num_epochs) \ + // cfg.training.gradient_accumulate_every, + # pytorch assumes stepping LRScheduler every epoch + # however huggingface diffusers steps it every batch + last_epoch=self.global_step-1 + ) + + # configure ema + ema: EMAModel = None + if cfg.training.use_ema: + ema = hydra.utils.instantiate( + cfg.ema, + model=self.ema_model) + + # configure env runner + env_runner: BaseLowdimRunner + env_runner = hydra.utils.instantiate( + cfg.task.env_runner, + output_dir=self.output_dir) + assert isinstance(env_runner, BaseLowdimRunner) + + # configure logging + wandb_run = wandb.init( + dir=str(self.output_dir), + config=OmegaConf.to_container(cfg, resolve=True), + **cfg.logging + ) + wandb.config.update( + { + "output_dir": self.output_dir, + } + ) + + # configure checkpoint + topk_manager = TopKCheckpointManager( + save_dir=os.path.join(self.output_dir, 'checkpoints'), + **cfg.checkpoint.topk + ) + + # device transfer + device = torch.device(cfg.training.device) + self.model.to(device) + if self.ema_model is not None: + self.ema_model.to(device) + optimizer_to(self.optimizer, device) + + # save batch for sampling + train_sampling_batch = None + + if cfg.training.debug: + cfg.training.num_epochs = 2 + cfg.training.max_train_steps = 3 + cfg.training.max_val_steps = 3 + cfg.training.rollout_every = 1 + cfg.training.checkpoint_every = 1 + cfg.training.val_every = 1 + cfg.training.sample_every = 1 + + # training loop + log_path = os.path.join(self.output_dir, 'logs.json.txt') + with JsonLogger(log_path) as json_logger: + for local_epoch_idx in range(cfg.training.num_epochs): + step_log = dict() + # ========= train for this epoch ========== + train_losses = list() + with tqdm.tqdm(train_dataloader, desc=f"Training epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + # device transfer + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + if train_sampling_batch is None: + train_sampling_batch = batch + + # compute loss + raw_loss = self.model.compute_loss(batch) + loss = raw_loss / cfg.training.gradient_accumulate_every + loss.backward() + + # step optimizer + if self.global_step % cfg.training.gradient_accumulate_every == 0: + self.optimizer.step() + self.optimizer.zero_grad() + lr_scheduler.step() + + # update ema + if cfg.training.use_ema: + ema.step(self.model) + + # logging + raw_loss_cpu = raw_loss.item() + tepoch.set_postfix(loss=raw_loss_cpu, refresh=False) + train_losses.append(raw_loss_cpu) + step_log = { + 'train_loss': raw_loss_cpu, + 'global_step': self.global_step, + 'epoch': self.epoch, + 'lr': lr_scheduler.get_last_lr()[0] + } + + is_last_batch = (batch_idx == (len(train_dataloader)-1)) + if not is_last_batch: + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + + if (cfg.training.max_train_steps is not None) \ + and batch_idx >= (cfg.training.max_train_steps-1): + break + + # at the end of each epoch + # replace train_loss with epoch average + train_loss = np.mean(train_losses) + step_log['train_loss'] = train_loss + + # ========= eval for this epoch ========== + policy = self.model + if cfg.training.use_ema: + policy = self.ema_model + policy.eval() + + # run rollout + if (self.epoch % cfg.training.rollout_every) == 0: + runner_log = env_runner.run(policy) + # log all + step_log.update(runner_log) + + # run validation + if (self.epoch % cfg.training.val_every) == 0: + with torch.no_grad(): + val_losses = list() + with tqdm.tqdm(val_dataloader, desc=f"Validation epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + loss = self.model.compute_loss(batch) + val_losses.append(loss) + if (cfg.training.max_val_steps is not None) \ + and batch_idx >= (cfg.training.max_val_steps-1): + break + if len(val_losses) > 0: + val_loss = torch.mean(torch.tensor(val_losses)).item() + # log epoch average validation loss + step_log['val_loss'] = val_loss + + # run diffusion sampling on a training batch + if (self.epoch % cfg.training.sample_every) == 0: + with torch.no_grad(): + # sample trajectory from training set, and evaluate difference + batch = train_sampling_batch + obs_dict = {'obs': batch['obs']} + gt_action = batch['action'] + + result = policy.predict_action(obs_dict) + if cfg.pred_action_steps_only: + pred_action = result['action'] + start = cfg.n_obs_steps - 1 + end = start + cfg.n_action_steps + gt_action = gt_action[:,start:end] + else: + pred_action = result['action_pred'] + mse = torch.nn.functional.mse_loss(pred_action, gt_action) + # log + step_log['train_action_mse_error'] = mse.item() + # release RAM + del batch + del obs_dict + del gt_action + del result + del pred_action + del mse + + # checkpoint + if (self.epoch % cfg.training.checkpoint_every) == 0: + # checkpointing + if cfg.checkpoint.save_last_ckpt: + self.save_checkpoint() + if cfg.checkpoint.save_last_snapshot: + self.save_snapshot() + + # sanitize metric names + metric_dict = dict() + for key, value in step_log.items(): + new_key = key.replace('/', '_') + metric_dict[new_key] = value + + # We can't copy the last checkpoint here + # since save_checkpoint uses threads. + # therefore at this point the file might have been empty! + topk_ckpt_path = topk_manager.get_ckpt_path(metric_dict) + + if topk_ckpt_path is not None: + self.save_checkpoint(path=topk_ckpt_path) + # ========= eval end for this epoch ========== + policy.train() + + # end of epoch + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + self.epoch += 1 + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = TrainDiffusionUnetLowdimWorkspace(cfg) + workspace.run() + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_ltl_value_workspace.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_ltl_value_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..02a2840e62f80cba377b6b6f0d0a6501bd912300 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/diffusion_policy/workspace/train_ltl_value_workspace.py @@ -0,0 +1,297 @@ +if __name__ == "__main__": + import sys + import os + import pathlib + + ROOT_DIR = str(pathlib.Path(__file__).parent.parent.parent) + sys.path.append(ROOT_DIR) + os.chdir(ROOT_DIR) + +import os +import hydra +import torch +from omegaconf import OmegaConf +import pathlib +from torch.utils.data import DataLoader +import copy +import numpy as np +import random +import wandb +import tqdm +import shutil + +from diffusion_policy.common.pytorch_util import dict_apply, optimizer_to +from diffusion_policy.workspace.base_workspace import BaseWorkspace +# from diffusion_policy.policy.diffusion_unet_lowdim_policy import DiffusionUnetLowdimPolicy +from diffusion_policy.policy.diffusion_unet_ltl_value import DiffusionUnetLTLValue +from diffusion_policy.dataset.base_dataset import BaseLowdimDataset +from diffusion_policy.dataset.pusht_dataset import PushTLTLValueDataset +from diffusion_policy.env_runner.base_lowdim_runner import BaseLowdimRunner +from diffusion_policy.common.checkpoint_util import TopKCheckpointManager +from diffusion_policy.common.json_logger import JsonLogger +from diffusion_policy.model.common.lr_scheduler import get_scheduler +from diffusers.training_utils import EMAModel + +OmegaConf.register_new_resolver("eval", eval, replace=True) + +# %% +class TrainLTLValueWorkspace(BaseWorkspace): + include_keys = ['global_step', 'epoch'] + + def __init__(self, cfg: OmegaConf, output_dir=None): + super().__init__(cfg, output_dir=output_dir) + + # set seed + seed = cfg.training.seed + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # configure model + # self.model: DiffusionUnetLowdimPolicy + self.model: DiffusionUnetLTLValue + self.model = hydra.utils.instantiate(cfg.policy) + + # self.ema_model: DiffusionUnetLowdimPolicy = None + self.ema_model: DiffusionUnetLTLValue = None + if cfg.training.use_ema: + self.ema_model = copy.deepcopy(self.model) + + # configure training state + self.optimizer = hydra.utils.instantiate( + cfg.optimizer, params=self.model.parameters()) + + self.global_step = 0 + self.epoch = 0 + + def run(self): + cfg = copy.deepcopy(self.cfg) + + # resume training + if cfg.training.resume: + lastest_ckpt_path = self.get_checkpoint_path() + if lastest_ckpt_path.is_file(): + print(f"Resuming from checkpoint {lastest_ckpt_path}") + self.load_checkpoint(path=lastest_ckpt_path) + + # configure dataset + dataset: PushTLTLValueDataset + dataset = hydra.utils.instantiate(cfg.task.dataset) + assert isinstance(dataset, PushTLTLValueDataset) + train_dataloader = DataLoader(dataset, **cfg.dataloader) + normalizer = dataset.get_normalizer() + + # configure validation dataset + val_dataset = dataset.get_validation_dataset() + val_dataloader = DataLoader(val_dataset, **cfg.val_dataloader) + + self.model.set_normalizer(normalizer) + if cfg.training.use_ema: + self.ema_model.set_normalizer(normalizer) + + # configure lr scheduler + lr_scheduler = get_scheduler( + cfg.training.lr_scheduler, + optimizer=self.optimizer, + num_warmup_steps=cfg.training.lr_warmup_steps, + num_training_steps=( + len(train_dataloader) * cfg.training.num_epochs) \ + // cfg.training.gradient_accumulate_every, + # pytorch assumes stepping LRScheduler every epoch + # however huggingface diffusers steps it every batch + last_epoch=self.global_step-1 + ) + + # configure ema + ema: EMAModel = None + if cfg.training.use_ema: + ema = hydra.utils.instantiate( + cfg.ema, + model=self.ema_model) + + # ## configure env runner + # env_runner: BaseLowdimRunner + # env_runner = hydra.utils.instantiate( + # cfg.task.env_runner, + # output_dir=self.output_dir) + # assert isinstance(env_runner, BaseLowdimRunner) + + # configure logging + wandb_run = wandb.init( + dir=str(self.output_dir), + config=OmegaConf.to_container(cfg, resolve=True), + **cfg.logging + ) + wandb.config.update( + { + "output_dir": self.output_dir, + } + ) + + # configure checkpoint + topk_manager = TopKCheckpointManager( + save_dir=os.path.join(self.output_dir, 'checkpoints'), + **cfg.checkpoint.topk + ) + + # device transfer + device = torch.device(cfg.training.device) + self.model.to(device) + if self.ema_model is not None: + self.ema_model.to(device) + optimizer_to(self.optimizer, device) + + # save batch for sampling + train_sampling_batch = None + + if cfg.training.debug: + cfg.training.num_epochs = 2 + cfg.training.max_train_steps = 3 + cfg.training.max_val_steps = 3 + cfg.training.rollout_every = 1 + cfg.training.checkpoint_every = 1 + cfg.training.val_every = 1 + cfg.training.sample_every = 1 + + # training loop + log_path = os.path.join(self.output_dir, 'logs.json.txt') + with JsonLogger(log_path) as json_logger: + for local_epoch_idx in range(cfg.training.num_epochs): + step_log = dict() + # ========= train for this epoch ========== + train_losses = list() + with tqdm.tqdm(train_dataloader, desc=f"Training epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + # device transfer + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + ## For LTLValueDataset + if hasattr(dataset, 'ltl_graphs'): + ltl_indices = batch['ltl_idx'].cpu().numpy() + # batch.update({'formula': dataset.ltl_graphs[ltl_indices]}) + batch_formulas = dataset.ltl_graphs[ltl_indices] + if train_sampling_batch is None: + train_sampling_batch = batch + + # compute loss + raw_loss = self.model.compute_loss(batch, formula=batch_formulas) + loss = raw_loss / cfg.training.gradient_accumulate_every + loss.backward() + + # step optimizer + if self.global_step % cfg.training.gradient_accumulate_every == 0: + self.optimizer.step() + self.optimizer.zero_grad() + lr_scheduler.step() + + # update ema + if cfg.training.use_ema: + ema.step(self.model) + + # logging + raw_loss_cpu = raw_loss.item() + tepoch.set_postfix(loss=raw_loss_cpu, refresh=False) + train_losses.append(raw_loss_cpu) + step_log = { + 'train_loss': raw_loss_cpu, + 'global_step': self.global_step, + 'epoch': self.epoch, + 'lr': lr_scheduler.get_last_lr()[0] + } + + is_last_batch = (batch_idx == (len(train_dataloader)-1)) + if not is_last_batch: + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + + if (cfg.training.max_train_steps is not None) \ + and batch_idx >= (cfg.training.max_train_steps-1): + break + + # at the end of each epoch + # replace train_loss with epoch average + train_loss = np.mean(train_losses) + step_log['train_loss'] = train_loss + + # ========= eval for this epoch ========== + policy = self.model + if cfg.training.use_ema: + policy = self.ema_model + policy.eval() + + # # run rollout + # if (self.epoch % cfg.training.rollout_every) == 0: + # runner_log = env_runner.run(policy) + # # log all + # step_log.update(runner_log) + + # run validation + if (self.epoch % cfg.training.val_every) == 0: + with torch.no_grad(): + val_losses = list() + with tqdm.tqdm(val_dataloader, desc=f"Validation epoch {self.epoch}", + leave=False, mininterval=cfg.training.tqdm_interval_sec) as tepoch: + for batch_idx, batch in enumerate(tepoch): + batch = dict_apply(batch, lambda x: x.to(device, non_blocking=True)) + ## For LTLValueDataset + if hasattr(val_dataset, 'ltl_graphs'): + ltl_indices = batch['ltl_idx'].cpu().numpy() + # batch.update({'formula': val_dataset.ltl_graphs[ltl_indices]}) + batch_formulas = val_dataset.ltl_graphs[ltl_indices] + loss = self.model.compute_loss(batch, formula=batch_formulas) + val_losses.append(loss) + if (cfg.training.max_val_steps is not None) \ + and batch_idx >= (cfg.training.max_val_steps-1): + break + if len(val_losses) > 0: + val_loss = torch.mean(torch.tensor(val_losses)).item() + # log epoch average validation loss + step_log['val_loss'] = val_loss + step_log['weighted_loss'] = val_loss + else: + print("No validation loss, taking train loss as weighted loss.") + step_log['weighted_loss'] = train_loss + + # checkpoint + if (self.epoch % cfg.training.checkpoint_every) == 0: + # checkpointing + if cfg.checkpoint.save_last_ckpt: + self.save_checkpoint() + if cfg.checkpoint.save_last_snapshot: + self.save_snapshot() + + # sanitize metric names + metric_dict = dict() + for key, value in step_log.items(): + new_key = key.replace('/', '_') + metric_dict[new_key] = value + + # We can't copy the last checkpoint here + # since save_checkpoint uses threads. + # therefore at this point the file might have been empty! + topk_ckpt_path = topk_manager.get_ckpt_path(metric_dict) + + if topk_ckpt_path is not None: + self.save_checkpoint(path=topk_ckpt_path) + # ========= eval end for this epoch ========== + policy.train() + + # end of epoch + # log of last step is combined with validation and rollout + wandb_run.log(step_log, step=self.global_step) + json_logger.log(step_log) + self.global_step += 1 + self.epoch += 1 + +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath("config")), + config_name=pathlib.Path(__file__).stem) +def main(cfg): + workspace = TrainLTLValueWorkspace(cfg) + workspace.run() + +if __name__ == "__main__": + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/pyrightconfig.json b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/pyrightconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..58bdcd7f948f2ba413fb95101726030d6d058346 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "exclude": [ + "data/**", + "data_local/**", + "outputs/**" + ], + "typeCheckingMode": "off", // "off", "strict", "basic" + "reportMissingImports": "warning", + "reportGeneralTypeIssues": "warning", + "reportOptionalMemberAccess": "none" +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analysis_helpers.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analysis_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..c898461358a92a70821f4847a702cc32ce5ac68d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analysis_helpers.py @@ -0,0 +1,95 @@ +import re +import warnings +from typing import Dict, Any + +# Helper function for safe extraction +def find_param(pattern, path, key, type_converter, default=None): + match = re.search(pattern, path) + if match: + try: + return type_converter(match.group(key)) + except (ValueError, IndexError): + return default + return default + +def extract_coupling_cost_func(path: str) -> str: + """ + Extracts the coupling method from a log-path string. + + Rules: + • Base method: the part after the last hyphen in the 'max_vel-…-X' segment. + • Coupling suffix: if the 'coupling' segment is written as 'coupling_ps', 'ps' is the suffix. + if it's just 'coupling', there's no suffix. + • Final method is '' if no coupling suffix, otherwise '-'. + + Examples: + >>> s1 = "…/max_vel-max_v6.2-dpp/…/init_st50-max_vel_admm-coupling_ps-…" + >>> extract_coupling_method(s1) + 'dpp-ps' + + >>> s2 = "…/max_vel-max_v10.789-exp_decay_l2_sqr/…/init_st10-max_vel_cvxpy-coupling-…" + >>> extract_coupling_method(s2) + 'exp_decay_l2_sqr' + """ + # 1) capture the method after the last hyphen in the max_vel segment + m1 = re.search(r'max_vel-[^/]+-([^/]+)', path) + if not m1: + warnings.warn(f"No 'max_vel-…-X' pattern found in: {path!r}. Running backup search.") + # raise ValueError(f"No 'max_vel-…-X' pattern found in: {path!r}") + m1 = re.search(r"H\d+O\d+A\d+D\d+/([A-Za-z0-9_]+?)/max_stp*", path) + if not m1: + warnings.warn(f"No alternative pattern found in: {path!r}. Returning 'vanilla'.") + return 'vanilla' + base_method = m1.group(1) + if not base_method: + base_method = 'vanilla' + + # 2) capture optional coupling suffix after 'coupling' + m2 = re.search(r'coupling(?:_([^-/]+))?(?:-|$)', path) + if not m2: + warnings.warn(f"No 'coupling' segment found in: {path!r}") + # raise ValueError(f"No 'coupling' segment found in: {path!r}") + suffix = m2.group(1) # None if no '_…' part + + # 3) combine + return base_method if suffix is None else f"{base_method}-{suffix}" + + +def extract_params_from_path(path: str) -> Dict[str, Any]: + """ + Extracts key parameters from a directory path string. + + Args: + path (str): The input directory path. + + Returns: + Dict[str, Any]: A dictionary of extracted parameters. + """ + params: Dict[str, Any] = {} + + # Diffusion configuration details + m = re.search(r'pusht.*output/([^/]+?)/', path) + if m: + diff_cfg = m.group(1) + params['diffusion_config'] = diff_cfg + params['pred_horizon'] = find_param(r'H(\d+)', path, 1, int) + params['action_horizon'] = find_param(r'A(\d+)', path, 1, int, default=params.get('pred_horizon')) + params['num_obs_steps'] = find_param(r'O(\d+)', path, 1, int, default=1) + params['diffusion_timesteps'] = find_param(r'D(\d+)', path, 1, int, default=100) + + # Other parameters + params['episode_length'] = find_param(r'max_stp(\d+)', path, 1, int) + params['num_init_states'] = find_param(r'init_st(\d+)', path, 1, int) + params['max_vel'] = find_param(r'max_v(\d+(?:\.\d+)?)', path, 1, float) + params['projection_method'] = find_param(r'max_vel_([A-Za-z0-9_]+?)(?=-|/|$)', path, 1, str) + params['num_trials'] = find_param(r'-t(\d+)(?:$|-)', path, 1, int) + params['cost_function'] = extract_coupling_cost_func(path) + + # Stp and Scl parameters + match = re.search(r'-stp(\d+)-scl([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)', path) + if match: + params['stp'] = int(match.group(1)) + params['scl'] = float(match.group(2)) + + return params + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analyzing_results.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analyzing_results.py new file mode 100644 index 0000000000000000000000000000000000000000..1d7ff23de823fb02753939774cc2f6b70de79a6b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/analyzing_results.py @@ -0,0 +1,405 @@ +import numpy as np +import os +import pandas as pd +import yaml +import argparse +import matplotlib.pyplot as plt +import logging +from tqdm import tqdm +from typing import Dict, Any + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +# Local imports from our new helper files +from analysis_helpers import extract_params_from_path +from plotting_utils import ( + plot_trajectory_tuples, plot_collision_masks, + plot_velocity_stats, plot_velocity_histogram +) +# Assuming diffusion_policy is in the python path +from diffusion_policy.common.eval_util import ( + collision_detect, calculate_per_segment_safety, + dtw_distance, frechet_distance +) + +def load_data(data_abs_dir: str) -> Dict[str, np.ndarray]: + """Loads raw data from the .npz file.""" + raw_data_path = os.path.join(data_abs_dir, "raw_data.npz") + if not os.path.exists(raw_data_path): + raise FileNotFoundError(f"Raw data not found at: {raw_data_path}") + logger.info(f"Loading data from: {raw_data_path}") + return np.load(raw_data_path) + +def analyze_and_plot_trajectories(all_actions: np.ndarray, config: Dict, save_dir: str, title_args: Dict): + """Plots trajectory tuples based on config settings.""" + if not config['switches']['plot_trajectories']: + return + + logger.info("Plotting trajectory tuples...") + figs_axs = plot_trajectory_tuples( + all_actions[..., :10], + tup_size=2, + h_start=0, h_end=config['plotting']['traj_plot_h_end'], + title="Action Trajectory Tuples", + title_args=title_args, + cmap_name='plasma', + xlim=tuple(config['plotting']['traj_plot_xlim']), + ylim=tuple(config['plotting']['traj_plot_ylim']), + figsize=(10, 10) + ) + + if config['switches']['save_plots']: + logger.info(f"Saving {len(figs_axs)} trajectory plots to '{save_dir}'...") + for i, (fig, _) in tqdm(enumerate(figs_axs), total=len(figs_axs)): + fig.savefig(os.path.join(save_dir, f"init_st{i:03d}_action_trj_tups.png"), bbox_inches='tight', dpi=300) + plt.close(fig) + +def analyze_collisions(roll_actions: np.ndarray, config: Dict, save_dir: str) -> Dict[str, Any]: + """Performs collision detection and calculates safety statistics.""" + logger.info("Analyzing collisions...") + constants = config['constants'] + collision_masks = collision_detect( + roll_actions, safe_dist=constants['SAFE_DIST'], norm_order=2 + ).transpose(0, 2, 1, 3) # (I, P, N, H) + + safe_masks = np.logical_not(collision_masks) + per_segment_mask = calculate_per_segment_safety(safe_masks, constants['ACTION_HORIZON']) + + final_segment_safe_rate = np.mean(per_segment_mask.astype(float), axis=(-1, -2)) + overall_safe_rate = final_segment_safe_rate.mean() + collision_counts = np.sum(collision_masks.astype(int), axis=-1) + + logger.info(f"Overall safe rate: {overall_safe_rate:.2%}") + + if config['switches']['plot_trajectories']: + fig, _ = plot_collision_masks(collision_masks[:10, ...], constants['SAFE_DIST'], safe_rates=final_segment_safe_rate) + if config['switches']['save_plots']: + fig.savefig(os.path.join(save_dir, "collision_masks.png"), bbox_inches='tight', dpi=300) + plt.close(fig) + + return { + 'Overall Mean Collision Count': collision_counts.mean(), + 'Overall Mean Safe Rate': overall_safe_rate, + } + +def compute_distance_metrics(roll_actions: np.ndarray, config: Dict, save_dir: str) -> Dict[str, Any]: + """Computes DTW and Frechet distances, plots histograms, and returns stats.""" + logger.info("Computing DTW and Frechet distances...") + constants = config['constants'] + action_horizon = constants['ACTION_HORIZON'] + I, N, P, H, _ = roll_actions.shape + n_seg = (H + action_horizon - 1) // action_horizon # Number of segments + split_indices = range(action_horizon, roll_actions.shape[-2], action_horizon) + action_segments = np.array(np.split(roll_actions[:, :, ...], split_indices, axis=-2)) # [(I, N, P, a_h, 2)]*n_seg + action_segments = np.stack(action_segments, axis=-3).transpose(2, 0, 1, 3, 4, 5) # [I, N, P, n_seg, a_h, 2] -> (P, I, N, n_seg, a_h, 2) + assert action_segments.shape == (P, I, N, n_seg, action_horizon, 2), \ + f"Action segments shape mismatch: expect [P, I, N, n_seg, a_h, 2] ({(P, I, N, n_seg, action_horizon, 2)}) but got {action_segments.shape}" + + a_max = np.array(constants['MAX_ACTION']) + a_min = np.array(constants['MIN_ACTION']) + norm_segments = (action_segments - a_min) / (a_max - a_min + constants['EPS']) * 2 - 1.0 + + dtw_dists = dtw_distance(norm_segments[0], norm_segments[1], norm_type='raw') # [I, N, n_seg] + frechet_dists = frechet_distance(norm_segments[0], norm_segments[1]) # [I, N, n_seg] + assert dtw_dists.shape == (I, N, n_seg, ), \ + f"DTW distances shape mismatch: expect [I, N, n_seg] ({(I, N, n_seg)}) but got {dtw_dists.shape}" + assert frechet_dists.shape == (I, N, n_seg, ), \ + f"Frechet distances shape mismatch: expect [I, N, n_seg] ({(I, N, n_seg)}) but got {frechet_dists.shape}" + + dtw_full:np.ndarray = dtw_dists.mean(axis=-1) # [I, N] + frechet_full:np.ndarray = frechet_dists.mean(axis=-1) # [I, N] + assert dtw_full.shape == (I, N), \ + f"DTW full distances shape mismatch: expect [I, N] ({(I, N)}) but got {dtw_full.shape}" + assert frechet_full.shape == (I, N), \ + f"Frechet full distances shape mismatch: expect [I, N] ({(I, N)}) but got {frechet_full.shape}" + + overall_mean_dtwd = np.mean(dtw_full) + overall_mean_fd = np.mean(frechet_full) + overall_dtw_std = dtw_full.std() + within_init_dtw_std = dtw_full.std(axis=1).mean() + between_init_dtw_std = dtw_full.mean(axis=1).std() + overall_fd_std = frechet_full.std() + within_init_fd_std = frechet_full.std(axis=1).mean() + between_init_fd_std = frechet_full.mean(axis=1).std() + logger.info(f"Overall Mean DTW: {overall_mean_dtwd:.4f}\u00b1{overall_dtw_std:.4f}, Overall Mean Frechet: {overall_mean_fd:.4f}\u00b1{overall_fd_std:.4f}") + + if config['switches']['plot_trajectories']: + fig, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True) + fig.suptitle("Distance Metrics Between Paired Trajectories") + bins = np.linspace(0, np.sqrt(2) * 2, 142) + + axes[0].hist(dtw_dists.flatten(), bins=bins, color='skyblue', alpha=0.8, density=True) + axes[0].axvline(overall_mean_dtwd, color='blue', linestyle='--', label=f'Mean DTW={overall_mean_dtwd:.3f}') + axes[0].set(title="Histogram of DTW Distances", xlabel="DTW", ylabel="Density") + axes[0].legend() + axes[0].grid(True, linestyle='--') + + axes[1].hist(frechet_dists.flatten(), bins=bins, color='salmon', alpha=0.8, density=True) + axes[1].axvline(overall_mean_fd, color='red', linestyle='--', label=f'Mean DFD={overall_mean_fd:.3f}') + axes[1].set(title="Histogram of Discrete Fréchet Distances", xlabel="DFD", ylabel="Density") + axes[1].legend() + axes[1].grid(True, linestyle='--') + + if config['switches']['save_plots']: + fig.savefig(os.path.join(save_dir, "dtw_frechet_histogram.png"), bbox_inches='tight', dpi=400) + plt.close(fig) + + return { + 'Overall Mean DTW Distance': overall_mean_dtwd, + 'Overall Mean Frechet Distance': overall_mean_fd, + 'Overall Std DTW Distance': overall_dtw_std, + 'Overall Std Frechet Distance': overall_fd_std, + 'Within Init Std DTW Distance': within_init_dtw_std, + 'Within Init Std Frechet Distance': within_init_fd_std, + 'Between Init Std DTW Distance': between_init_dtw_std, + 'Between Init Std Frechet Distance': between_init_fd_std, + } + +def analyze_rewards(raw_data: Dict[str, np.ndarray]) -> Dict[str, Any]: + """Calculates and returns reward statistics.""" + logger.info("Analyzing rewards...") + all_rewards = raw_data['all_rewards'].transpose(0, 3, 1, 2) # (P, I, N, H) + P, I, N, H = all_rewards.shape + max_rewards = np.max(all_rewards, axis=-1) + assert max_rewards.shape == (P, I, N), \ + f"Max rewards shape mismatch: expect [P, I, N] ({(P, I, N)}) but got {max_rewards.shape}" + mean_rewards_per_player = np.mean(max_rewards, axis=(-1, -2)) + overall_mean_rewards = np.mean(mean_rewards_per_player) + std_rewards_per_player = np.std(max_rewards, axis=(-1, -2)) + overall_std_rewards = np.std(max_rewards) + within_init_std_rewards_per_player = max_rewards.std(axis=2).mean(1) + assert within_init_std_rewards_per_player.shape == (P,), \ + f"Within init std rewards shape mismatch: expect [P] ({(P,)}) but got {within_init_std_rewards_per_player.shape}" + ############################################################################## + between_init_std_rewards_per_player = max_rewards.mean(axis=2).std(1) + assert between_init_std_rewards_per_player.shape == (P,), \ + f"Between init std rewards shape mismatch: expect [P] ({(P,)}) but got {between_init_std_rewards_per_player.shape}" + + max_rewards_all = max_rewards.mean(0) # (I, N) + assert max_rewards_all.shape == (I, N), \ + f"Max rewards all shape mismatch: expect [I, N] ({(I, N)}) but got {max_rewards_all.shape}" + overall_within_init_std_rewards = max_rewards_all.std(axis=1).mean() + overall_between_init_std_rewards = max_rewards_all.mean(axis=1).std() + + logger.info(f"Mean rewards per player: {np.round(mean_rewards_per_player, 4)}\u00b1{np.round(std_rewards_per_player, 4)}") + logger.info(f"Overall mean reward: {overall_mean_rewards:.4f}\u00b1{overall_std_rewards:.4f}") + + return { + 'Overall Mean Rewards': overall_mean_rewards, + 'Overall Std Rewards': overall_std_rewards, + 'Within Init Std Rewards': overall_within_init_std_rewards, + 'Between Init Std Rewards': overall_between_init_std_rewards, + 'Mean Paired Rewards': tuple(mean_rewards_per_player.tolist()), + 'Std Paired Rewards': tuple(std_rewards_per_player.tolist()), + 'Within Init Std Paired Rewards': tuple(within_init_std_rewards_per_player.tolist()), + 'Between Init Std Paired Rewards': tuple(between_init_std_rewards_per_player.tolist()), + 'Episode Length': all_rewards.shape[-1], + } + +def analyze_velocity(raw_data: Dict[str, np.ndarray], config: Dict, expt_params: Dict, save_dir: str): + """Calculates velocity, profiles it, and plots statistics.""" + logger.info("Analyzing velocity...") + constants = config['constants'] + all_actions = raw_data['all_roll_action_trjs'] + all_state_trjs = raw_data['all_state_trjs'] + + P, N, H, _, I = all_actions.shape + v_eps = constants['VEL_TOL'] + dt = constants['DT'] + a_max = np.array(constants['MAX_ACTION']) + a_min = np.array(constants['MIN_ACTION']) + action_horizon = constants['ACTION_HORIZON'] + n_seg = (H + action_horizon - 1) // action_horizon # Number of segments + + # Observations are the positions from the state trajectories + obs_trjs = all_state_trjs[..., :-1, :2, :].transpose(4, 1, 0, 2, 3) # (I,N,P,H,2) + obs_trjs = obs_trjs.reshape(I*N*P, H, 2) + obs_segments = np.stack(np.array_split(obs_trjs, H // action_horizon, axis=1), axis=1) # (I*N*P, n_seg, a_h, 2) + norm_obs_segments = 2 * (obs_segments - a_min) / (a_max - a_min + constants['EPS']) - 1 + assert norm_obs_segments.shape == (I*N*P, n_seg, action_horizon, 2), \ + f"Observation segments shape mismatch: expect [I*N*P, n_seg, a_h, 2] ({(I*N*P, n_seg, action_horizon, 2)}) but got {norm_obs_segments.shape}" + + # Actions + norm_actions = all_actions.transpose(4,1,0,2,3).reshape(I*N*P, H, 2) + norm_actions = 2 * (norm_actions - a_min) / (a_max - a_min + constants['EPS']) - 1 + norm_action_segments = np.stack(np.array_split(norm_actions, H // action_horizon, axis=1), axis=1) # (I*N*P, n_seg, a_h, 2) + assert norm_action_segments.shape == (I*N*P, n_seg, action_horizon, 2), \ + f"Action segments shape mismatch: expect [I*N*P, n_seg, a_h, 2] ({(I*N*P, n_seg, action_horizon, 2)}) but got {norm_action_segments.shape}" + + # Displacements + norm_displacement_segments = np.concatenate([norm_obs_segments[:, :, :1, :], norm_action_segments], axis=-2) + dx = np.diff(norm_displacement_segments, axis=-2) # (I*N*P, n_seg, a_h, 2) + # Calculate velocity + v = np.linalg.norm(dx, axis=-1) / dt # (I*N*P, n_seg, a_h) + assert v.shape == (I*N*P, n_seg, action_horizon), \ + f"Velocity shape mismatch: expect [I*N*P, n_seg, a_h] ({(I*N*P, n_seg, action_horizon)}) but got {v.shape}" + + ## Check if velocity exceeds max threshold segment-wise + max_vel = expt_params.get('max_vel') + if max_vel is None: + max_vel = constants['DEFAULT_MAX_VEL'] + logger.warning(f"Max velocity not found in experiment setting, using default max velocity: {max_vel}.") + if abs(max_vel - constants['DEFAULT_MAX_VEL']) > 1e-6: + logger.warning(f"Max velocity ({max_vel}) differs from default ({constants['DEFAULT_MAX_VEL']}).") + + ## Velocity constraint satisfaction + vel_constr_sat = v <= max_vel + v_eps + vel_constr_sat_seg:np.ndarray = np.all(vel_constr_sat, axis=-1) # (I*N*P, n_seg) + vel_constr_sat_seg = vel_constr_sat_seg.reshape(I*N, P, -1) # (I*N, P, n_seg) + mean_vel_constr_sat_per_player = np.mean(vel_constr_sat_seg.astype(float), axis=-1) # (I*N, P) + overall_mean_vel_constr_sat = np.mean(mean_vel_constr_sat_per_player) + overall_std_vel_constr_sat_per_player = np.std(mean_vel_constr_sat_per_player, axis=0) # (P,) + + vel_constr_sat_full_per_player = vel_constr_sat_seg.reshape(I, N, P, -1).mean(-1) # (I, N, P, n_seg) -> (I, N, P) + assert vel_constr_sat_full_per_player.shape == (I, N, P), \ + f"Velocity constraint satisfaction full shape mismatch: expect [I, N, P] ({(I, N, P)}) but got {vel_constr_sat_full_per_player.shape}" + within_init_std_vel_constr_sat_per_player = vel_constr_sat_full_per_player.std(axis=1).mean(0) # (P,) + between_init_std_vel_constr_sat_per_player = vel_constr_sat_full_per_player.mean(axis=1).std(0) # (P,) + assert within_init_std_vel_constr_sat_per_player.shape == (P,), \ + f"Mean velocity constraint satisfaction shape mismatch: expect [P] ({(P)}) but got {within_init_std_vel_constr_sat_per_player.shape}" + assert between_init_std_vel_constr_sat_per_player.shape == (P,), \ + f"Between init std velocity constraint satisfaction shape mismatch: expect [P] ({(P,)}) but got {between_init_std_vel_constr_sat_per_player.shape}" + + vel_constr_sat_full = vel_constr_sat_full_per_player.mean(axis=-1) # (I, N, P) -> (I, N) + assert vel_constr_sat_full.shape == (I, N), \ + f"Velocity constraint satisfaction full shape mismatch: expect [I, N] ({(I, N)}) but got {vel_constr_sat_full.shape}" + overall_std_vel_constr_sat_full = vel_constr_sat_full.std() + within_init_std_vel_constr_sat_full = vel_constr_sat_full.std(axis=1).mean() + between_init_std_vel_constr_sat_full = vel_constr_sat_full.mean(axis=1).std() + + + + # Plotting + v = v.reshape(-1, v.shape[-1] * v.shape[-2]) # (I*N*P, H // action_horizon * 2) + if config['switches']['plot_velocity_stats']: + hard_deck = max_vel + proj_method = expt_params.get('projection_method') + if proj_method is None: + proj_method = "N/A" + title = f"(Normalized) Velocity Stats (Proj: {proj_method})" + subtitle = f"cfg: {expt_params.get('diffusion_config', 'N/A')}; ep_len={expt_params.get('episode_length', 'N/A')}" + + fig1, _ = plot_velocity_stats(np.arange(v.shape[-1]), v.mean(0), v.std(0), v.max(0), v.min(0), v.mean(), hard_deck, title, subtitle) + fig2, _ = plot_velocity_histogram(v, bins=config['plotting']['vel_hist_bins'], quantiles=constants['VEL_HIST_QUANTILES'], title=f"Velocity Histogram (Proj: {proj_method})", subtitle=subtitle) + + if config['switches']['save_plots']: + logger.info("Saving velocity plots...") + fig1.savefig(os.path.join(save_dir, f"vel_stats_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + fig2.savefig(os.path.join(save_dir, f"vel_hist_{proj_method.replace('/', '')}_max_vel{max_vel}.png"), bbox_inches='tight', dpi=300) + plt.close(fig1) + plt.close(fig2) + + return { + 'Overall Mean Velocity Constraint Satisfaction': overall_mean_vel_constr_sat, + 'Overall Std Velocity Constraint Satisfaction': overall_std_vel_constr_sat_full, + 'Mean Velocity Constraint Satisfaction per Player': tuple(mean_vel_constr_sat_per_player.mean(axis=0).tolist()), + 'Std Velocity Constraint Satisfaction per Player': tuple(overall_std_vel_constr_sat_per_player.tolist()), + 'Within Init Std Velocity Constraint Satisfaction per Player': tuple(within_init_std_vel_constr_sat_per_player.tolist()), + 'Between Init Std Velocity Constraint Satisfaction per Player': tuple(between_init_std_vel_constr_sat_per_player.tolist()), + 'Within Init Std Velocity Constraint Satisfaction': within_init_std_vel_constr_sat_full, + 'Between Init Std Velocity Constraint Satisfaction': between_init_std_vel_constr_sat_full, + 'Max Velocity': max_vel, + } + +def aggregate_and_save_stats(stats: Dict, data_dir: str, config: Dict): + """Aggregates all computed stats into a DataFrame and saves to JSON.""" + df = pd.DataFrame([stats]) + df.index = [os.path.basename(data_dir)] # Use folder name as index + + if config['switches']['save_stats_json']: + save_path = os.path.join(data_dir, f"summary_stats_vmax{stats['Max Velocity']}.json") + logger.info(f"Saving summary stats to: {save_path}") + df.to_json(save_path, orient='records', indent=4) + + return df + +def run_analysis_for_directory(data_dir: str, config: Dict) -> pd.DataFrame: + """Main pipeline for processing a single data directory.""" + basedir = os.path.dirname(os.path.dirname(os.getcwd())) + data_abs_dir = os.path.join(basedir, data_dir) + save_dir = os.path.join(data_abs_dir, "figs") + os.makedirs(save_dir, exist_ok=True) + + # Extract parameters from path + expt_params = extract_params_from_path(data_dir) + title_args = {'stp': expt_params.get('stp'), 'scl': expt_params.get('scl')} + + # Load Data + raw_data = load_data(data_abs_dir) + all_actions = raw_data['all_roll_action_trjs'] # (P, N, H, 2, I) + roll_actions = all_actions.transpose(4, 1, 0, 2, 3) # (I, N, P, H, 2) + + # --- Run Analysis Modules --- + summary_stats = {"Directory": data_dir, **expt_params} + analyze_and_plot_trajectories(all_actions, config, save_dir, title_args) + summary_stats.update(compute_distance_metrics(roll_actions, config, save_dir)) + summary_stats.update(analyze_rewards(raw_data)) + summary_stats.update(analyze_velocity(raw_data, config, expt_params, save_dir)) + + # --- Aggregate and Save --- + final_df = aggregate_and_save_stats(summary_stats, data_abs_dir, config) + return final_df + + +def main(): + """Main execution function.""" + parser = argparse.ArgumentParser(description="Run trajectory analysis from a config file.") + parser.add_argument( + '--config', + type=str, + default='config.yaml', + help='Path to the YAML configuration file.' + ) + args = parser.parse_args() + + # Determine base_dir as the grandparent of this script's directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + base_dir = os.path.dirname(script_dir) + + cfg_pth = os.path.join(base_dir, args.config) + assert os.path.exists(cfg_pth), f"Configuration file not found: {cfg_pth}" + + # Load configuration + with open(cfg_pth, 'r') as f: + config = yaml.safe_load(f) + + # Process each data directory specified in the config + ## Read log paths and data directories from config + data_dirs = [] + for log_pth in tqdm(config['log_paths'], desc="Processing Directories", unit="dir"): + with open(os.path.join(base_dir, log_pth), 'r') as f: + logged_dirs = [line.strip() for line in f if line.strip()] + data_dirs.extend(logged_dirs) + + all_stats_df = [] + for data_dir in tqdm(data_dirs, desc="Processing Directories", unit="dir"): + print("\n" + "="*80) + logger.info(f"Processing Directory: {data_dir}") + print("="*80) + data_dir = os.path.join(base_dir, data_dir) # Ensure absolute path + try: + stats_df = run_analysis_for_directory(data_dir, config) + all_stats_df.append(stats_df) + except Exception as e: + logger.error(f"\nERROR processing directory '{data_dir}': \n{e}") + logger.info("Skipping to next directory.\n") + continue + + # Display final aggregated stats + if all_stats_df: + final_summary_df = pd.concat(all_stats_df) + print("\n" + "="*80) + print(" FINAL AGGREGATED STATISTICS ") + print("="*80) + pd.set_option('display.max_columns', None) + pd.set_option('display.width', 1000) + print(final_summary_df) + else: + print("\nNo data was processed successfully.") + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_fix_init.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_fix_init.py new file mode 100644 index 0000000000000000000000000000000000000000..97fda2ad508d91a70ba591f876f685ac82b7f1d9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_fix_init.py @@ -0,0 +1,148 @@ +""" +Baseline evaluation sub-script for PushT with fixed initial states. +Invoked programmatically by eval_H16_seq.py via run_eval(cfg). +""" + +import sys +import time +# use line-buffering for both stdout and stderr +sys.stdout = open(sys.stdout.fileno(), mode='w', buffering=1) +sys.stderr = open(sys.stderr.fileno(), mode='w', buffering=1) + +import os +import pathlib +import click +import hydra +import torch +import dill +import wandb +import json +import logging +import numpy as np +from copy import deepcopy +from diffusion_policy.workspace.eval_pcdiff_cc_workspace import EvalPCDiffCCWorkspace +from diffusion_policy.common.env_util import rand_init_state, regroup_trajectories +from omegaconf import OmegaConf +# allows arbitrary python code execution in configs using the ${eval:''} resolver +OmegaConf.register_new_resolver("eval", eval, replace=True) + + +logger = logging.getLogger(__name__) + + + +def get_policy(cfg): + ## Resolve config to get main workspace + OmegaConf.resolve(cfg) + cls = hydra.utils.get_class(cfg._target_) + workspace: EvalPCDiffCCWorkspace = cls(cfg) + ## get policy from workspace + policy = workspace.model + + ## load diffusion model checkpoint + logger.info(f"Loading diffusion model from checkpoint: \'{cfg.diffusion_checkpoint}\'") + diffusion_payload = torch.load(open(cfg.diffusion_checkpoint, 'rb'), pickle_module=dill) + diffusion_cfg = diffusion_payload['cfg'] + train_diffusion_workspace_cls = hydra.utils.get_class(diffusion_cfg._target_) + diffusion_workspace = train_diffusion_workspace_cls(diffusion_cfg, output_dir=cfg.output_dir) + diffusion_workspace: EvalPCDiffCCWorkspace + diffusion_workspace.load_payload(diffusion_payload, exclude_keys=None, include_keys=None) + ## get diffusion model from workspace + diffusion_policy = diffusion_workspace.model + if diffusion_cfg.training.use_ema: + diffusion_policy = diffusion_workspace.ema_model + #end if + diffusion_model = deepcopy(diffusion_policy.model) + + ## Replacing model with the pretrained diffusion + policy.model = diffusion_model + + ## Set new policy's normalizer as the one in diffusion policy. + policy.set_normalizer(diffusion_workspace.model.normalizer) + + device = torch.device(cfg.device) + policy.to(device) + policy.eval() + + return policy + + +def run_eval(cfg): + output_dir = cfg.output_dir + log_pth = cfg.log_pth + + if os.path.exists(output_dir): + click.confirm(f"Output path {output_dir} already exists! Overwrite?", abort=True) + pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True) + + #--------------------------- policy ----------------------------# + policy = get_policy(cfg) + + # run eval + env_runner = hydra.utils.instantiate( + cfg.task.env_runner, + output_dir=output_dir) + start = time.time() + runner_log:dict = env_runner.run( + policy=policy, + init_states = rand_init_state(cfg), + ) + end = time.time() + logger.info(f"Evaluation completed in {end - start:.6f} seconds.") + + #--------------------------- Regroup Trajectories ----------------------------# + ## Post-process the trajectories (Regrouping) + regrouped_trj_dict = dict() + for key in ['all_pred_action_trjs', 'all_roll_action_trjs', 'all_state_trjs', 'all_rewards', 'all_obs_trjs']: + if key in runner_log: + regrouped_trj_dict[key] = regroup_trajectories( + trajectories=runner_log[key], + tup_size=2, + n_chunks=runner_log["n_chunks"].item(), + ) + else: + print(f"Expected key {key} in runner_log, but not found.") + # end for + ## Update the runner_log with regrouped trajectories + runner_log.update(regrouped_trj_dict) + + + # dump log to json + json_log = dict() + raw_data_to_save = {} + for key, value in runner_log.items(): + if isinstance(value, wandb.sdk.data_types.video.Video): + json_log[key] = value._path + elif isinstance(value, np.ndarray): + raw_data_to_save[key] = value + json_log[key] = os.path.join(output_dir, f"raw_data.npz/{key}") + else: + try: + json_log[key] = value + except RuntimeError as err: + print(f"[WARNING] Logging error:", err) + out_path = os.path.join(output_dir, 'eval_baseline_log.json') + json.dump(json_log, open(out_path, 'w'), indent=2, sort_keys=True) + + ## save data to npz + np.savez_compressed(os.path.join(output_dir, 'raw_data.npz'), **raw_data_to_save) + + ## Write the output directory to a .log file + outer_log_file = log_pth + with open(outer_log_file, 'a' if os.path.exists(outer_log_file) else 'w') as f: + f.write(f'{output_dir}\n') + + +#------------------------------------ main -----------------------------------# +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath( + 'diffusion_policy','config')) +) +def main(cfg): + return run_eval(cfg) + + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_guided_parallel.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_guided_parallel.py new file mode 100644 index 0000000000000000000000000000000000000000..4513b1659f1ecfaf7b283d69241f9510a44456fd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_pusht_guided_parallel.py @@ -0,0 +1,177 @@ +""" +Guided evaluation sub-script for PushT (coupling / coupling+projection). +Invoked programmatically by eval_H16_seq.py via run_eval(cfg). +""" + +import sys +# use line-buffering for both stdout and stderr +sys.stdout = open(sys.stdout.fileno(), mode='w', buffering=1) +sys.stderr = open(sys.stderr.fileno(), mode='w', buffering=1) + +import os +import pathlib +import click +import hydra +import torch +import dill +import wandb +import json +import logging +import time +import numpy as np +from copy import deepcopy +from typing import Union +from diffusion_policy.policy.diffusion_unet_lowdim_cost_policy import DiffusionUnetLowdimCostPolicy +from diffusion_policy.policy.diffusion_unet_lowdim_proj_coup_policy import DiffusionUnetLowdimProjCoupPolicy +from diffusion_policy.common.env_util import rand_init_state, regroup_trajectories +from diffusion_policy.coupling_cost.cost_functions import cost_registry +from omegaconf import OmegaConf +from datetime import datetime +# allows arbitrary python code execution in configs using the ${eval:''} resolver +OmegaConf.register_new_resolver("eval", eval, replace=True) + + +logger = logging.getLogger(__name__) + +def get_policy(cfg): + ## Resolve config to get main workspace + OmegaConf.resolve(cfg) + + policy:Union[DiffusionUnetLowdimCostPolicy, DiffusionUnetLowdimProjCoupPolicy] = hydra.utils.instantiate(cfg.policy) + + ## load diffusion model checkpoint + logger.info(f"Loading diffusion model from checkpoint: \'{cfg.diffusion_checkpoint}\'") + diffusion_payload = torch.load(open(cfg.diffusion_checkpoint, 'rb'), pickle_module=dill) + diffusion_cfg = diffusion_payload['cfg'] + train_diffusion_workspace_cls = hydra.utils.get_class(diffusion_cfg._target_) + diffusion_workspace = train_diffusion_workspace_cls(diffusion_cfg, output_dir=cfg.output_dir) + diffusion_workspace.load_payload(diffusion_payload, exclude_keys=None, include_keys=None) + ## get diffusion model from workspace + diffusion_policy = diffusion_workspace.model + if diffusion_cfg.training.use_ema: + diffusion_policy = diffusion_workspace.ema_model + #end if + diffusion_model = deepcopy(diffusion_policy.model) + + ## Replacing diffusion model with the pretrained model + policy.model = diffusion_model + + if cfg.guider == 'coupling' or 'coupling_ps': + policy.guide.set_cost_function(cost_registry[cfg.cost_func_key],) + else: + raise ValueError(f"Unknown guide type: \'{cfg.guider}\'") + + ## Set new policy's normalizer as the one in diffusion policy. + policy.set_normalizer(diffusion_workspace.model.normalizer) + + device = torch.device(cfg.device) + policy.to(device) + policy.eval() + + return policy + + +def run_eval(cfg): + base_output_dir = cfg.output_dir + log_pth = cfg.log_pth + time_str = datetime.now().strftime('%y%m%d-%H%M%S') + output_dir = os.path.join(base_output_dir, time_str) + if os.path.exists(output_dir): + click.confirm(f"Output path {output_dir} already exists! Overwrite?", abort=True) + + #--------------------------- policy ----------------------------# + policy = get_policy(cfg) + + if cfg.guider not in ['coupling', 'coupling_ps']: + raise ValueError(f"Unknown guide type: \'{cfg.guider}\'") + if cfg.guider == 'coupling_ps': + assert policy.pt_est , \ + "Coupling PS guide requires the policy to have `point_estimate`=True " + elif cfg.guider == 'coupling': + assert not policy.pt_est, \ + "Coupling guide requires the policy to have `point_estimate`=False " + + if hasattr(cfg, 'projector'): + if cfg.projector not in ['max_vel_cvxpy', 'max_vel_admm']: + raise ValueError(f"Unknown projector type: \'{cfg.projector}\'") + else: + logger.info("Projector setting no found; proceeding w/o projection.") + + + #--------------------------- Logging Directory ----------------------------# + pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True) + + ## Dump the entire config to the output directory as a YAML file + config_out_path = os.path.join(output_dir, 'config.yaml') + with open(config_out_path, 'w') as f: + OmegaConf.save(cfg, f) + + #--------------------------- Run Env Evaluation ----------------------------# + env_runner = hydra.utils.instantiate( + cfg.task.env_runner, + output_dir=output_dir, + ) + start = time.time() + if cfg.guider in ['coupling', 'coupling_ps']: + runner_log:dict = env_runner.run( + policy=policy, + init_states = rand_init_state(cfg), + ) + else: + raise ValueError(f"Unknown guide type: \'{cfg.guider}\'") + end = time.time() + logger.info(f"Evaluation completed in {end - start:.6f} seconds.") + + #--------------------------- Regroup Trajectories ----------------------------# + ## Post-process the trajectories (Regrouping) + regrouped_trj_dict = dict() + for key in ['all_pred_action_trjs', 'all_roll_action_trjs', 'all_state_trjs', 'all_rewards', 'all_guide_grad_trjs']: + if key in runner_log: + regrouped_trj_dict[key] = regroup_trajectories( + trajectories=runner_log[key], + tup_size=2, + n_chunks=runner_log["n_chunks"].item(), + ) + else: + logger.warning(f"Expected key '{key}' in `runner_log`, but not found.") + # end for + ## Update the runner_log with regrouped trajectories + runner_log.update(regrouped_trj_dict) + + # dump log to json + json_log = dict() + raw_data_to_save = {} + + ## Log Raw data + for key, value in runner_log.items(): + if isinstance(value, wandb.sdk.data_types.video.Video): # type: ignore + json_log[key] = value._path + elif isinstance(value, np.ndarray): + raw_data_to_save[key] = value + json_log[key] = os.path.join(output_dir, f"raw_data.npz/{key}") + else: + json_log[key] = value + + ## save data to npz + np.savez_compressed(os.path.join(output_dir, 'raw_data.npz'), **raw_data_to_save) + + json_out_path = os.path.join(output_dir, f'eval_DP{str(cfg.guider).upper()}_log.json') + json.dump(json_log, open(json_out_path, 'w'), indent=2, sort_keys=True) + + ## Write the output directory to a .log file + outer_log_file = log_pth + with open(outer_log_file, 'a' if os.path.exists(outer_log_file) else 'w') as f: + f.write(f'{output_dir}\n') + +#------------------------------------ main -----------------------------------# +@hydra.main( + version_base=None, + config_path=str(pathlib.Path(__file__).parent.parent.joinpath( + 'diffusion_policy','config')) +) +def main(cfg): + return run_eval(cfg) + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_seq.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_seq.py new file mode 100644 index 0000000000000000000000000000000000000000..13684ffddecd730bf94572719e64bade6bbaae08 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/eval_H16_seq.py @@ -0,0 +1,320 @@ +import os +from os.path import join, dirname, abspath +import sys +import logging +import click +import hydra +import datetime +import numpy as np +from tqdm import tqdm +from itertools import product +import importlib.util + +from diffusion_policy.coupling_cost.cost_functions import cost_registry + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +BASE_DIR = str(os.path.dirname(os.path.abspath(__file__))) +PROJECT_ROOT = str(dirname(dirname(abspath(__file__)))) +LOG_DIR = str(join(dirname(dirname(abspath(__file__))), "logs/tests/event_logs")) +GUIDERS = ['vanilla', 'coupling', 'coupling_ps'] +PROJECTORS = ['none', 'max_vel_cvxpy', 'max_vel_admm'] +COST_FUNCS = list(cost_registry.keys()) + +PROJECTOR_PARAMS = { + 'max_vel_cvxpy': { + '_target_': 'diffusion_policy.model.projection.cvxpy_projector_multiprocess.MaxVelocityCvxPyProjector', + 'horizon': "${eval:'${horizon} - ${n_obs_steps} + ${policy.oa_step_convention}'}", + 'ndim': "${action_dim}" , + 'proj_profile_key': "${proj_profile_key}", + 'parallel': True, + 'n_jobs': 50, + 'solver': 'ECOS', + 'abstol': 1.0e-6, + 'reltol': 1.0e-5, + 'feastol': 1.0e-5, + 'verbose': False, + 'warm_start': True + }, + 'max_vel_admm': { + '_target_': 'diffusion_policy.model.projection.projection_operator.ADMMProjectionOperatorCUDAGraph', + 'rho': 6.0, + 'max_iter': 250, + 'tol': 2e-4, + 'decomp': 'lu', + 'convergence_check': True, + 'verbose': False, + } +} + +# Sweep parameters +n_init_states = 50 +trial = 200 +max_steps = 360 +n_envs = None +n_diffusion_steps = 32 +proj_profile_key= "max_vel" +default_cost_fn_key = "dpp" +steps = [1,] +scales = [1e-3, 5e-3, 7e-3, .01, .02, .03, .04, .05, .07, 0.1, 0.2] +vmaxs = [6.2, 8.4, 10.7] +skips = [] + +def import_module_from_path(module_name: str, file_path: str): + """ Imports a module dynamically from a full file path. """ + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None: + raise ImportError(f"Could not load spec for module '{module_name}' at {file_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + logger.info(f"Successfully imported and cached module '{module_name}' from {file_path}") + return module + +@click.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)) +@click.option("--work-dir", type=str, default=BASE_DIR) +@click.option("--gpu-id", type=int, default=0) +@click.option("--guider", '-g', type=str, required=True) +@click.option("--projector", '-p', type=str, required=True) +@click.option("--cost-func-key", '-c', type=str, default=default_cost_fn_key) +@click.option("--product-params", '-r', is_flag=True) +@click.option("--comment", '-m', type=str, default="") +@click.pass_context +def main( + ctx, + work_dir: str, + gpu_id: int, + guider: str, + projector: str, + cost_func_key: str, + product_params: bool, + comment: str = "", + ): + overrides = list(ctx.args) + + assert os.path.exists(work_dir), f"work_dir {work_dir} does not exist" + if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + logger.info(f"Created log directory at {LOG_DIR}") + log_file_nm = f"eval_runs-{datetime.datetime.now().strftime('%y%m%d_%H%M%S')}" + if comment: + log_file_nm += f"-{comment}" + log_file_nm += ".log" + log_pth = join(LOG_DIR, log_file_nm) + logger.info(f"Logging all experiment directories to '{log_pth}'.") + if overrides: + logger.info(f"Applying ad-hoc overrides to all runs: '{overrides}'") + + ## check guider, projector, cost function + assert guider in GUIDERS + assert projector in PROJECTORS + assert cost_func_key in COST_FUNCS + + # Set the environment variable BEFORE any other modules (especially + ## those that import torch/jax/tensorflow) are loaded. + os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id) + + # This must be the RELATIVE path from the project root to the config dir + config_rel_path = os.path.relpath( + join(PROJECT_ROOT, 'diffusion_policy', 'config'), + start=BASE_DIR + ) + logger.info(f"Initializing Hydra with config path: '{config_rel_path}' relative to root: '{PROJECT_ROOT}'") + + # Initialize Hydra. + hydra.initialize(config_path=config_rel_path, job_name="seq_eval", version_base=None) + + # This caches their code in memory for the entire run. + logger.info("Importing sub-evaluation scripts to create a snapshot...") + guide_script_path = os.path.join(work_dir, "eval_H16_pusht_guided_parallel.py") + baseline_script_path = os.path.join(work_dir, "eval_H16_pusht_fix_init.py") + + assert os.path.exists(guide_script_path), f"Sub-script not found: {guide_script_path}" + assert os.path.exists(baseline_script_path), f"Sub-script not found: {baseline_script_path}" + + eval_guide_module = import_module_from_path("eval_guide", guide_script_path) + eval_baseline_module = import_module_from_path("eval_baseline", baseline_script_path) + logger.info("Sub-scripts imported. Execution will use these in-memory versions.") + + ## run + ### Baseline + if guider == 'vanilla' and projector.lower() == 'none': + ### Baseline: no coupling, no projection + config_name = "eval_H16_pusht_fix_init_state.yaml" + print("".join(['*']*70)) + logger.info(f"GPU {gpu_id}: Running '{guider}' for {trial} trials...") + + args = [ + f'n_init_states={n_init_states}', + f'trial={trial}', + f'task.env_runner.max_steps={max_steps}', + f'task.env_runner.n_envs={n_envs if n_envs is not None else "null"}', + f"log_pth='{log_pth}'", + f'n_diffusion_steps={n_diffusion_steps}', + ] + + args.extend(overrides) + + try: + # Use hydra.compose to load the config and apply overrides + cfg = hydra.compose(config_name=config_name, overrides=args) + # Call the raw logic function directly + eval_baseline_module.run_eval(cfg) + except Exception as e: + logger.error(f"Execution of baseline script failed: {e}", exc_info=True) + logger.info(f"Finished running '{guider}'-'{projector}'.") + + elif guider == 'vanilla' and projector.lower() != 'none': + ### Projection only, no coupling + config_name = "eval_H16_pusht_proj_admm.yaml" if projector.lower() == 'max_vel_admm' else "eval_H16_pusht_projection.yaml" + + for v_max in tqdm(vmaxs, desc="Projected Runs"): + print("".join(['*']*70)) + logger.info(f"GPU {gpu_id}: Running '{projector.split('_')[-1]}' with v_max={v_max} for {trial} trials...") + + args = [ + f'projector={projector}', + f'proj_profile_key={proj_profile_key}', + f'v_max={v_max}', + f'n_init_states={n_init_states}', + f'trial={trial}', + f'task.env_runner.max_steps={max_steps}', + f'task.env_runner.n_envs={n_envs if n_envs is not None else "null"}', + f"log_pth='{log_pth}'", + f'n_diffusion_steps={n_diffusion_steps}', + ] + + # Append projector params + for k, v in PROJECTOR_PARAMS[projector].items(): + args.append(f'policy.projector.{k}={v}') + + args.extend(overrides) + + try: + cfg = hydra.compose(config_name=config_name, overrides=args) + # Call the raw logic function directly + eval_baseline_module.run_eval(cfg) + except Exception as e: + logger.error(f"Execution of baseline script failed: {e}", exc_info=True) + + logger.info(f"Finished running '{guider}'-'{projector}' with v_max={v_max}.") + #end for v_max + + elif guider in ['coupling', 'coupling_ps'] and projector.lower() == 'none': + ### Coupling only, no projection + config_name = "eval_H16_pusht_cost_coupling.yaml" + + ## check param list + if product_params: + params = product(steps, scales) + params_len = len(steps) * len(scales) + else: + assert len(steps) == len(scales) + params = zip(steps, scales) + params_len = len(steps) + + for stp, scl in tqdm(params, total=params_len, desc="Coupling Runs"): + # skip logic remains the same + param_np = np.array([stp, scl]) + is_skipped = any(np.linalg.norm(param_np - np.array(skip)) < 1e-6 for skip in skips) + if is_skipped: + logger.info(f"Skipping step={stp}, scale={scl}") + continue + + print("".join(['*']*70)) + logger.info(f"GPU {gpu_id}: Running '{cost_func_key}'-'{guider}' with stp={stp}, scl={scl}, for {trial} trials...") + + # Build argument list for this iteration + args = [ + f'guider={guider}', + f'cost_func_key={cost_func_key}', + f'stp={stp}', + f'scl={scl}', + f'n_init_states={n_init_states}', + f'trial={trial}', + f'task.env_runner.max_steps={max_steps}', + f'task.env_runner.n_envs={n_envs if n_envs is not None else "null"}', + f"log_pth='{log_pth}'", + f'n_diffusion_steps={n_diffusion_steps}', + ] + + args.extend(overrides) + + # Call the imported function with the arguments for this iteration + logger.info(f"Calling eval_guide_module.main with args:\n{args}") + try: + cfg = hydra.compose(config_name=config_name, overrides=args) + # Call the raw logic function directly + eval_guide_module.run_eval(cfg) + except Exception as e: + logger.error(f"Execution of guided script failed for stp={stp}, scl={scl}: {e}", exc_info=True) + + logger.info(f"Finished running '{cost_func_key}'-'{guider}'-'{projector}' with stp={stp}, scl={scl}.") + #end for stp, scl + + ### Guided + elif guider in ['coupling', 'coupling_ps'] and projector.lower() != 'none': + config_name = "eval_H16_pusht_proj_coup.yaml" + + ## check param list + if product_params: + params = product(steps, scales, vmaxs) + params_len = len(steps) * len(scales) * len(vmaxs) + else: + assert len(steps) == len(scales) == len(vmaxs) + params = zip(steps, scales, vmaxs) + params_len = len(steps) + + for stp, scl, v_max in tqdm(params, total=params_len, desc="PCDiff Runs"): + # skip logic remains the same + param_np = np.array([stp, scl]) + is_skipped = any(np.linalg.norm(param_np - np.array(skip)) < 1e-6 for skip in skips) + if is_skipped: + logger.info(f"Skipping step={stp}, scale={scl}") + continue + + print("".join(['*']*70)) + logger.info(f"GPU {gpu_id}: Running '{projector.split('_')[-1]}'-'{cost_func_key}'-'{guider}' with v_max={v_max}, stp={stp}, scl={scl}, for {trial} trials...") + + # Build argument list for this iteration + args = [ + f'guider={guider}', + f'projector={projector}', + f'proj_profile_key={proj_profile_key}', + f'v_max={v_max}', + f'cost_func_key={cost_func_key}', + f'stp={stp}', + f'scl={scl}', + f'n_init_states={n_init_states}', + f'trial={trial}', + f'task.env_runner.max_steps={max_steps}', + f'task.env_runner.n_envs={n_envs if n_envs is not None else "null"}', + f"log_pth='{log_pth}'", + f'n_diffusion_steps={n_diffusion_steps}', + ] + + # Append projector params + for k, v in PROJECTOR_PARAMS[projector].items(): + args.append(f'policy.projector.{k}={v}') + + args.extend(overrides) + + # Call the imported function with the arguments for this iteration + logger.info(f"Calling eval_guide_module.main with args:\n{args}") + try: + cfg = hydra.compose(config_name=config_name, overrides=args) + # Call the raw logic function directly + eval_guide_module.run_eval(cfg) + except Exception as e: + logger.error(f"Execution of guided script failed for stp={stp}, scl={scl}: {e}", exc_info=True) + + logger.info(f"Finished running '{guider}'-'{projector}' with v_max={v_max}, stp={stp}, scl={scl}.") + else: + raise ValueError(f"Unknown guider type '{guider}'; guider must be one of {GUIDERS}") + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/plotting_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/plotting_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..40f74e6aae2bb94f19acc15632b1f9bcace7fe55 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/scripts/plotting_utils.py @@ -0,0 +1,439 @@ +import numpy as np +import math +import matplotlib +import matplotlib.pyplot as plt +import matplotlib.cm as cm +from matplotlib.collections import LineCollection +from typing import List, Tuple +from tqdm import tqdm + +def plot_trajectories( + trajectories: np.ndarray, + title: str = "Trajectory Grids", + cmap_name: str = 'viridis', + xlim: tuple = None, + ylim: tuple = None, + figsize: tuple = None, + wspace: float = 0.05, + hspace: float = 0.05 + ): + """ + Plots a batch of (X,Y) trajectories in a grid, where each grid cell + corresponds to the last dimension of the input trajectories. + Colors vary by time (horizon step) along each individual trajectory. + A single colorbar is shown for the entire figure. + + Args: + trajectories (np.ndarray): A NumPy array of shape (N, H, 2, I), where: + - N is the batch size (number of trajectories per grid cell) + - H is the horizon (number of points per trajectory) + - 2 represents (X, Y) coordinates for each point. + - I is the number of grid cells to plot. + title (str): The super-title for the entire figure. + cmap_name (str): The name of the matplotlib colormap. + xlim (tuple, optional): Fixed (min, max) x-limits for each subplot. If None, auto-scaled per subplot. + ylim (tuple, optional): Fixed (min, max) y-limits for each subplot. If None, auto-scaled per subplot. + figsize (tuple, optional): Overall figure size (width, height). If None, automatically determined. + wspace (float): Width space between subplots (fraction of subplot width). + hspace (float): Height space between subplots (fraction of subplot height). + """ + if trajectories.ndim != 4 or trajectories.shape[2] != 2: + print("Error: Input 'trajectories' must be a NumPy array of shape (N, H, 2, I).") + return None, None + + N_traj_per_grid, H, _, I_grids = trajectories.shape + + if I_grids == 0: + print("Error: No grids to plot (I_grids=0).") + return None, None + if H <= 1 and N_traj_per_grid > 0: + print("Warning: Horizon H <= 1, trajectories will not be visible as lines.") + + # Determine grid layout (square-ish) + ncols = int(math.ceil(math.sqrt(I_grids))) + nrows = int(math.ceil(I_grids / ncols)) + + # Determine figure size + _figsize = figsize + if _figsize is None: + subplot_width_default = 4 + subplot_height_default = 4 + max_total_width = 20 + max_total_height = 20 + + fig_width = min(ncols * subplot_width_default, max_total_width) + fig_height = min(nrows * subplot_height_default, max_total_height) + _figsize = (fig_width, fig_height) + + fig, axes = plt.subplots(nrows, ncols, figsize=_figsize, squeeze=False) + + # Tighter spacing right after creating subplots + fig.subplots_adjust( + left=0.05, right=0.95, + bottom=0.05, top=0.90, + wspace=wspace, hspace=hspace + ) + + cmap = cm.get_cmap(cmap_name) + if H > 1: + norm_values = np.linspace(0, 1, H - 1) + colors_for_one_trajectory = cmap(norm_values) + else: + colors_for_one_trajectory = np.array([]) + + for i in range(I_grids): + ax = axes[i // ncols, i % ncols] + current = trajectories[:, :, :, i] + + if N_traj_per_grid == 0 or H <= 1: + if N_traj_per_grid == 0: + ax.text(0.5, 0.5, "No data", ha='center', va='center', transform=ax.transAxes) + if xlim: ax.set_xlim(xlim) + if ylim: ax.set_ylim(ylim) + ax.set_xticks([]); ax.set_yticks([]) + continue + + starts = current[:, :-1, :] + ends = current[:, 1:, :] + segments = np.stack((starts, ends), axis=2).reshape(-1, 2, 2) + colors = np.tile(colors_for_one_trajectory, (N_traj_per_grid, 1)) + + lc = LineCollection(segments, colors=colors, linewidths=2) + ax.add_collection(lc) + ax.grid(True, linestyle='--', alpha=0.7) + ax.set_aspect('equal', adjustable='box') + + # Limits + if xlim is None or ylim is None: + xs = current[:, :, 0]; ys = current[:, :, 1] + mnx, mxx = xs.min(), xs.max() + mny, mxy = ys.min(), ys.max() + bx = (mxx - mnx) * 0.1 or 0.1 + by = (mxy - mny) * 0.1 or 0.1 + ax.set_xlim(xlim if xlim is not None else (mnx - bx, mxx + bx)) + ax.set_ylim(ylim if ylim is not None else (mny - by, mxy + by)) + else: + ax.set_xlim(xlim); ax.set_ylim(ylim) + + ax.set_xticks([]); ax.set_yticks([]) + + # Remove any extra axes + for j in range(I_grids, nrows * ncols): + fig.delaxes(axes[j // ncols, j % ncols]) + + fig.suptitle(title, fontsize=16) + + # Single colorbar + if H > 1: + sm = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(vmin=0, vmax=H - 1)) + sm.set_array([]) + cbar_ax = fig.add_axes([0.96, 0.15, 0.02, 0.7]) + cbar = fig.colorbar(sm, cax=cbar_ax) + cbar.set_label("Time (Horizon Step)") + + return fig, axes + + +def plot_trajectory_tuples( + trajectories: np.ndarray, + tup_size: int, + h_start: int, + h_end: int, + title: str = "Trajectory Tuples", + title_args: dict = {}, + cmap_name: str = 'viridis', + xlim: tuple = None, + ylim: tuple = None, + figsize: tuple = None, + wspace: float = 0.05, + hspace: float = 0.05 +) -> List[Tuple[plt.Figure, np.ndarray]]: + """Plots trajectories organized into tuples, with chunks and batches merged. + + This function visualizes trajectories from a stacked NumPy array. The data + in the first dimension (N) is assumed to be ordered by chunk, then by + tuple member, then by batch instance. The chunk and batch dimensions are + merged to create a single grid of subplots. A separate figure is generated + for each component along the last dimension (I). Each subplot shows + 'tup_size' trajectories. + + Args: + trajectories (np.ndarray): A NumPy array of shape (N, H, 2, I), where: + - N is the total batch size, equal to (n_chunks * tup_size * B). + - H is the full horizon (number of points per trajectory). + - 2 represents (X, Y) coordinates. + - I is the number of components, each plotted in a separate figure. + tup_size (int): The number of trajectories that form a single tuple, + plotted together in one subplot. + n_chunks (int): The number of chunks the input 'trajectories' array + is conceptually divided into along the N dimension. + h_start (int): The starting time step to slice from the horizon H. + h_end (int): The ending time step to slice from the horizon H. + title (str): The base super-title for the figures. Each figure title + will be appended with its component index. + cmap_name (str): The name of the matplotlib colormap for the lines. + xlim (tuple, optional): Fixed (min, max) x-limits for all subplots. + If None, limits are auto-scaled for each subplot. + ylim (tuple, optional): Fixed (min, max) y-limits for all subplots. + If None, limits are auto-scaled for each subplot. + figsize (tuple, optional): Overall figure size (width, height) for each + figure. If None, it's automatically determined. + wspace (float): The width space between subplots. + hspace (float): The height space between subplots. + + Returns: + List[Tuple[plt.Figure, np.ndarray]]: A list of tuples, where each + tuple contains the matplotlib Figure and Axes objects for one + component (i in I). + """ + # --- Input Validation --- + if trajectories.ndim != 5 or trajectories.shape[3] != 2: + raise ValueError("Input 'trajectories' must be of shape (P, N, H, 2, I).") + + P, N, H_full, _, I = trajectories.shape + + if N == 0 or I == 0: + print("Warning: No data to plot (N=0 or I=0).") + return [] + + if tup_size <= 0: + raise ValueError("'tup_size' and 'n_chunks' must be positive integers.") + + total_subplots = N + + if not (0 <= h_start < h_end <= H_full): + raise ValueError("Invalid slicing with h_start and h_end.") + + # --- Data Preparation --- + # Slice the trajectories along the time horizon. + traj_sliced = trajectories[:, :, h_start:h_end, :2, :] + H = traj_sliced.shape[2] + + if H <= 1: + print("Warning: Sliced horizon H <= 1, lines will not be visible.") + + grouped_traj = traj_sliced + + # --- Plotting --- + output_figs = [] + cmap = cm.get_cmap(cmap_name) + + # Prepare the color array for a single trajectory. This is reused. + if H > 1: + norm_values = np.linspace(0, 1, H - 1) + colors_for_one_trajectory = cmap(norm_values) + else: + colors_for_one_trajectory = np.array([]) + + # Generate one figure for each component `i` in `I`. + for i in tqdm(range(I)): + # Data for the current figure, shape (total_subplots, tup_size, H, 2) + data_for_fig_i = grouped_traj[:, :, :, :, i] + + # Determine grid layout (square-ish) for the subplots. + if total_subplots > 0: + ncols = int(math.ceil(math.sqrt(total_subplots))) + nrows = int(math.ceil(total_subplots / ncols)) + else: + nrows, ncols = 1, 1 + + # Determine figure size automatically if not provided. + _figsize = figsize + if _figsize is None: + subplot_w, subplot_h = 4, 4 + max_w, max_h = 24, 24 + fig_width = min(ncols * subplot_w, max_w) + fig_height = min(nrows * subplot_h, max_h) + _figsize = (fig_width, fig_height) + + fig, axes = plt.subplots( + nrows, ncols, figsize=_figsize, squeeze=False + ) + fig.subplots_adjust( + left=0.05, right=0.9, bottom=0.05, top=0.9, + wspace=wspace, hspace=hspace + ) + + # Iterate through all subplots. + for i_subplot in range(total_subplots): + r, c = i_subplot // ncols, i_subplot % ncols + ax = axes[r, c] + + # Get the data for the current subplot, shape (tup_size, H, 2) + current_tuple = data_for_fig_i[:, i_subplot, :, :] + + if H <= 1: + ax.text(0.5, 0.5, "H<=1", ha='center', va='center') + else: + # Prepare segments for LineCollection. + starts = current_tuple[:, :-1, :] + ends = current_tuple[:, 1:, :] + segments = np.stack((starts, ends), axis=2).reshape(-1, 2, 2) + colors = np.tile(colors_for_one_trajectory, (tup_size, 1)) + lc = LineCollection(segments, colors=colors, linewidths=1.5) + ax.add_collection(lc) + + # --- Aesthetics and Limits --- + ax.grid(True, linestyle='--', alpha=0.6) + ax.set_aspect('equal', adjustable='box') + ax.set_xticks([]) + ax.set_yticks([]) + + if xlim is None or ylim is None: + if H > 0: + xs, ys = current_tuple[..., 0], current_tuple[..., 1] + mnx, mxx = xs.min(), xs.max() + mny, mxy = ys.min(), ys.max() + bx = (mxx - mnx) * 0.1 or 0.1 + by = (mxy - mny) * 0.1 or 0.1 + ax.set_xlim(xlim if xlim is not None else (mnx - bx, mxx + bx)) + ax.set_ylim(ylim if ylim is not None else (mny - by, mxy + by)) + else: + ax.set_xlim(xlim) + ax.set_ylim(ylim) + + # Hide unused axes + for j in range(total_subplots, nrows * ncols): + fig.delaxes(axes.flatten()[j]) + + title_str = f"{title}: Intial State {i}" + if len(title_args) > 0: + title_str += '\n' + '(' + ", ".join(f"{k}={v}" for k, v in title_args.items()) + ')' + fig.suptitle(title_str, fontsize=14) + + + # Add a single, shared colorbar to the figure. + if H > 1: + sm = cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(vmin=h_start, vmax=h_end-1)) + sm.set_array([]) + cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7]) + cbar = fig.colorbar(sm, cax=cbar_ax) + cbar.set_label(f"Time Step ({h_start} to {h_end-1})") + + output_figs.append((fig, axes)) + + return output_figs + + +def plot_collision_masks(collision_masks, safe_dist, safe_rates=None): + """ + Plot collision masks as a grid where each row is a batch sample and each column is a horizon step. + Green cells represent safe states, red cells represent collisions. + + Args: + collision_masks: np.ndarray of shape (I, P, N, H) - boolean masks for collisions + safe_dist: float - the safety distance threshold used for collision detection + + Returns: + fig, axes - matplotlib figure and axes objects + """ + I, P, N, H = collision_masks.shape + # Just use player 0 since both players have the same collision patterns + player_idx = 0 + + # Create a figure with subplots. One for each initial state. + fig_rows = int(np.ceil(I / 2)) + fig_cols = 2 + fig, axes = plt.subplots(fig_rows, fig_cols, figsize=(12, fig_rows * 3), squeeze=False) + fig.suptitle(f"Collision Masks by Initial State\n(Red=Collision, Green=Safe, Safe Dist={safe_dist})", fontsize=16) + + # Define a colormap: green for 0 (safe), red for 1 (collision) + cmap = plt.cm.colors.ListedColormap(['green', 'red']) + + for i in range(I): + row_idx = i // 2 + col_idx = i % 2 + ax = axes[row_idx, col_idx] + + # Convert the boolean mask to integers for plotting + mask_to_plot = collision_masks[i, player_idx].astype(int) + + # Display the data as an image, where each pixel corresponds to a boolean value + im = ax.imshow(mask_to_plot, cmap=cmap, aspect='auto', interpolation='none') + + ax.set_title(f"Initial State {i}") + ax.set_xlabel("Horizon Step") + ax.set_ylabel("Batch Sample") + + # Add text showing collision rate + if safe_rates is None: + collision_rate = np.mean(mask_to_plot) + safe_rate = 1.0 - collision_rate + else: + safe_rate = safe_rates[i, 0] + ax.text(0.98, 0.02, f"Safe: {safe_rate:.2%}", + transform=ax.transAxes, ha='right', va='bottom', + bbox=dict(facecolor='white', alpha=0.5)) + + # Remove any extra axes if odd number of initial states + if I % 2 != 0: + fig.delaxes(axes[fig_rows-1, 1]) + + # Add tight layout to ensure proper spacing and prevent overlap + plt.tight_layout(rect=[0, 0, 1, 0.96]) # Leave room for suptitle + return fig, axes + + +def plot_velocity_stats( + t, v_mean, v_std, v_max, v_min, v_mean_all, hard_deck, + title='(Normalized) Velocity Statistics Over Horizon', + subtitle='', + legend_loc='lower right', +): + fig, ax = plt.subplots(figsize=(10, 7.5)) + ax.plot(t, v_mean, label='Mean Velocity', color='blue') + ax.fill_between( + t, + v_mean - v_std, + v_mean + v_std, + color='blue', alpha=0.2, label='Std Dev' + ) + if hard_deck is not None and np.isfinite(hard_deck): + ax.axhline(hard_deck, color='red', linestyle=':', label=f'Constraint = {hard_deck:.4f}') + ax.plot(t, v_max, label=rf'Max Velocity $\leq$ {v_max.max():.4f}', color='purple', linestyle='--') + ax.plot(t, v_min, label='Min Velocity', color='green', linestyle='--') + ax.axhline(v_mean_all, color='orange', linestyle='--', label=f'Overall Mean Velocity = {v_mean_all:.2f}') + ax.set_title(subtitle, fontsize=14) + ax.set_xlabel('Time Step (Horizon)') + ax.set_ylabel('Velocity (units/s)') + ax.legend(loc=legend_loc, fontsize='14') + ax.grid() + fig.suptitle(title, fontsize=16) + fig.tight_layout() + return fig, ax + +def plot_velocity_histogram( + v, bins=40, figsize=(10, 6), quantiles=[], colors=None, + title='Histogram of (Normalized) Velocities', + subtitle='' + ): + """ + Plot a histogram of velocities with optional quantile lines. + + Args: + v (np.ndarray): Velocity array. + bins (int): Number of histogram bins. + figsize (tuple): Figure size. + quantiles (list): List of quantiles to plot (e.g., [0.9, 0.95]). + colors (list or None): List of colors for quantile lines. + """ + fig, ax = plt.subplots(figsize=figsize) + flat_v = v.flatten() + ax.hist(flat_v, bins=bins, color='skyblue', edgecolor='black', alpha=0.8, density=True) + if colors is None: + # Use default matplotlib tab10 colors if not provided + colors = matplotlib.colormaps['tab10'].colors + for i, q in enumerate(quantiles): + quantile_val = np.quantile(flat_v, q) + color = colors[i % len(colors)] + ax.axvline(quantile_val, color=color, linestyle='--', label=f'{int(q*100)}% Quantile: {quantile_val:.4f}') + ax.set_title(subtitle, fontsize=14) + ax.set_xlabel('Velocity (units/s)') + ax.set_ylabel('Density') + ax.grid(True, linestyle='--', alpha=0.5) + if len(quantiles) > 0: + ax.legend(fontsize=14) + fig.suptitle(title, fontsize=16) + plt.tight_layout() + return fig, ax + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..a9cbc8a28db6c624e39d12985209b28d82943455 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/pusht/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages +from pathlib import Path + +this_dir = Path(__file__).parent +long_description = (this_dir / "README.md").read_text(encoding="utf-8") + +setup( + name="diffusion_policy", + version="1.0.0", + description="Diffusion Policy for PCD", + long_description=long_description, + long_description_content_type="text/markdown", + author="Hao Luan", + author_email="haoluan@comp.nus.edu.sg", + packages=find_packages(include=["diffusion_policy", "diffusion_policy.*"]), + install_requires=[], +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/README.md b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b4c0b3e4361da01b1a292c1c3f5f4fc41c482b99 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/README.md @@ -0,0 +1,60 @@ +# 🎯 Constrained Diffusion Toy Example + +This repository contains a toy experiment to illustrate the different resulting distributions of two spheres of different sizes after applying various variations of Langevin Monte Carlo (LMC). + +The problem setting is visualized using the `problem_setting.ipynb` notebook. This notebook generates plots to describe the initial setup of the experiment. + + +## 📂 Project Structure + +``` +problem_setting.ipynb # Jupyter notebook for generating plots and visualizations +README.md # Project documentation +requirements.txt # Python dependencies +plots/ # Directory containing generated plots +src/ # Source code directory +├── lmc.py # Implementation of LMC (Langevin Monte Carlo) +└── main.py # Main script for running the toy example +``` + +
+
+ +## 🚀 Getting Started + +### Prerequisites + +Ensure you have Python installed on your system. It is recommended to use a virtual environment to manage dependencies. + + +### Installation + +1. Install the required dependencies: + ```bash + conda create -n toy-example python=3.10 -y + conda activate toy-example + pip install -r requirements.txt + ``` + + +### Usage + +1. Open the Jupyter notebook `problem_setting.ipynb` to generate and view visualizations: + ```bash + jupyter notebook problem_setting.ipynb + ``` + +2. Run the main script to execute the toy example: + ```bash + python -m src.main + ``` + + +### Outputs + +Generated plots can be found in the `plots/` directory. These include visualizations of the constrained diffusion processes. + + +### 💡 NOTES + +Configurations are centralised in [src/main.py](src/main.py). \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/problem_setting.ipynb b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/problem_setting.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..a500aa078c3128ac63a4c751bb478cb6e5fa26e4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/problem_setting.ipynb @@ -0,0 +1,139 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "37a04250", + "metadata": {}, + "source": [ + "# Problem Setting\n", + "\n", + "This notebook is used to plot the problem setting for the toy experiment illustrating different resulting distributions of two spheres / rectangles of different sizes after applying various variations of Langevin Monte Carlo (LMC)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ade1f242", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import os\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.patches import Rectangle\n", + "\n", + "# Config\n", + "# ============================\n", + "\n", + "# parameters\n", + "length_big = 6\n", + "length_small = 2\n", + "center = 4.5\n", + "\n", + "# assign variances such that big block has more central mass (smaller sigma)\n", + "sigma_big = 0.6\n", + "sigma_small = 1.0\n", + "\n", + "# X-axis\n", + "x = np.linspace(0, 9, 500)\n", + "\n", + "# output\n", + "savefile = 'plots/toy_demo.pdf'\n", + "\n", + "# ============================\n", + "\n", + "\n", + "# Gaussian PDFs\n", + "pdf_big = (1 / (sigma_big * np.sqrt(2 * np.pi))) * np.exp(-((x - center) ** 2) / (2 * sigma_big**2))\n", + "pdf_small = (1 / (sigma_small * np.sqrt(2 * np.pi))) * np.exp(-((x - center) ** 2) / (2 * sigma_small**2))\n", + "\n", + "# Create plot\n", + "fig, ax = plt.subplots(figsize=(6, 5), constrained_layout=True)\n", + "\n", + "\n", + "# Draw 1D blocks as colored rectangles\n", + "rect_big = Rectangle((center - length_big / 2, -0.2), length_big, 0.1,\n", + " color='purple', alpha=0.6, label=f'Big Block')\n", + "rect_small = Rectangle((center - length_small / 2, -0.35), length_small, 0.1,\n", + " color='green', alpha=0.6, label=f'Small Block')\n", + "\n", + "ax.add_patch(rect_big)\n", + "ax.add_patch(rect_small)\n", + "\n", + "# Plot distributions\n", + "ax.plot(x, pdf_big, color='purple', label=f'Prior(Big)', linewidth=3)\n", + "ax.plot(x, pdf_small, color='green', label=f'Prior(Small)', linewidth=3)\n", + "\n", + "# Mark boundaries\n", + "ax.axvline(0, color='black', linestyle='-', linewidth=3, alpha=0.75)\n", + "ax.axvline(9, color='black', linestyle='-', linewidth=3, alpha=0.75, label='Boundary')\n", + "\n", + "ax.set_xlim(-0.5, 10.3)\n", + "ax.set_ylim(-0.5, max(pdf_big.max(), pdf_small.max()) + 0.1)\n", + "ticks = np.arange(0, 11)\n", + "labels = [str(i) if i <= 9 else \"\" for i in ticks]\n", + "ax.set_xticks(ticks)\n", + "ax.set_xticklabels(labels, fontsize=20)\n", + "ax.set_yticklabels([])\n", + "ax.legend(loc='upper right', fontsize=16)\n", + "\n", + "# set invinsible dummy texts for alignment\n", + "ax.set_xlabel(\"N/A\", fontsize=11, color=\"white\")\n", + "ax.set_ylabel(\"N/A\\nN/A\", fontsize=14, color=\"white\")\n", + "\n", + "# tighter tick-label padding for paper\n", + "ax.margins(x=0, y=0)\n", + "ax.tick_params(pad=1, direction='in', labelsize=15)\n", + "ax.xaxis.labelpad = 1\n", + "ax.yaxis.labelpad = 1\n", + "\n", + "plt.show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c9f63bc0", + "metadata": {}, + "outputs": [], + "source": [ + "savedir = os.path.dirname(savefile)\n", + "os.makedirs(savedir, exist_ok=True)\n", + "\n", + "fig.savefig(savefile, dpi=300, bbox_inches='tight', format='pdf', pad_inches=0)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "toy-example", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0e14e708df1d2be752c53b3ce00fea0299a3e233 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/requirements.txt @@ -0,0 +1,5 @@ +torch +numpy +matplotlib +tqdm + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/lmc.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/lmc.py new file mode 100644 index 0000000000000000000000000000000000000000..984aa4acae2403c76146db00bdbec06056530d1f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/lmc.py @@ -0,0 +1,507 @@ +"""Pipeline script for each LMC variation.""" + +import os +import torch +import numpy as np +from tqdm import tqdm +from copy import deepcopy +import matplotlib.pyplot as plt +from matplotlib import cm, colors +import matplotlib.patheffects as pe +import matplotlib.gridspec as gridspec +from matplotlib.patches import Rectangle + + + + +# Operators & Gradients +# ====================================== +def proj(z, lo, hi): + """Simple projection via clamp.""" + return torch.clamp(z, min=lo, max=hi) + +def dC_dx(x, y, alpha): + """C(x,y): Log-Repulsion, Eq. (10) in paper.""" + eps = 1e-12 + delta = x - y + r = torch.sqrt(delta * delta + eps) + return - delta / (r * (r + alpha)) + +def dC_dy(x, y, alpha): + return -dC_dx(x, y, alpha) +# ====================================== + + + + +def run_coupled_lmc(seed, N, steps, mu_x, mu_y, sigma2_x, sigma2_y, eta, lam, sqrt_2eta, alpha, x_lo, x_hi, y_lo, y_hi, device): + """Coupling Only.""" + torch.manual_seed(seed) + + # ---- init: sample centers from N(mu, I) + X = torch.normal(mean=mu_x, std=sigma2_x**0.5, size=(N,), device=device) + Y = torch.normal(mean=mu_y, std=sigma2_y**0.5, size=(N,), device=device) + + # ---- Langevin loop + for _ in tqdm(range(steps), desc="Coupled LMC"): + # Gaussian prior score terms + drift_x = (mu_x - X) / sigma2_x + drift_y = (mu_y - Y) / sigma2_y + + # coupling (penalty) gradients + g_x = dC_dx(X, Y, alpha) + g_y = dC_dy(X, Y, alpha) + + # Euler–Maruyama step + X = X + eta * (drift_x - lam * g_x) + sqrt_2eta * torch.randn_like(X) + Y = Y + eta * (drift_y - lam * g_y) + sqrt_2eta * torch.randn_like(Y) + + # ---- to numpy + Xc = X.detach().cpu().numpy() + Yc = Y.detach().cpu().numpy() + + return Xc, Yc + + + +def run_projected_lmc(seed, N, steps, mu_x, mu_y, sigma2_x, sigma2_y, eta, lam, sqrt_2eta, alpha, x_lo, x_hi, y_lo, y_hi, device): + """Projection Only.""" + torch.manual_seed(seed) + + # ---- init: sample centers from N(mu, I) + X = torch.normal(mean=mu_x, std=sigma2_x**0.5, size=(N,), device=device) + Y = torch.normal(mean=mu_y, std=sigma2_y**0.5, size=(N,), device=device) + + # ---- Langevin loop + for _ in tqdm(range(steps), desc="Projected LMC"): + # Gaussian prior score terms + drift_x = (mu_x - X) / sigma2_x + drift_y = (mu_y - Y) / sigma2_y + + # Euler–Maruyama step + proj + X = proj(X + eta * drift_x + sqrt_2eta * torch.randn_like(X), x_lo, x_hi) + Y = proj(Y + eta * drift_y + sqrt_2eta * torch.randn_like(Y), y_lo, y_hi) + + # ---- to numpy + Xc = X.detach().cpu().numpy() + Yc = Y.detach().cpu().numpy() + + return Xc, Yc + + + +def run_projected_coupled_lmc(seed, N, steps, mu_x, mu_y, sigma2_x, sigma2_y, eta, lam, sqrt_2eta, alpha, x_lo, x_hi, y_lo, y_hi, device): + """Projection + Coupling.""" + torch.manual_seed(seed) + + # ---- init: sample centers from N(mu, I) + X = torch.normal(mean=mu_x, std=sigma2_x**0.5, size=(N,), device=device) + Y = torch.normal(mean=mu_y, std=sigma2_y**0.5, size=(N,), device=device) + + # ---- Langevin loop + for _ in tqdm(range(steps), desc="Projected Coupled LMC"): + # Gaussian prior score terms + drift_x = (mu_x - X) / sigma2_x + drift_y = (mu_y - Y) / sigma2_y + + # coupling (penalty) gradients + g_x = dC_dx(X, Y, alpha) + g_y = dC_dy(X, Y, alpha) + + # Euler–Maruyama step + projection + X = proj(X + eta*(drift_x - lam*g_x) + sqrt_2eta*torch.randn_like(X), x_lo, x_hi) + Y = proj(Y + eta*(drift_y - lam*g_y) + sqrt_2eta*torch.randn_like(Y), y_lo, y_hi) + + # ---- to numpy + Xc = X.detach().cpu().numpy() + Yc = Y.detach().cpu().numpy() + + return Xc, Yc + + + +def run_conditional_lmc(seed, N, steps, mu_x, mu_y, sigma2_x, sigma2_y, eta, lam, sqrt_2eta, alpha, x_lo, x_hi, y_lo, y_hi, device): + """Conditional Sampling (Sequential).""" + torch.manual_seed(seed) + + # sample x and fix it + X = torch.normal(mean=mu_x, std=sigma2_x**0.5, size=(N,), device=device) + if steps > 0: + for _ in tqdm(range(steps), desc="Regular LMC"): + score_x = (mu_x - X) / sigma2_x + X = X + eta * score_x + sqrt_2eta * torch.randn_like(X) + X_star = X.detach() + + # sample y w.r.t. fixed x / X_star + Y = torch.normal(mean=mu_y, std=sigma2_y**0.5, size=(N,), device=device) + for _ in tqdm(range(steps), desc="Conditional LMC"): + score_prior_y = (mu_y - Y) / sigma2_y + g_y = dC_dy(X_star, Y, alpha) + Y = Y + eta * (score_prior_y - lam * g_y) + sqrt_2eta * torch.randn_like(Y) + + # ---- to numpy + Xc = X_star.detach().cpu().numpy() + Yc = Y.detach().cpu().numpy() + + return Xc, Yc + + + + + + +def run_main(CONFIG_MAP, FN_MAP): + + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + + # histogram num bins + bins = 180 + + # fontsize + fontsize = 20 + + print("\nRunning various LMCs") + print("============================") + + BUCKET = {} + + for MODE, LMC_FN in FN_MAP.items(): + + print(f"Running {MODE}..") + + # read config + # ================================== + CONFIG = CONFIG_MAP[MODE] + + N = CONFIG.N + steps = CONFIG.steps + eta = CONFIG.eta + mu = CONFIG.mu + sigma2_x = CONFIG.sigma2_x + sigma2_y = CONFIG.sigma2_y + rx = CONFIG.rx + ry = CONFIG.ry + d0 = CONFIG.d0 + lam = CONFIG.lam + sqrt_2eta = CONFIG.sqrt_2eta + alpha = CONFIG.alpha + corridor_min = CONFIG.corridor_min + corridor_max = CONFIG.corridor_max + view_min = CONFIG.view_min + view_max = CONFIG.view_max + # ================================== + + + # feasible intervals (hard walls via projection) + x_lo, x_hi = corridor_min+rx, corridor_max-rx + y_lo, y_hi = corridor_min+ry, corridor_max-ry + + # feasible rectangle + rect_x, rect_w = float(x_lo), float(x_hi - x_lo) + rect_y, rect_h = float(y_lo), float(y_hi - y_lo) + + + # run each LMC variant + x, y = LMC_FN( + seed = CONFIG.seed, + N = N, + steps = steps, + mu_x = mu, + mu_y = mu, + sigma2_x = sigma2_x, + sigma2_y = sigma2_y, + eta = eta, + lam = lam, + sqrt_2eta = sqrt_2eta, + alpha = alpha, + x_lo = x_lo, + x_hi = x_hi, + y_lo = y_lo, + y_hi = y_hi, + device = device + ) + + # keep only non-colliding samples & allow anything outside the wall + r_xy = np.abs(x - y) + non_collide_mask = (r_xy >= d0) + collide_mask = ~non_collide_mask + x_non_collide = x[non_collide_mask] + y_non_collide = y[non_collide_mask] + x_collide = x[collide_mask] + y_collide = y[collide_mask] + + # histogram count + counts, xedges, yedges = np.histogram2d( + x_non_collide, y_non_collide, bins=bins, + range=[[view_min, view_max], [view_min, view_max]] + ) + coll_counts, xedges_c, yedges_c = np.histogram2d( + x_collide, y_collide, bins=bins, + range=[[view_min, view_max], [view_min, view_max]] + ) + + # norm to probs + total = N + probs = counts / (total if total > 0 else 1) + + # natural log + log_probs = np.full_like(probs, 0.0, dtype=float) + mask = probs > 0 + log_probs[mask] = np.log(probs[mask]) + + log_counts = np.full_like(counts, 0.0, dtype=float) + mask = counts > 0 + log_counts[mask] = np.log(counts[mask]) + log_counts_c = np.full_like(coll_counts, 0.0, dtype=float) + mask_c = coll_counts > 0 + log_counts_c[mask_c] = np.log(coll_counts[mask_c]) + + # proportion of valid samples (inside wall & do not collide) + # 1) Inside feasible intervals (both x and y inside their walls) + inside_mask = (x >= x_lo) & (x <= x_hi) & (y >= y_lo) & (y <= y_hi) + num_inside = int(inside_mask.sum()) + prop_inside = num_inside / (N if N > 0 else 1) + + # 2) Do not collide (distance >= d0), regardless of walls + non_collide_mask = (np.abs(x - y) >= d0) + num_non_collide = int(non_collide_mask.sum()) + prop_non_collide = num_non_collide / (N if N > 0 else 1) + + # 3) Both conditions simultaneously + both_mask = inside_mask & non_collide_mask + num_both = int(both_mask.sum()) + prop_both = num_both / (N if N > 0 else 1) + + # store + BUCKET[MODE] = { + "x" : x, + "y" : y, + "counts" : counts, + "xedges" : xedges, + "yedges" : yedges, + "coll_counts": coll_counts, + "xedges_c" : xedges_c, + "yedges_c" : yedges_c, + "total" : total, + "probs" : probs, + "log_probs" : log_probs, + "log_counts": log_counts, + "log_counts_c": log_counts_c, + "prop_inside" : prop_inside, + "prop_non_collide": prop_non_collide, + "prop_valid" : prop_both + } + + print("============================") + + + + # Set colorbar to cover from 0-99 th percentile values. + logs_all = [] + for d in BUCKET.values(): + c = d["counts"] + v = np.log(c[c > 0].ravel()) + if v.size: + logs_all.append(v) + + if logs_all: + p90_log = np.percentile(np.concatenate(logs_all), 99) + else: + p90_log = 0.0 # fallback if everything is empty + + print(f"Global 90th percentile (log-counts): {p90_log:.6g}") + + colorbar_norm = colors.Normalize(vmin=0.0, vmax=p90_log) + + + + print(f"\nPlotting Heatmaps") + print("============================") + + + for MODE, RESULTS in BUCKET.items(): + + print(f"Plotting {MODE}..") + + # read config + # ================================== + CONFIG = CONFIG_MAP[MODE] + rx = CONFIG.rx + ry = CONFIG.ry + corridor_min = CONFIG.corridor_min + corridor_max = CONFIG.corridor_max + x_lo, x_hi = corridor_min+rx, corridor_max-rx + y_lo, y_hi = corridor_min+ry, corridor_max-ry + rect_x, rect_w = float(x_lo), float(x_hi - x_lo) + rect_y, rect_h = float(y_lo), float(y_hi - y_lo) + cmap_non_collide = cm.get_cmap(CONFIG.cmap_non_collide) + cmap_collide = cm.get_cmap(CONFIG.cmap_collide) + view_min = CONFIG.view_min + view_max = CONFIG.view_max + output_dir = CONFIG.output_dir + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + # ================================== + + + + # segment out density map regions that are 0. + # ================================================= + log_nc = RESULTS["log_counts"] + counts = RESULTS["counts"] + log_nc = np.ma.masked_where(counts == 0, log_nc) + + log_c = RESULTS["log_counts_c"] + coll_counts = RESULTS["coll_counts"] + log_c = np.ma.masked_where(coll_counts == 0, log_c) + + cmap_nc = deepcopy(cmap_non_collide) + cmap_nc.set_bad((0, 0, 0, 0)) # RGBA, alpha=0 + + cmap_c = deepcopy(cmap_collide) + cmap_c.set_bad((0, 0, 0, 0)) + # ================================================= + + + + # build marginal histograms + # ================================================= + x = RESULTS["x"] + y = RESULTS["y"] + + grid_min = min(x.min(), y.min(), x_lo) + grid_max = max(x.max(), y.max(), x_hi) + bins = min(200, max(10, min(len(x),len(y))//5)) + edges = np.linspace(grid_min, grid_max, bins+1) + centers = 0.5*(edges[:-1]+edges[1:]) + widths = edges[1:] - edges[:-1] + hx,_ = np.histogram(x, bins=edges) + hy,_ = np.histogram(y, bins=edges) + pdf_x = hx/(len(x)*widths); pdf_x /= np.trapz(pdf_x, centers) + pdf_y = hy/(len(y)*widths); pdf_y /= np.trapz(pdf_y, centers) + # ================================================= + + + + # construct layout with GridSpec + fig = plt.figure(figsize=(8,8)) + gs = gridspec.GridSpec(2,2, + width_ratios =[4,1], + height_ratios=[1,4], + wspace=0.05, hspace=0.05) + + ax_histy = fig.add_subplot(gs[0,0]) + ax_joint = fig.add_subplot(gs[1,0]) + ax_histx = fig.add_subplot(gs[1,1]) + + + # 1) top histogram of x + ax_histx.fill_betweenx(centers, 0, pdf_x, color='purple', alpha=0.3, step='mid', linewidth=0) + ax_histx.plot(pdf_x, centers, '-', color='purple', markersize=4, linewidth=2) + ax_histx.axhline(corridor_min, color='black', linestyle='-', alpha=0.75, linewidth=3) + ax_histx.axhline(corridor_max, color='black', linestyle='-', alpha=0.75, linewidth=3) + ax_histx.axhline(x_lo, color='red', linestyle='--', alpha=1.0, linewidth=3) + ax_histx.axhline(x_hi, color='red', linestyle='--', alpha=1.0, linewidth=3) + ax_histx.set_ylim(view_min, view_max) + ax_histx.set_xlim(0, 1) + ax_histx.set_xticks([]) + ax_histx.tick_params(labelleft=False) + + # 2) right histogram of y + ax_histy.fill_between(centers, pdf_y, 0, color='green', alpha=0.3, step='mid', linewidth=0) + ax_histy.plot(centers, pdf_y, '-', color='green', markersize=4, linewidth=2) + ax_histy.axvline(corridor_min, color='black', linestyle='-', alpha=0.75, linewidth=3) + ax_histy.axvline(corridor_max, color='black', linestyle='-', alpha=0.75, linewidth=3) + ax_histy.axvline(y_lo, color='red', linestyle='--', alpha=1.0, linewidth=3) + ax_histy.axvline(y_hi, color='red', linestyle='--', alpha=1.0, linewidth=3) + ax_histy.set_xlim(view_min, view_max) + ax_histy.set_ylim(0, 1) + ax_histy.set_yticks([]) + ax_histy.tick_params(labelbottom=False) + + # show segmented density maps + ax_joint.imshow( + log_nc, + origin='lower', + extent=[view_min, view_max, view_min, view_max], + interpolation='none', + cmap=cmap_nc, + norm=colorbar_norm, + zorder=1, + ) + ax_joint.imshow( + log_c, + origin='lower', + extent=[view_min, view_max, view_min, view_max], + interpolation='none', + cmap=cmap_c, + norm=colorbar_norm, + zorder=2, + ) + # draw feasible regions + rect = Rectangle( + (rect_y, rect_x), rect_h, rect_w, + fill=False, + edgecolor='red', + linewidth=3, + linestyle='--', + alpha=0.8, + zorder=3 + ) + ax_joint.add_patch(rect) + rect = Rectangle( + (corridor_min, corridor_min), (corridor_max-corridor_min), (corridor_max-corridor_min), + fill=False, + edgecolor='black', + linewidth=3, + alpha=0.75, + zorder=4 + ) + ax_joint.add_patch(rect) + + # set lims and labels + ax_joint.set_xlim(view_min, view_max) + ax_joint.set_ylim(view_min, view_max) + ax_joint.set_xlabel("Location of Small Block", fontsize=fontsize) + ax_joint.set_ylabel("Location of Big Block", fontsize=fontsize) + ax_joint.set_xticks(np.linspace(corridor_min, corridor_max, len(range(corridor_max - corridor_min + 1)))) + ax_joint.set_yticks(np.linspace(corridor_min, corridor_max, len(range(corridor_max - corridor_min + 1)))) + ax_joint.tick_params(axis='both', labelsize=fontsize) + ax_joint.grid(color='black', alpha=0.1, linewidth=0.7) + + # clean up ticks + for ax in (ax_joint,): + ax.tick_params(pad=1, labelsize=12) + for ax in (ax_histx, ax_histy): + ax.margins(0) + + # tighter tick-label padding for paper + ax_joint.margins(x=0, y=0) + ax_joint.tick_params(pad=1) + ax_joint.xaxis.labelpad = 1 + ax_joint.yaxis.labelpad = 1 + + # add proportion numbers + prop_valid = RESULTS["prop_valid"] + prop_inside = RESULTS["prop_inside"] + prop_non_collide = RESULTS["prop_non_collide"] + + label = ( + # f"$p_\\mathrm{{inside}}$ = {prop_inside:.1%}\n" + f"$p_\\mathrm{{out}}$ = {1-prop_inside:.1%}\n" + f"$p_\\mathrm{{overlap}}$ = {1-prop_non_collide:.1%}" + # f"$p_\\mathrm{{feasible}}$ = {prop_valid:.1%}" + ) + ax_joint.text( + 0.9, 0.9, label, transform=ax_joint.transAxes, + ha='right', va='top', fontsize=fontsize, linespacing=1.2, + bbox=dict(boxstyle='round,pad=0.25', facecolor='white', alpha=0.75, edgecolor='none'), + zorder=5 + ) + + plt.savefig(os.path.join(output_dir, f'heatmap_nc_{MODE}_2.pdf'), dpi=300, bbox_inches='tight', pad_inches=0) + plt.close(fig) + + print("============================") + print("DONE.") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/main.py b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/main.py new file mode 100644 index 0000000000000000000000000000000000000000..6ffab3aca72d4539bd10fa0a6ea7ea939b92bb4a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/PCD/toy_example/src/main.py @@ -0,0 +1,88 @@ +"""Main driver script to run various LMCs.""" + +import argparse +from copy import deepcopy +from src.lmc import run_main +from src.lmc import ( + run_conditional_lmc, + run_projected_coupled_lmc, + run_projected_lmc, + run_coupled_lmc +) + + +# python -m src.main + + +# BASE_CONFIG +# ============================================================= +# ============================================================= + +BASE_CONFIG = argparse.Namespace() + +# random seed +BASE_CONFIG.seed = 3407 + +# Langevin steps +BASE_CONFIG.N = 500000 # batch size (pairs) +BASE_CONFIG.steps = 200 # Langevin steps +BASE_CONFIG.eta = 0.05 # step size + +# init dist & score function +BASE_CONFIG.mu = 4.5 # Guassian mean (score func) +BASE_CONFIG.sigma2_x = 0.6 # Guassian var (score func) +BASE_CONFIG.sigma2_y = 1.0 # Guassian var (score func) + +# sphere / rectangle sizes +BASE_CONFIG.rx = 3.0 # each sphere's radius size +BASE_CONFIG.ry = 1.0 + +# coupling +BASE_CONFIG.d0 = BASE_CONFIG.rx + BASE_CONFIG.ry # desired separation = 4 +BASE_CONFIG.lam = 32 # coupling strength λ +BASE_CONFIG.sqrt_2eta = (2.0 * BASE_CONFIG.eta) ** 0.5 # +BASE_CONFIG.alpha = 1.5 # Log-repulsive c(x,y) +alpha + +# projection +BASE_CONFIG.corridor_min = 0 # corridor min +BASE_CONFIG.corridor_max = 9 # corridor max + +# visualisation +BASE_CONFIG.cmap_non_collide = "Blues" +BASE_CONFIG.cmap_collide = "Oranges" +BASE_CONFIG.view_min = -0.25 +BASE_CONFIG.view_max = 9.25 + +# save +BASE_CONFIG.output_dir = "plots/lmc" + +# ============================================================= +# ============================================================= + + + +if __name__ == "__main__": + + # custom config + COUPLED_CFG = deepcopy(BASE_CONFIG) + PROJECTED_CFG = deepcopy(BASE_CONFIG) + PROJECTED_COUPLED_CFG = deepcopy(BASE_CONFIG) + CONDITIONAL_LMC_CFG = deepcopy(BASE_CONFIG) + CONDITIONAL_LMC_CFG.lam = 12 + + + + FN_MAP = { + "coupled_lmc" : run_coupled_lmc, + "projected_coupled_lmc" : run_projected_coupled_lmc, + "conditional_lmc" : run_conditional_lmc, + # "projected_lmc" : run_projected_lmc, + } + + CONFIG_MAP = { + "coupled_lmc" : COUPLED_CFG, + "projected_coupled_lmc" : PROJECTED_COUPLED_CFG, + "conditional_lmc" : CONDITIONAL_LMC_CFG, + # "projected_lmc" : PROJECTED_CFG, + } + run_main(CONFIG_MAP, FN_MAP) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/README.md b/applications/multi_robot/robotarium/robotarium_baselines/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0afadcd8e0054861ecc5e8c424ba6aebadb4f898 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/README.md @@ -0,0 +1,130 @@ +# robotarium_baselines + +The six baselines of the Robotarium experiment (Sec. 4.2): DiRecT, CD, Final Proj., PCD-SHD, PCD-LB and MMD-CBS, on the +Georgia Tech Robotarium arena (3.2 x 2.0 m, robot radius 0.13 m), 5 maps x N in {3, 6, 9, 12}, 10 start-goal instances +per cell. Every method plans the same instances (`data_prep/instances/instances_v2.npz`, also GlobMP's) and the +diffusion baselines sample from one single-robot diffusion model per map (shipped in `checkpoints/`). GlobMP's own side +of this experiment is one level up (`../README.md`). + +## Repository + +``` +config/ machine paths (paths.env.example), env.sh (the Robotarium setting: RBT_ARENA, RBT_ISO, ROBOT_R=0.13), + maps.json (per-map environment class and obstacle scale) +data_prep/ demonstrations/: the paper's single-robot demonstrations per map (744 MB), unpacked by + get_demonstrations.sh; generate_data.sh: a new draw of them; instances/ (instances_v2.npz: 10 per cell, 9 + oracle-certified + 1 spanning ring; certified_B_eval10 and rings/: the sets it was assembled from); + assemble_instances_v2.py: rebuilds instances_v2.npz from those sets (identical arrays); + build_certified_instances.py, feasibility_oracle_rbt.py, gen_ring_lowN.py, shared_instances.py +train/ train_models.sh: the single-robot diffusion models (skip it to use checkpoints/) +run/ run_cell.sh: one method on one cell; run_all.sh: the 120 cells; original_drivers/: the scripts the paper's + runs were launched with (machine paths kept, as a record) +eval/ score_cells.py: SR(one) and SR(64) per cell -> robotarium_cells.json; fig_robotarium_sr_one.py: the figure; + robotarium_cells.json: the published cells; mmd_cbs_runs/: the MMD-CBS runs behind them and + mmd_cbs_rerun.md: how they were re-run; original/: the scorers, patch files and setup notes as run +tools/ wire_artifacts.sh (demonstrations and models into the forks) +hardware/ the baselines' Robotarium hardware runs: the instance, the plans that ran, the 28 scripts as submitted, and + the runs, plan pick and rendering that reproduce them (hardware/README.md) +checkpoints/ the five single-robot diffusion models of the paper's setting (args.yaml + final EMA weights, 76 MB) +DiRecT/ DiRecT's multi_robot code (DiRecT, CD, Final Proj., MMD-CBS), with the Robotarium changes applied +PCD/ PCD (PCD-SHD, PCD-LB), with the Robotarium changes applied +SMD/ SMD (Diffusion-MRMP): data generation and training, with the Robotarium changes applied +``` + +## Original code + +The fork folders are the trees the paper's runs used, which are the original repositories at the commits below (the +commit with the most files in common) with our changes applied; licenses are kept in each folder. They replace the +patch-file layout of the earlier package (`eval/original/overrides/` and `OVERRIDES_MANIFEST.md`, kept as a record). + +| Folder | Original repository | Commit | License | Our changes | +|---|---|---|---|---| +| `DiRecT/` | [azizanlab/DiRecT](https://github.com/azizanlab/DiRecT), `multi_robot/` | a0586f3 | MIT | 214 files unchanged. torch_robotics: the arena gate `RBT_ARENA=1` (limits x [-1.6, 1.6], y [-1.0, 1.0]), `ROBOT_R` sets the disc radius, `RBT_WIDE=1` widens Conveyor's channels (0.24 to 0.29 m), Cross-Room and Corridor-Bay maps. mmd: the isotropic normalizer `RBT_ISO=1` (both axes scaled alike), the radius from `ROBOT_R`, casadi and jax imports made optional, shared start-goal instances. Added: the per-cell runners `scripts/inference/run_{direct,cd_pdm,finalproj,mmd_cbs}_cell.py` | +| `PCD/` | [EdmundLuan/pcd](https://github.com/EdmundLuan/pcd) | e983e1f | MIT | 377 unchanged; the same torch_robotics and mmd changes; the data folder taken from the fork's location instead of the enclosing git repository; added: Cross-Room and Corridor-Bay maps and configs, `plan_pcd.py`, `scripts/inference/run_pcd_cell.py` | +| `SMD/` | [RAISELab-atUVA/Diffusion-MRMP](https://github.com/RAISELab-atUVA/Diffusion-MRMP) | c87fc76 | MIT | 190 unchanged; the same torch_robotics and normalizer changes, the data folder fix; added: `scripts/repro/` (`generate_singleagent_data.py`, `train_singleagent_all.py`, scenario tables), `instances_data/` (the map files of Diffusion-MRMP's data release; Empty reads `empty_map.pkl`) | + +MMD-CBS is the MMD planner (Shaoul et al., [yoraish/mmd](https://github.com/yoraish/mmd)) as DiRecT ships it. + +## Setup + +0. Get the repository with git and git-lfs: `git lfs install`, then `git clone https://huggingface.co/GlobalMotionPlanning/GlobMP` + (models, instances and demonstrations are LFS files). A file-by-file download (`hf download`, `snapshot_download`) + of its 3,000+ files runs into the Hub's request limit. The scripts lose their executable bit on the Hub: run them + with `bash`. +1. `cp config/paths.env.example config/paths.env` and set `RB_ROOT` and `CONDA_BASE` (or `PY_DIRECT`, `PY_SMD`). +2. Python environments: the suite's `direct` and `smd` envs. From `../../multi_robot_extension/suite_baselines/`: + `bash tools/install_conda.sh` (only without conda; installs Miniforge), then + `CONDA_BASE= bash tools/create_envs.sh direct smd` (about 7 min; the exact packages of the paper's envs). + The scripts put this package's forks first on `PYTHONPATH`, so the envs need the forks' dependencies but no + editable install of the forks (an env that has the suite's forks installed works unchanged). `hardware/` (the + hardware submission scripts, not needed for the benchmark) also needs the Robotarium simulator in the `smd` env: + `git clone https://github.com/robotarium/robotarium_python_simulator` (the paper used 3072698), then + `pip install -e robotarium_python_simulator`. +3. Data and models: + +| What | Size | Get it with | Lands in | +|---|---|---|---| +| Single-robot demonstrations per map (needed even with the shipped models: the planners rebuild their normalizer from them) | 744 MB packed, ~4 GB unpacked | included (`data_prep/demonstrations/`, the paper's): `data_prep/get_demonstrations.sh` (20 s); or a new draw, `data_prep/generate_data.sh` (CPU, 15 min; see below) | `SMD/data_trajectories/` | +| Single-robot diffusion models, one per map | 76 MB | included (`checkpoints/`, the paper's); or `train/train_models.sh [gpu] [map ...]` (about 17 min per model on an H100; then `RB_MODELS=/runs/trained_models` in paths.env and `wire_artifacts.sh`, which takes the shipped model for maps not retrained) | `checkpoints/` or `runs/trained_models/` (`RB_MODELS`) | +| Start-goal instances, 10 per (map, N) | 40 kB | included; `python data_prep/assemble_instances_v2.py` rebuilds it from its parts | `data_prep/instances/instances_v2.npz` (`RB_INSTANCES`) | + +Then `bash tools/wire_artifacts.sh`. + +## Use + +``` +bash data_prep/get_demonstrations.sh # once +bash tools/wire_artifacts.sh +bash run/run_cell.sh [gpu] # methods: direct cd final_projection pcd_shd pcd_lb mmd_cbs +bash run/run_all.sh 0,1 # all 120 cells, one per listed GPU at a time +python eval/score_cells.py # -> /runs/robotarium_cells.json +python eval/fig_robotarium_sr_one.py /runs/robotarium_cells.json robotarium_sr_one.png +``` + +The planners rebuild their normalizer from the demonstrations, and the shipped models were trained with the paper's +sets, so the paper's cells need those (`get_demonstrations.sh`). `generate_data.sh` draws a new set with the same +recipe (40000 contexts per map; the generator is not seeded): use it to retrain the models (`train/`). Planning the +shipped models on a new draw shifts the normalizer's velocity range by up to 0.5% (4% on Conveyor, whose paper set +the generator does not reproduce), so its cells need not match the published ones. + +Every cell plans the 10 instances of its (map, N) with B = 64 candidates (MMD-CBS: 64 N samples, one plan per instance, +60 s per instance). Conveyor runs on the widened geometry (`RBT_WIDE=1`, set by `run_cell.sh`) with its model retrained +there. Final Proj. names its outputs after the map's tile, which Cross-Room and Drop-Region share: do not run those two +concurrently (`run_all.sh` runs each method's maps one after another). + +## Evaluation + +`free` = no robot-robot and no robot-obstacle collision, per candidate and instance, from each run's collision masks. +SR(one) = `free[0].mean()`: the first candidate per instance, the one plan a deployment would return (the paper's +figure). SR(64) = `free.any(0).mean()`: best of 64. MMD-CBS returns one plan per instance; its SR is the runner's. +`eval/score_cells.py` reproduces the published cells: the 20 MMD-CBS cells from the runs in `eval/mmd_cbs_runs/` +(`--cells eval/mmd_cbs_runs`), and every sampling-baseline cell it can score from the original run outputs except one +PCD-SHD SR(64) value taken from a later rerun (Drop-Region, N = 12). The MMD-CBS cells were re-run on 2026-10-15 with the isotropic normalizer +(`RBT_ISO`) that the earlier version of the paper's MMD-CBS runs lacked, although the models were trained with it and the +other five baselines used it; `eval/mmd_cbs_rerun.md` gives both versions and what the fix alone changes. + +Retraining (2026-10-16, Cross-Room, `train_models.sh` on the shipped demonstrations): the same configuration and the +same train/validation split as the shipped model, weights 0.26% apart (relative L2; GPU kernels are not deterministic); +DiRecT's four Cross-Room cells on the retrained model have the published SR(one) (0.4, 0.3, 0.2, 0.3) and SR(64) +except at N = 6 (0.6; published 0.5). + +Tested end to end (2026-10-16) from a fresh `git clone` of this repository, on two H100 NVL GPUs: Miniforge, +`create_envs.sh direct smd`, `get_demonstrations.sh`, `wire_artifacts.sh`, the 120 cells through `run/run_cell.sh`, +`eval/score_cells.py`; about 2 h 40 min (the scored cells: `eval/reproduced_cells.json`). All 100 cells of DiRecT, CD, Final Proj., PCD-SHD and PCD-LB equal the +published ones in SR(one) and SR(64), except PCD-SHD's SR(64) on Drop-Region at N = 12 (0.3 here; the published 0.4 is +from a later rerun). MMD-CBS: 18 of 20 cells equal; Corridor-Bay N = 12 and Conveyor N = 3 are 0.1 higher, each from +one instance that finished in 59 s here and hit the 60 s limit (61 and 62 s) in the published run, every other +instance with the same outcome. + +## Known issues + +- **MMD-CBS has a 60 s wall-clock limit per instance**, so its success depends on the GPU and its load; run it on idle + GPUs (the published cells: one cell per idle H100 NVL). On slower or busy GPUs more instances time out. +- The demonstrations are not included (about 4 GB); `generate_data.sh` rebuilds them. + +## Outputs + +``` +/runs/cells/____N.{json,log}, jobs.log, robotarium_cells.json, models_flat/ +DiRecT/results/, PCD/multi_robot/results/ (the planners' own outputs, read by eval/score_cells.py) +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/.gitignore b/applications/multi_robot/robotarium/robotarium_baselines/SMD/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e43b0f988953ae3a84b00331d0ccf5f7d51cb3cf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/SMD/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..46061e049631179731ab339516a0f05dabf8cb69 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/LICENSE @@ -0,0 +1,83 @@ +MIT License + +Copyright (c) 2025 Jinhao Liang. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +Large portions of the included code are from/modified-from the following sources: + +1. MMD (Multi-Robot Motion Planning with Diffusion Models) + https://github.com/yoraish/mmd + Copyright (c) 2024 Yorai Shaoul, Itamar Mishani, and Shivam Vats. + +2. MPD (Motion Planning Diffusion) + https://github.com/jacarvalho/mpd-public + Copyright (c) 2022 Julen Urain + +We repeat their licenses below. + +================================================================================ + +MIT License (MMD) + +Copyright (c) 2024 Yorai Shaoul, Itamar Mishani, and Shivam Vats. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +MIT License (MPD) + +Copyright (c) 2022 Julen Urain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/README.md b/applications/multi_robot/robotarium/robotarium_baselines/SMD/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1aeca3049227f12a65ae67ae09bad2a71e4332ac --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/README.md @@ -0,0 +1,145 @@ +# Simultaneous Multi-Robot Motion Planning with Projected Diffusion Models + +**[Paper](https://arxiv.org/pdf/2502.03607)** | **[Project Page](https://multi-robot-constrained-diffusion.github.io/)** | **[arXiv](https://arxiv.org/abs/2502.03607)** + +Official implementation of "Simultaneous Multi-Robot Motion Planning with Projected Diffusion Models" + + + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Citation](#citation) + + + +## Overview + +This repository contains the official implementation of our method for **simultaneous multi-robot motion planning with projected diffusion models**. Our approach enables efficient and collision-free path planning for multiple robots operating in shared environments. + +### Key Features + +- **Scalable**: Handles 9 robots simultaneously +- **Efficient**: Leverages diffusion models with projection-based constraints +- **Flexible**: Works across various environment configurations (empty, basic, dense, rooms, shelf.) +- **Generalizable**: Handles unseen scenarios + + + +## Installation + +### System Requirements + +- **OS**: Rocky Linux 8.10 (tested), other Linux distributions should work +- **Python**: 3.8.20 +- **CUDA**: 12.1 (for GPU acceleration) + + + +### Setup Instructions + +1. **Create and activate the conda environment:** + +```bash +conda create -n smd python=3.8.20 +conda activate smd +``` + +2. **Install core dependencies:** + +```bash +conda install patchelf +pip install setuptools==70.2.0 +pip install torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu121 +pip install -r requirements.txt +``` + +3. **Install project dependencies:** + +```bash +# Install torch_robotics +cd deps/torch_robotics +pip install -e . + +# Install experiment_launcher +cd ../experiment_launcher +pip install -e . + +# Install motion_planning_baselines +cd ../motion_planning_baselines +pip install -e . + +# Install the main package +cd ../.. +pip install -e . +``` + +4. **Install IPOPT optimizer:** + +```bash +conda install -c conda-forge ipopt +``` + +5. **Download pre-trained models and data:** + +Download the data and checkpoints from [Google Drive](https://drive.google.com/file/d/1M0hfM5TlY45mzMxoyaeslvohSC2e74Yk/view?usp=sharing) and extract: + +```bash +tar -xzvf data_checkpoints.tar.gz +``` + + + +## Quick Start + +Run inference with pre-trained models: + +```bash +cd scripts/inference/ +python launch_smd_composite_experiment.py +cd ../.. +python is_collision.py +``` + +This will: + +1. Load pre-trained models for multi-robot scenarios +2. Generate motion plans for test instances +3. Evaluate collision metrics + + + +## Citation + +If you find this work useful in your research, please consider citing: + +```bibtex +@article{liang2025simultaneous, + author = {Liang, Jinhao and Christopher, Jacob K. and Koenig, Sven and Fioretto, Ferdinando}, + title = {Simultaneous Multi-Robot Motion Planning with Projected Diffusion Models}, + journal = {Forty-second International Conference on Machine Learning}, + year = {2025}, +} +``` + + + +## Acknowledgments + +This codebase is built upon [MMD (Multi-Robot Motion Planning with Diffusion Models)](https://github.com/yoraish/mmd) by Shaoul et al. We thank the authors for providing a robust foundation for multi-robot motion planning research. + +We also acknowledge the contributions from [MPD (Motion Planning Diffusion: Learning and Planning of Robot Motions with Diffusion Models)](https://github.com/jacarvalho/mpd-public) for foundational work on motion planning with diffusion models. + + + +## Contact + +For questions or issues regarding this implementation, please contact: + +**Jinhao Liang** + +Email: [jliang@email.virginia.edu](mailto:jliang@email.virginia.edu) + +Alternatively, feel free to open an issue on GitHub. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..950d9bf2182a4f5b9754dfc456063601bc968068 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Common + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/MANIFEST.in b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..746d308e20663c0a7f9dab6035b76677a4fca06e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/MANIFEST.in @@ -0,0 +1,2 @@ +include requirements.txt +include setup.py diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/Makefile b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..057f03b082ecdc7bf2c56a1bad2482411cab0210 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/Makefile @@ -0,0 +1,14 @@ +package: + python setup.py sdist + +install: package + pip install $(shell ls dist/*.tar.gz) + +all: clean install + +upload: + python -m twine upload dist/* + +clean: + rm -rf dist + rm -rf build diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/README.md b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a8dbbf1736f4787b541f801fe25848ca72c10764 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/README.md @@ -0,0 +1,111 @@ +# Experiment Launcher + +Launch experiments locally or on a cluster running SLURM in a single file. + +## Description + +The ``experiment_launcher`` package provides a simple way to run multiple experiments using SLURM or Joblib with minimum +effort - you just have to set the `local` parameter to `True` to run locally, +or to `False` to run with a cluster using SLURM. + +It is particularly useful to run multiple seeds and/or test multiple configurations of hyperparameters such as learning rates, batch size. + +## Installation + +You can do a minimal installation of ``experiment_launcher`` with: +``` +pip install -e . +``` + +## How to Use + +### Basic Usage + +The best way to understand experiment launcher is to look at the basic example in [examples/basic](examples/basic). + +- The file [examples/basic/test.py](examples/basic/test.py) contains a single experiment +- The file [examples/basic/launch_test.py](examples/basic/launch_test.py) contains the launcher file configurations and call + +**Single experiment** +- [examples/basic/test.py](examples/basic/test.py) consists of: + - The function `experiment` is the entry point of your experiment + - It takes as arguments your experiment settings (e.g., the number of layers in a neural + network, the learning rate, ...) + - The arguments need to be assigned a type and default value in the function definition + - Current accepted types are `int`, `float`, `str`, `bool`, `list` + - The arguments `seed` and `results_dir` **must** always be included + - Python kwargs can also be added as `**kwargs` (accepted types are the same as above) + - This function **must** be decorated with a decorator, e.g. `@single_experiment`. + This will take care of creating proper results directories. + + - The `if __name__ == '__main__'` block + - This **must** contain one single line: `run_experiment(experiment)` +- You can test your code by running + ```bash + cd examples/basic + python test.py + ``` + +**Launch file** +- [examples/basic/launch_test.py](examples/basic/launch_test.py) consists of: + - Creating an instance of the `Launcher` object, that contains the SLURM or Joblib (if run locally) parameters. These are some of the important parameters. For more consult the class definition. + - `exp_name` is the experiment name, under which a results directory will be created + - `exp_file` is the path to the python file where the `experiment` is implemented (without the extension `.py`) + - `project_name` is the project name in the cluster + - `n_seeds` is the number of random seeds for each single experiment configuration + - `n_exps_in_parallel` is the number of experiments to be run in parallel. This is useful to run multiple jobs in a single GPU in the cluster + - `n_cores` is the number of cores for each experiment. + Note that if `n_exps_in_parallel > 1`, then `n_exps_in_parallel` jobs will share `n_cores`. + This parameter does not apply if running locally. For that you should use the linux comamnd [taskset](https://man7.org/linux/man-pages/man1/taskset.1.html) + - `memory_per_core` is amount of memory in MB requested for each core in SLURM. If you specify this too low, SLURM might crash. + - `partition` is the SLURM partition, which is cluster dependent + - `gres` are special resources asked for a SLURM experiment + - `conda_env` if you are using a conda environment, specify its name here + - Adding experiments with `launcher.add_experiment` + - Use `launcher.add_experiment` to create an experiment for a particular configuration (e.g., different learning rates) + - Parameters that are followed by _**two trailing underscores**_ will be used to create experiment directories + - Results directories are going to be created by default as, e.g. with `param__` and `param2__` + - `./logs/exp_name_DATE/param1__value/param2__value/SEED/` + - **Be careful** when specifying the parameters and double underscores. + The launcher will throw an error if two experiments lead to the same results directory (which would be overwritten). + - To see this problem, uncomment the line `# dict(floating_arg=11.0, string_arg='test')` in [examples/basic/launch_test.py](examples/basic/launch_test.py) + - Parameters will with trailing underscores have priority over their counterparts without underscores. E.g., `env='some_env'` will be overwritten by `env__='my_env'` + - Running the experiments with `launcher.run(LOCAL)` + - This runs your experiment either locally (LOCAL: `True`) or in the cluster (LOCAL: `False`) + + +**Running the experiment** +- To run the launcher simply call + ```bash + cd examples/basic + python launch_test.py + ``` +- Log files will be placed in + - `./logs` if running locally or in the IAS cluster + - `/work/scratch/$USERNAME` if ran in the `Lichtenberg-Hochleistungsrechner of the TU Darmstadt` + + +### Integration with Weights and Biases + +The experiment launcher provides an easy way to integrate with Weights and Biases. +- In the experiment file add + - `**kwargs` in the `experiment` function definition +- In the launcher file, create wandb options and pass them to the `launcher.add_experiment()` + ```python + wandb_options = dict( + wandb_enabled=False, # If True, runs and logs to wandb. + wandb_entity='joaocorreiacarvalho', + wandb_project='experiment_launcher_test', + wandb_group='group_test' + ) + ``` + + +### Running experiments with parameters in configuration files +- If you have many parameters that you need to change, a good idea can be to use configuration files. +- If you want to specify your parameters in a configuration file, look at the example under `examples/config_files` + + +## Notes +- For reproducibility, the seeds are created sequentially from `0` to `n_exps-1`. + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..6cc1aeb0640a7d85b44aadab9a01eede04b99e0b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/launch_test.py @@ -0,0 +1,75 @@ +from itertools import product + +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 3 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + gres=GRES, + conda_env=CONDA_ENV, + use_timestamp=True, + compact_dirs=False +) + +envs = { + 'env_00': {'env_param': 'aa'}, + 'env_01': {'env_param': 'bb'} +} +a_l = [1, 2, 3] +boolean_param_l = [True, False] +some_default_param = 'b' + +# These arguments are kwargs of the experiment function +unknown_args_list = [ + dict(integer_arg=10), + # dict(floating_arg=11.0, string_arg='test') +] + +for env in envs: + d = envs[env] + for a, boolean_param in product(a_l, boolean_param_l): + for unknown_args in unknown_args_list: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + env__=env, + a__=a, + boolean_param__=boolean_param, + + env='some_env', # This value will be overwritten by env__ + + **d, + some_default_param=some_default_param, + + **unknown_args, + debug=False, + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/test.py new file mode 100644 index 0000000000000000000000000000000000000000..2572c6f2b7e2ad1673007744e53745a0cf8650fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/basic/test.py @@ -0,0 +1,47 @@ +import os + +import wandb + +from experiment_launcher import run_experiment, single_experiment + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment +def experiment( + ####################################### + env: str = 'env', # You need to specify the argument type if you use the automatic parser. + env_param: str = 'aa', + a: int = 1, + boolean_param: bool = True, + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + print(f'kwargs: {kwargs}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}, env {env}, ' \ + f'env_param {env_param}, a {a}, ' \ + f'boolean_param {boolean_param}, some_default_param {some_default_param}' + print(out_str) + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config00.yaml b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config00.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d3d16b27db8814633bb5feef1d67d81e77f38ba9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config00.yaml @@ -0,0 +1,7 @@ +network: mynetwork +layers: 3 +activations: + - relu + - relu + - tanh +lr: 1e-3 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config01.yaml b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config01.yaml new file mode 100644 index 0000000000000000000000000000000000000000..514fa3b70ac516a9b638ddd3c868f3f57789ea1d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/configs/config01.yaml @@ -0,0 +1,7 @@ +network: myothernetwork +layers: 3 +activations: + - relu + - relu + - tanh +lr: 1e-3 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..53839b855e0864f35f66cf7a91740a2cc7eb8a64 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/launch_test.py @@ -0,0 +1,65 @@ +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 3 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + conda_env=CONDA_ENV, + gres=GRES, + use_timestamp=True, + compact_dirs=False +) + +config_files_l = [ + 'configs/config00.yaml', + 'configs/config01.yaml', +] + +# Optional arguments for Weights and Biases +wandb_options = dict( + wandb_mode='disabled', # "online", "offline" or "disabled" + wandb_entity='joaocorreiacarvalho', + wandb_project='test_experiment_launcher_config_files' +) + +for i, config_file in enumerate(config_files_l): + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + config__=f'config-{str(i).zfill(len(config_files_l))}', + + config_file_path=config_file, + + debug=False, + + **wandb_options, + wandb_group=f'test_group-el-{config_file}' + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/test.py new file mode 100644 index 0000000000000000000000000000000000000000..5633ec4015b363c7ce08f8351bd5ff33f05387fd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/config_files/test.py @@ -0,0 +1,49 @@ +import os + +import wandb +import yaml + +from experiment_launcher import run_experiment, single_experiment_yaml + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment_yaml +def experiment( + ####################################### + config_file_path: str = './configs/config00.yaml', + + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + with open(config_file_path, 'r') as f: + configs = yaml.load(f, yaml.Loader) + + print('Config file content:') + print(configs) + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}' + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + wandb.log({'seed': seed}, step=1) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..bc957be14bae9364f33f96ce5a1183e7256eaa99 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/launch_test.py @@ -0,0 +1,60 @@ +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = True + +N_SEEDS = 10 + +if LOCAL: + N_EXPS_IN_PARALLEL = 2 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'rtx2,rtx' if USE_CUDA else 'amd2,amd' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + +launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=N_SEEDS, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + conda_env=CONDA_ENV, + gres=GRES, + use_timestamp=True, + compact_dirs=False +) + +tensor_sizes_l = [1000000 + i for i in range(5)] + +# Optional arguments for Weights and Biases +wandb_options = dict( + wandb_mode='disabled', # "online", "offline" or "disabled" + wandb_entity='joaocorreiacarvalho', + wandb_project='test_experiment_launcher_config_files' +) + +for tensor_size in tensor_sizes_l: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + tensor_size__=tensor_size, + + debug=False, + + **wandb_options, + wandb_group=f'test_group-el-{tensor_size}', + ) + +launcher.run(LOCAL, TEST) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py new file mode 100644 index 0000000000000000000000000000000000000000..98a3dcc41d60696e98e121edbefbe34d83bc0948 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/multi_exp_same_gpu/test.py @@ -0,0 +1,54 @@ +import os +import time + +import torch.cuda +import wandb + +from experiment_launcher import run_experiment, single_experiment_yaml + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment_yaml +def experiment( + ####################################### + env: str = 'env-name', # You need to specify the argument type if you use the automatic parser. + tensor_size: int = 10000, + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + tensor1 = torch.ones(tensor_size, device='cuda' if torch.cuda.is_available() else 'cpu') + tensor2 = tensor1.clone() + + print(f'Env: {env}') + print(f'Tensor shape: {tensor1.shape}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}' + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + wandb.log({'seed': seed}, step=1) + + end_time = time.time() + 0.01 + while time.time() < end_time: + tensor = tensor1 * tensor2 + print('Finished experiment.') + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/launch_test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/launch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..b8db2f41244087ad94780fc68731bbbb1e082d5d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/launch_test.py @@ -0,0 +1,98 @@ +import time +from itertools import product + +from experiment_launcher import Launcher, is_local + +LOCAL = is_local() +TEST = False +USE_CUDA = False + +N_SEEDS = 103 + +if LOCAL: + N_EXPS_IN_PARALLEL = 5 +else: + N_EXPS_IN_PARALLEL = 3 + +N_CORES = N_EXPS_IN_PARALLEL +MEMORY_SINGLE_JOB = 1000 +MEMORY_PER_CORE = N_EXPS_IN_PARALLEL * MEMORY_SINGLE_JOB // N_CORES +PARTITION = 'amd2,amd' # 'amd', 'rtx' +GRES = 'gpu:1' if USE_CUDA else None # gpu:rtx2080:1, gpu:rtx3080:1 +CONDA_ENV = 'el' # None + + +# E.g. SLURM only allows arrays with N_SEEDS_MAX_SLURM size +N_SEEDS_MAX_SLURM = 100 +start_seed = 0 +if LOCAL: + n_seeds_split = [N_SEEDS] +else: + if N_SEEDS <= N_SEEDS_MAX_SLURM: + n_seeds_split = [N_SEEDS] + elif N_SEEDS % N_SEEDS_MAX_SLURM == 0: + n_seeds_split = [N_SEEDS_MAX_SLURM for _ in range(N_SEEDS // N_SEEDS_MAX_SLURM)] + else: + n_seeds_split = [N_SEEDS_MAX_SLURM for _ in range(N_SEEDS // N_SEEDS_MAX_SLURM)] + [N_SEEDS % N_SEEDS_MAX_SLURM] + +for _n_seeds in n_seeds_split: + print(f"---------> Running seeds {start_seed}-{start_seed + _n_seeds}") + + time.sleep(1) + launcher = Launcher( + exp_name='test_launcher', + exp_file='test', + # project_name='project01234', # for hrz cluster + n_seeds=_n_seeds, + start_seed=start_seed, + n_exps_in_parallel=N_EXPS_IN_PARALLEL, + n_cores=N_CORES, + memory_per_core=MEMORY_PER_CORE, + days=2, + hours=23, + minutes=59, + seconds=0, + partition=PARTITION, + gres=GRES, + conda_env=CONDA_ENV, + use_timestamp=True, + compact_dirs=False + ) + + envs = { + 'env_00': {'env_param': 'aa'}, + 'env_01': {'env_param': 'bb'} + } + a_l = [1, 2, 3] + boolean_param_l = [True, False] + some_default_param = 'b' + + # These arguments are kwargs of the experiment function + unknown_args_list = [ + dict(integer_arg=10), + # dict(floating_arg=11.0, string_arg='test') + ] + + for env in envs: + d = envs[env] + for a, boolean_param in product(a_l, boolean_param_l): + for unknown_args in unknown_args_list: + launcher.add_experiment( + # A subdirectory will be created for parameters with a trailing double underscore. + env__=env, + a__=a, + boolean_param__=boolean_param, + + env='some_env', # This value will be overwritten by env__ + + **d, + some_default_param=some_default_param, + + **unknown_args, + debug=False, + ) + + launcher.run(LOCAL, TEST) + + ################################################################################################################ + start_seed += _n_seeds diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/test.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/test.py new file mode 100644 index 0000000000000000000000000000000000000000..2572c6f2b7e2ad1673007744e53745a0cf8650fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/examples/start_seed/test.py @@ -0,0 +1,47 @@ +import os + +import wandb + +from experiment_launcher import run_experiment, single_experiment + + +# This decorator creates results_dir as results_dir/seed, and saves the experiment arguments into a file. +@single_experiment +def experiment( + ####################################### + env: str = 'env', # You need to specify the argument type if you use the automatic parser. + env_param: str = 'aa', + a: int = 1, + boolean_param: bool = True, + some_default_param: str = 'b', + + debug: bool = True, + + ####################################### + # MANDATORY + seed: int = 0, + results_dir: str = 'logs', + + ####################################### + # OPTIONAL + # accept unknown arguments + **kwargs +): + # EXPERIMENT + print(f'DEBUG MODE: {debug}') + + print(f'kwargs: {kwargs}') + + filename = os.path.join(results_dir, 'log_' + str(seed) + '.txt') + out_str = f'Running experiment with seed {seed}, env {env}, ' \ + f'env_param {env_param}, a {a}, ' \ + f'boolean_param {boolean_param}, some_default_param {some_default_param}' + print(out_str) + with open(filename, 'w') as file: + file.write('Some logs in a log file.\n') + file.write(out_str) + + +if __name__ == '__main__': + # Leave unchanged + run_experiment(experiment) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/PKG-INFO b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..8be894f4b563a37a6861bcbba02311abdff59aa2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/PKG-INFO @@ -0,0 +1,28 @@ +Metadata-Version: 2.4 +Name: experiment-launcher +Version: 2.3 +Summary: A Python toolkit for launching experiments. +Home-page: https://git.ias.informatik.tu-darmstadt.de/common/experiment_launcher +Author: Davide Tateo, Joao Carvalho +Author-email: davide@robot-learning.de, joao@robot-learning.de +License: MIT +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +License-File: LICENSE +Requires-Dist: joblib +Requires-Dist: gitpython +Requires-Dist: numpy +Requires-Dist: pyyaml +Requires-Dist: wandb +Dynamic: author +Dynamic: author-email +Dynamic: classifier +Dynamic: description +Dynamic: home-page +Dynamic: license +Dynamic: license-file +Dynamic: requires-dist +Dynamic: summary + +experiment_launcher is a Python library used to run experiments. Supports Joblib and slurm jobs. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/SOURCES.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..a459b6763ceda5fc490df10a75c493c04490657a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +LICENSE +MANIFEST.in +README.md +requirements.txt +setup.py +experiment_launcher/__init__.py +experiment_launcher/decorators.py +experiment_launcher/exceptions.py +experiment_launcher/launcher.py +experiment_launcher/utils.py +experiment_launcher.egg-info/PKG-INFO +experiment_launcher.egg-info/SOURCES.txt +experiment_launcher.egg-info/dependency_links.txt +experiment_launcher.egg-info/not-zip-safe +experiment_launcher.egg-info/requires.txt +experiment_launcher.egg-info/top_level.txt \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/dependency_links.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/not-zip-safe b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/not-zip-safe new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/requires.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..812d40970d18d83b7d67ed5e0a6a882441207b01 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/requires.txt @@ -0,0 +1,5 @@ +joblib +gitpython +numpy +pyyaml +wandb diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/top_level.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..7a4d6ae8c40d3139871e0a1f6cf577e3e61423f0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher.egg-info/top_level.txt @@ -0,0 +1 @@ +experiment_launcher diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5fee850c832de1ddc9c2519b804303afa6982b48 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/__init__.py @@ -0,0 +1,8 @@ +try: + from .launcher import Launcher, run_experiment + from .decorators import single_experiment, single_experiment_yaml, single_experiment_flat + from .utils import is_local +except ImportError: + pass + +__version__ = '2.3' diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/decorators.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..41b314a9c5f7bd1d8f304675aa273f9d34f2b733 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/decorators.py @@ -0,0 +1,49 @@ +import logging +import os +from functools import wraps, partial + +from experiment_launcher.utils import save_args, start_wandb, create_results_dir + + +def wrapper_single_experiment(exp_func, save_args_yaml=False, use_logging=False, + make_dirs_with_seed=True, print_exp_args=False): + @wraps(exp_func) + def wrapper( + # Function arguments + *args, + **kwargs + ): + # Make results directory + create_results_dir(kwargs, make_dirs_with_seed) + + # Setup logging + if use_logging: + logging.basicConfig(level=logging.INFO, + filename=os.path.join(kwargs['results_dir'], "logfile"), + # stream=sys.stdout, + filemode="a+", + format="%(asctime)-15s %(levelname)-8s %(message)s") + + # Save arguments + save_args(kwargs['results_dir'], kwargs, + git_repo_path='./', save_args_as_yaml=save_args_yaml, print_exp_args=print_exp_args) + + # Start WandB + wandb_activated = True if 'wandb_mode' in kwargs else False + if wandb_activated: + wandb_run = start_wandb(**kwargs) + + # Run the experiment + exp_func(*args, **kwargs) + + # Clean up + if wandb_activated: + wandb_run.finish() + + return wrapper + + +single_experiment = partial(wrapper_single_experiment) +single_experiment_flat = partial(wrapper_single_experiment, make_dirs_with_seed=False) +single_experiment_yaml = partial(wrapper_single_experiment, save_args_yaml=True, use_logging=True, print_exp_args=False) +single_experiment_flat_yaml = partial(wrapper_single_experiment, make_dirs_with_seed=False, save_args_yaml=True) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/exceptions.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..28ecd0ff0cdc48aed94a9db352ff08e3c97f3346 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/exceptions.py @@ -0,0 +1,10 @@ +class ResultsDirException(Exception): + """ + Raised when the results directory already exists + """ + def __init__(self, exp, results_dir='./logs'): + message = f"\n" \ + f"When trying to add the experiment: {exp}\n" \ + f"Results directory {results_dir} already exists.\n" \ + f"Make sure that varying parameters have a trailing double underscore." + super().__init__(message) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/launcher.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/launcher.py new file mode 100644 index 0000000000000000000000000000000000000000..5b2a3832b8aa8587e31e6419c5e92dcb2ffcc02f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/launcher.py @@ -0,0 +1,495 @@ +import argparse +import datetime +import inspect +import os +import traceback +from copy import copy, deepcopy +from distutils.util import strtobool +from importlib import import_module + +import numpy as np +from joblib import Parallel, delayed + +from experiment_launcher.exceptions import ResultsDirException + + +class Launcher(object): + """ + Creates and starts jobs with Joblib or SLURM. + + """ + + def __init__(self, exp_name, exp_file, n_seeds, start_seed=0, n_cores=1, memory_per_core=2000, + days=0, hours=24, minutes=0, seconds=0, + project_name=None, base_dir=None, + n_exps_in_parallel=1, + conda_env=None, gres=None, constraint=None, partition=None, + begin=None, use_timestamp=True, compact_dirs=False): + """ + Constructor. + + Args: + exp_name (str): name of the experiment + exp_file (str): name of the python module running a single experiment (relative path) + n_seeds (int): number of seeds for each experiment configuration + start_seed (int): first seed + n_cores (int): number of cpu cores + memory_per_core (int): maximum memory per core (slurm will kill the job if this is reached) + days (int): number of days the experiment can last (in slurm) + hours (int): number of hours the experiment can last (in slurm) + minutes (int): number of minutes the experiment can last (in slurm) + seconds (int): number of seconds the experiment can last (in slurm) + project_name (str): name of the project for slurm. This is important if you have + different projects (e.g. in the hhlr cluster) + base_dir (str): path to directory to save the results (in hhlr results are saved to /work/scratch/$USER) + n_exps_in_parallel (int): number of experiment configurations to run in parallel. + If running in the cluster, and the gpu is selected, then it is the number of jobs in each slurm file + (e.g. for multiple experiments in the same gpu) + conda_env (str): name of the conda environment to run the experiments in + gres (str): request cluster resources. E.g. to add a GPU in the IAS cluster specify gres='gpu:rtx2080:1' + constraint (str): constraint for the slurm job. E.g. to add a GPU in the IAS cluster: constraint='rtx2080' + partition (str, None): the partition to use in case of slurm execution. If None, no partition is specified. + begin (str): start the slurm experiment at a given time (see --begin in slurm docs) + use_timestamp (bool): add a timestamp to the experiment name + compact_dirs (bool): If true, only the parameter value is used for the directory name. + + """ + self._exp_name = exp_name + self._exp_file = exp_file + self._start_seed = start_seed + self._n_seeds = n_seeds + self._n_cores = n_cores + self._memory_per_core = memory_per_core + self._duration = Launcher._to_duration(days, hours, minutes, seconds) + self._project_name = project_name + self._n_exps_in_parallel = n_exps_in_parallel + self._conda_env = conda_env + self._gres = gres + self._constraint = constraint + self._partition = partition + self._begin = begin + + self._experiment_list = list() + + if use_timestamp: + self._exp_name += datetime.datetime.now().strftime('_%Y-%m-%d_%H-%M-%S') + + base_dir = './logs' if base_dir is None else base_dir + self._exp_dir_local = os.path.join(base_dir, self._exp_name) + + # tracks the results directories + self._results_dir_l = [] + + self._exp_dir_slurm = self._exp_dir_local + if os.getenv("USER"): + scratch_dir = os.path.join('/work', 'scratch', os.getenv('USER')) + if os.path.isdir(scratch_dir): + self._exp_dir_slurm = os.path.join(scratch_dir, self._exp_name) + os.makedirs(self._exp_dir_slurm) + + # directories for slurm sbatch files and logs + self._exp_dir_slurm_files = os.path.join(self._exp_dir_slurm, "slurm_files") + self._exp_dir_slurm_logs = os.path.join(self._exp_dir_slurm, "slurm_logs") + + self._compact_dirs = compact_dirs + + def add_experiment(self, **kwargs): + self._experiment_list.append(deepcopy(kwargs)) + + def run(self, local, test=False, sequential=False): + self._check_experiments_results_directories() + if local: + if sequential: + self._run_sequential(test) + else: + self._run_local(test) + else: + self._run_slurm(test) + + self._experiment_list = list() + + def generate_slurm(self, command_line_list=None): + project_name_option = '' + partition_option = '' + begin_option = '' + gres_option = '' + constraint_option = '' + + if self._project_name: + project_name_option = '#SBATCH -A ' + self._project_name + '\n' + if self._partition: + partition_option += f'#SBATCH -p {self._partition}\n' + if self._begin: + begin_option += f'#SBATCH --begin={self._begin}\n' + if self._gres: + print(self._gres) + gres_option += '#SBATCH --gres=' + str(self._gres) + '\n' + if self._constraint: + print(self._constraint) + constraint_option += '#SBATCH --constraint=' + str(self._constraint) + '\n' + + conda_code = '' + if self._conda_env: + if os.path.exists(f'{os.getenv("HOME")}/miniconda3'): + conda_code += f'eval \"$({os.getenv("HOME")}/miniconda3/bin/conda shell.bash hook)\"\n' + elif os.path.exists(f'{os.getenv("HOME")}/anaconda3'): + conda_code += f'eval \"$({os.getenv("HOME")}/anaconda/bin/conda shell.bash hook)\"\n' + else: + raise Exception('You do not have a /home/USER/miniconda3 or /home/USER/anaconda3 directories') + conda_code += f'conda activate {self._conda_env}\n\n' + python_code = f'python {self._exp_file_path} \\' + else: + python_code = f'python3 {self._exp_file_path} \\' + + experiment_args = '\t\t' + experiment_args += r'${@: 2}' + experiment_args += ' \\' + + seed_code = f'\t\t--seed $(({self._start_seed} + $SLURM_ARRAY_TASK_ID)) \\' + result_dir_code = '\t\t--results_dir $1' + + code = f"""\ +#!/usr/bin/env bash + +############################################################################### +# SLURM Configurations + +# Optional parameters +{project_name_option}{partition_option}{begin_option}{gres_option}{constraint_option} +# Mandatory parameters +#SBATCH -J {self._exp_name} +#SBATCH -a 0-{self._n_seeds - 1} +#SBATCH -t {self._duration} +#SBATCH --ntasks 1 +#SBATCH --cpus-per-task {self._n_cores} +#SBATCH --mem-per-cpu={self._memory_per_core} +#SBATCH -o {self._exp_dir_slurm_logs}/%A_%a.out +#SBATCH -e {self._exp_dir_slurm_logs}/%A_%a.err + +############################################################################### +# Your PROGRAM call starts here +echo "Starting Job $SLURM_JOB_ID, Index $SLURM_ARRAY_TASK_ID" + +# Program specific arguments +{conda_code} + +""" + code += f"""\ +# Program specific arguments + +echo "Running scripts in parallel..." +echo "########################################################################" + +""" + for command_line in command_line_list: + code += f"""\ + +{python_code} +{seed_code} +\t\t{command_line} & + +""" + + code += f"""\ + +wait # This will wait until both scripts finish +echo "########################################################################" +echo "...done." +""" + return code + + def save_slurm(self, command_line_list=None, idx: str = None): + code = self.generate_slurm(command_line_list) + + label = f"_{idx}" if idx is not None else "" + script_name = f'slurm_{self._exp_name}{label}.sh' + full_path = os.path.join(self._exp_dir_slurm_files, script_name) + + with open(full_path, "w") as file: + file.write(code) + + return full_path + + def _run_slurm(self, test): + # Create slurm directories for sbatch and log files + os.makedirs(self._exp_dir_slurm_files) + os.makedirs(self._exp_dir_slurm_logs) + + # Generate and save slurm files + slurm_files_path_l = [] + experiment_list_chunked = [] + for i in range(0, len(self._experiment_list), self._n_exps_in_parallel): + experiment_list_chunked.append(self._experiment_list[i:i + self._n_exps_in_parallel]) + + for i, exps in enumerate(experiment_list_chunked): + command_line_l = [] + for exp in exps: + exp_new_without_underscore = self.remove_last_underscores_dict(exp) + command_line_arguments = self._convert_to_command_line(exp_new_without_underscore) + results_dir = self._generate_results_dir(self._exp_dir_slurm, exp) + command_line_l.append(f'--results_dir {results_dir} {command_line_arguments}') + slurm_files_path_l.append(self.save_slurm(command_line_l, + str(i).zfill(len(str(len(experiment_list_chunked)))))) + + # Launch slurm files in parallel + for slurm_file_path in slurm_files_path_l: + command = f"sbatch {slurm_file_path}" + if test: + print(command) + else: + os.system(command) + + def _run_local(self, test): + if not test: + os.makedirs(self._exp_dir_local, exist_ok=True) + + module = import_module(self._exp_file) + experiment = module.experiment + + if test: + self._test_experiment_local() + else: + def experiment_wrapper(params): + try: + experiment(**params) + except Exception: + print("Experiment failed with parameters:") + print(params) + traceback.print_exc() + + params_dict = get_experiment_default_params(experiment) + + Parallel(n_jobs=self._n_exps_in_parallel)(delayed(experiment_wrapper)(deepcopy(params)) + for params in self._generate_exp_params(params_dict)) + + def _run_sequential(self, test): + if not test: + os.makedirs(self._exp_dir_local, exist_ok=True) + + module = import_module(self._exp_file) + experiment = module.experiment + + if test: + self._test_experiment_local() + else: + default_params_dict = get_experiment_default_params(experiment) + + for params in self._generate_exp_params(default_params_dict): + try: + experiment(**params) + except Exception: + print("Experiment failed with parameters:") + print(params) + traceback.print_exc() + + def _check_experiments_results_directories(self): + """ + Check if the results directory produced for each experiment clash. + """ + for exp in self._experiment_list: + results_dir = self._generate_results_dir(self._exp_dir_local, exp) + # Check if the results directory already exists. + if results_dir in self._results_dir_l: + # Terminate to prevent overwriting the results directory. + raise ResultsDirException(exp, results_dir) + self._results_dir_l.append(results_dir) + + def _test_experiment_local(self): + for exp, results_dir in zip(self._experiment_list, self._results_dir_l): + for i in range(self._start_seed, self._n_seeds): + params = str(exp).replace('{', '(').replace('}', '').replace(': ', '=').replace('\'', '') + if params: + params += ', ' + print('experiment' + params + 'seed=' + str(i) + ', results_dir=' + results_dir + ')') + + def _generate_results_dir(self, results_dir, exp, n=6): + for key, value in exp.items(): + if key.endswith('__'): + if self._compact_dirs: + subfolder = str(value) + else: + subfolder = key + '_' + str(value).replace(' ', '') + subfolder = subfolder.replace('/', '-') # avoids creating subfolders if there is a slash in the name + results_dir = os.path.join(results_dir, subfolder) + return results_dir + + def _generate_exp_params(self, default_params_dict): + seeds = np.arange(self._start_seed, self._start_seed + self._n_seeds) + for exp in self._experiment_list: + params_dict = deepcopy(default_params_dict) + exp_new_without_underscore = self.remove_last_underscores_dict(exp) + params_dict.update(exp_new_without_underscore) + params_dict['results_dir'] = self._generate_results_dir(self._exp_dir_local, exp) + for seed in seeds: + params_dict['seed'] = int(seed) + yield params_dict + + @staticmethod + def remove_last_underscores_dict(exp_dict): + exp_dict_new = copy(exp_dict) + for key, value in exp_dict.items(): + if key.endswith('__'): + exp_dict_new[key[:-2]] = value + del exp_dict_new[key] + return exp_dict_new + + @staticmethod + def _convert_to_command_line(exp): + command_line = '' + for key, value in exp.items(): + new_command = '--' + key + ' ' + + if isinstance(value, list): + new_command += ' '.join(map(str, value)) + ' ' + else: + new_command += str(value) + ' ' + + command_line += new_command + + # remove last space + command_line = command_line[:-1] + + return command_line + + @staticmethod + def _to_duration(days, hours, minutes, seconds): + h = "0" + str(hours) if hours < 10 else str(hours) + m = "0" + str(minutes) if minutes < 10 else str(minutes) + s = "0" + str(seconds) if seconds < 10 else str(seconds) + + return str(days) + '-' + h + ":" + m + ":" + s + + @property + def exp_name(self): + return self._exp_name + + def log_dir(self, local=True): + if local: + return self._exp_dir_local + else: + return self._exp_dir_slurm + + @property + def _exp_file_path(self): + module = import_module(self._exp_file) + return module.__file__ + + +def get_experiment_default_params(func): + signature = inspect.signature(func) + defaults = {} + for k, v in signature.parameters.items(): + if v.default is not inspect.Parameter.empty: + defaults[k] = v.default + return defaults + + +def translate_experiment_params_to_argparse(parser, func): + annotation_to_argparse = { + 'str': str, + 'int': int, + 'float': float, + 'bool': bool, + 'list': None, + 'tuple': None, + } + arg_experiments = parser.add_argument_group('Experiment') + signature = inspect.signature(func) + for k, v in signature.parameters.items(): + if k not in ['seed', 'results_dir']: + if v.default is not inspect.Parameter.empty: + if v.annotation.__name__ in annotation_to_argparse: + if v.annotation.__name__ == 'bool': + arg_experiments.add_argument(f"--{str(k)}", type=lambda x: bool(strtobool(x)), + nargs='?', const=v.default, default=v.default) + elif v.annotation.__name__ == 'list': + arg_experiments.add_argument(f"--{str(k)}", nargs='+') + else: + arg_experiments.add_argument(f"--{str(k)}", type=annotation_to_argparse[v.annotation.__name__]) + else: + raise NotImplementedError(f'{v.annotation.__name__} not found in annotation_to_argparse.') + return parser + + +def add_launcher_base_args(parser): + arg_default = parser.add_argument_group('Default') + arg_default.add_argument('--seed', type=int) + arg_default.add_argument('--results_dir', type=str) + return parser + + +def has_kwargs(func): + signature = inspect.signature(func) + for k, v in signature.parameters.items(): + if v.kind == v.VAR_KEYWORD: + return True + + return False + + +def string_to_primitive(string): + try: + return int(string) + except ValueError: + try: + return float(string) + except ValueError: + try: + # boolean + return bool(strtobool(string)) + except ValueError: + return string + + +def parse_unknown_args(unknown): + kwargs = dict() + + key_idxs = [i for i, arg in enumerate(unknown) if arg.startswith('--')] + + if len(key_idxs) > 0: + key_n_args = [key_idxs[i+1] - 1 - key_idxs[i] for i in range(len(key_idxs)-1)] + key_n_args.append(len(unknown) - 1 - key_idxs[-1]) + + for i, idx in enumerate(key_idxs): + key = unknown[idx][2:] + n_args = key_n_args[i] + if n_args > 1: + values = list() + + for v in unknown[idx+1:idx + 1 + n_args]: + values.append(string_to_primitive(v)) + + kwargs[key] = values + + elif n_args == 1: + kwargs[key] = string_to_primitive(unknown[idx+1]) + + return kwargs + + +def parse_args(func): + parser = argparse.ArgumentParser() + + parser = translate_experiment_params_to_argparse(parser, func) + + parser = add_launcher_base_args(parser) + parser.set_defaults(**get_experiment_default_params(func)) + + if has_kwargs(func): + args, unknown = parser.parse_known_args() + kwargs = parse_unknown_args(unknown) + + args = vars(args) + args.update(kwargs) + + return args + else: + args = parser.parse_args() + return vars(args) + + +def run_experiment(func, args=None): + if not args: + args = parse_args(func) + + func(**args) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b875c8aa8e3e25e2c31af8e74906fe5e4e56aeab --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/experiment_launcher/utils.py @@ -0,0 +1,117 @@ +import datetime +import json +import os +import random +from random import randint +from shutil import which + +import git +import wandb +import yaml +from git import InvalidGitRepositoryError + + +def save_args(results_dir, args, git_repo_path=None, seed=None, save_args_as_yaml=False, print_exp_args=False): + try: + repo = git.Repo(git_repo_path, search_parent_directories=True) + args['git_hash'] = repo.head.object.hexsha + args['git_url'] = repo.remotes.origin.url + except InvalidGitRepositoryError: + args['git_hash'] = '' + args['git_url'] = '' + except ValueError: + args['git_hash'] = '' + args['git_url'] = '' + + if save_args_as_yaml: + filename = 'args.yaml' if seed is None else f'args-{seed}.yaml' + with open(os.path.join(results_dir, filename), 'w') as f: + yaml.dump(args, f, Dumper=yaml.Dumper) + else: + filename = 'args.json' if seed is None else f'args-{seed}.json' + with open(os.path.join(results_dir, filename), 'w') as f: + json.dump(args, f, indent=2) + + del args['git_hash'] + del args['git_url'] + + if print_exp_args: + print('------------------------------------------------------------------------------------') + print('--------> Experiment arguments') + print(json.dumps(args, indent=2)) + print('------------------------------------------------------------------------------------') + + +def is_local(): + return which('sbatch') is None + + +def start_wandb( + wandb_mode='disabled', + wandb_entity='experiment_launcher', + wandb_project='test_experiment_launcher', + wandb_group=None, + wandb_run_name=None, + **kwargs +): + # https://github.com/wandb/wandb/issues/3911#issuecomment-1409769887 + # workaround to make sure that wandb does not crash + os.environ["WANDB__SERVICE_WAIT"] = "600" + + modes = ['online', 'offline', 'disabled'] + assert wandb_mode in modes, f"wandb_mode must be in {modes}" + + if wandb_mode == 'disabled': + return wandb.init(mode="disabled", reinit=True) + + init = { + "mode": wandb_mode, + "entity": wandb_entity, + "project": wandb_project, + "group": wandb_group, + "name": wandb_run_name, + "reinit": True, + "notes": datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S'), + } + + return wandb.init(**init) + + +def fix_random_seed(seed): + random.seed(seed) + + try: + import numpy as np + import torch + except ImportError: + pass + + try: + np.random.seed(seed) + except NameError: + pass + + try: + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + # https://pytorch.org/docs/stable/notes/randomness.html#cuda-convolution-benchmarking + # torch.backends.cudnn.benchmark = False + except NameError: + pass + + +def random_int_with_n_digits(n): + range_start = 10 ** (n - 1) + range_end = (10 ** n) - 1 + return randint(range_start, range_end) + + +def create_results_dir(kwargs, make_dirs_with_seed=True): + seed = kwargs['seed'] + results_dir = kwargs['results_dir'] + assert results_dir is not None and seed is not None, "results_dir and seed must exist" + results_dir_new = results_dir + if make_dirs_with_seed: + results_dir_new = os.path.join(results_dir_new, str(seed)) + os.makedirs(results_dir_new, exist_ok=True) + kwargs['results_dir'] = results_dir_new diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..812d40970d18d83b7d67ed5e0a6a882441207b01 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/requirements.txt @@ -0,0 +1,5 @@ +joblib +gitpython +numpy +pyyaml +wandb diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..2807cd487f4fd29efa763a0b7033a5e694885ee7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/experiment_launcher/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup, find_packages +from codecs import open +from os import path + +from experiment_launcher import __version__ + +here = path.abspath(path.dirname(__file__)) + +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + +long_description = 'experiment_launcher is a Python library used to run experiments.' \ + ' Supports Joblib and slurm jobs.' + +setup( + name='experiment-launcher', + version=__version__, + description='A Python toolkit for launching experiments.', + long_description=long_description, + url='https://git.ias.informatik.tu-darmstadt.de/common/experiment_launcher', + author="Davide Tateo, Joao Carvalho", + author_email='davide@robot-learning.de, joao@robot-learning.de', + license='MIT', + packages=[package for package in find_packages() + if package.startswith('experiment_launcher')], + zip_safe=False, + install_requires=requires_list, + extras_require={}, + classifiers=["Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ] +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..15c5762c24a7258873ac7e30e9dfa372126ac7fc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2024 Yorai Shaoul, slightly modified original code written by An Thai Le, Joao Carvalho. +Original license below: + +Copyright (c) 2023 An Thai Le, Joao Carvalho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/README.md b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6b8c0ceb606acb75b8301f964778d50d33cfa500 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/README.md @@ -0,0 +1,54 @@ +The original repository is this one: https://github.com/anindex/motion_planning_baselines. +Their original README follows. + +# Motion Planning Baselines in PyTorch + +This library implements various Motion Planning methods. + +**NOTE**: `mp_baslines` is under heavy development and highly experimental. + +## Installation + +Simply activate your conda/Python environment and run + +```bash +pip install -e . +``` + +## Examples + +```bash +python examples/pointmass_dense_2d_CHOMP.py +python examples/pointmass_dense_2d_GPMP.py +python examples/pointmass_grid_circles_2d_Stoch-GPMP.py +python examples/pointmass_grid_circles_2d_STOMP.py +python examples/pointmass_grid_circles_2d_MPPI.py +python examples/pointmass_dense_2d_RRT_multiprocess.py +``` + +## Contact + +If you have any questions or find any bugs, please let us know: + +- [An Le](https://www.ias.informatik.tu-darmstadt.de/Team/AnThaiLe), [an@robot-learning.de](an@robot-learning.de) +- [Joao Carvalho](https://www.ias.informatik.tu-darmstadt.de/Team/JoaoCarvalho), [joao@robot-learning.de](joao@robot-learning.de) + +## Citation + +If you found this repository useful, please consider citing these references: + +```bibtex +@inproceedings{le2023accelerating, + title={Accelerating Motion Planning via Optimal Transport}, + author={Le, An T. and Chalvatzaki, Georgia and Biess, Armin and Peters, Jan}, + booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, + year={2023} +} + +@article{carvalho2023motion, + title={Motion planning diffusion: Learning and planning of robot motions with diffusion models}, + author={Carvalho, Joao and Le, An T and Baierl, Mark and Koert, Dorothea and Peters, Jan}, + journal={arXiv preprint arXiv:2308.01557}, + year={2023} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/PKG-INFO b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..41b8b560693872737188a95d079968f1eb8b826e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/PKG-INFO @@ -0,0 +1,15 @@ +Metadata-Version: 2.4 +Name: mp_baselines +Version: 0.0.0 +Summary: Motion Planning Baselines +Author: An T. Le, Joao Carvalho +Author-email: an@robot-learning.de, joao@robot-learning.de +License-File: LICENSE +Requires-Dist: numpy +Requires-Dist: matplotlib +Requires-Dist: cholespy +Dynamic: author +Dynamic: author-email +Dynamic: license-file +Dynamic: requires-dist +Dynamic: summary diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/SOURCES.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..eef608ae2e40b4f369084f1b53a7167b0110575c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/SOURCES.txt @@ -0,0 +1,9 @@ +LICENSE +README.md +setup.py +mp_baselines/__init__.py +mp_baselines.egg-info/PKG-INFO +mp_baselines.egg-info/SOURCES.txt +mp_baselines.egg-info/dependency_links.txt +mp_baselines.egg-info/requires.txt +mp_baselines.egg-info/top_level.txt \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/dependency_links.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/requires.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..b7e2c3ceecc3e5808724a5756d7d8d5da3e724ee --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/requires.txt @@ -0,0 +1,3 @@ +numpy +matplotlib +cholespy diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/top_level.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b54721b1b55c91bae0e5dc27e5e33459add8b72 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines.egg-info/top_level.txt @@ -0,0 +1 @@ +mp_baselines diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/base.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8d7e0a0b0f6464196d6c4f43043ada67ef00426d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/base.py @@ -0,0 +1,226 @@ +import abc +from abc import ABC, abstractmethod +from typing import Tuple +import torch + +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from torch_robotics.trajectory.utils import finite_difference_vector + + +class MPPlanner(ABC): + """Base class for all planners.""" + + def __init__( + self, + name: str = None, + tensor_args: dict = None, + **kwargs + ): + self.name = name + self.tensor_args = tensor_args + self._kwargs = kwargs + + @abstractmethod + def optimize(self, opt_iters: int = 1, **observation) -> Tuple[bool, torch.Tensor]: + """Plan a path from start to goal. + + Args: + opt_iters: Number of optimization iters. + observation: dict of observations. + + Returns: + success: True if a path was found. + path: Path from start to goal. + """ + pass + + def __call__(self, opt_iters: int = 1, **observation) -> Tuple[bool, torch.Tensor]: + """Plan a path from start to goal. + + Args: + start: Start position. + goal: Goal position. + + Returns: + success: True if a path was found. + path: Path from start to goal. + """ + return self.optimize(opt_iters, **observation) + + def __repr__(self): + return f"{self.name}({self._kwargs})" + + @abc.abstractmethod + def render(self, ax, **kwargs): + raise NotImplementedError + + +class OptimizationPlanner(MPPlanner): + + def __init__( + self, + name: str = 'OptimizationPlanner', + n_dof: int = None, + n_support_points: int = None, + n_interpolated_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + cost=None, + initial_particle_means=None, + multi_goal_states: torch.Tensor = None, + sigma_start_init: float = 0.001, + sigma_goal_init: float = 0.001, + sigma_gp_init: float = 10., + pos_only: bool = False, + tensor_args: dict = None, **kwargs + ): + super().__init__(name, tensor_args, **kwargs) + self.n_dof = n_dof + self.dim = 2 * self.n_dof + self.n_support_points = n_support_points + self.n_interpolated_points = n_interpolated_points + self.num_particles_per_goal = num_particles_per_goal + self.opt_iters = opt_iters + self.dt = dt + self.pos_only = pos_only + + self.start_state = start_state + self.multi_goal_states = multi_goal_states + if multi_goal_states is None: # NOTE(an): if there is no goal, we assume xere is at least one solution + self.num_goals = 1 + else: + assert multi_goal_states.ndim == 2 + self.num_goals = multi_goal_states.shape[0] + self.num_particles = self.num_goals * self.num_particles_per_goal + self.cost = cost + self.initial_particle_means = initial_particle_means + self._particle_means = None + if self.pos_only: + self.d_state_opt = self.n_dof + self.start_state = self.start_state + else: + self.d_state_opt = 2 * self.n_dof + self.start_state = torch.cat([self.start_state, torch.zeros_like(self.start_state)], dim=-1) + if self.multi_goal_states is not None: + self.multi_goal_states = torch.cat([self.multi_goal_states, torch.zeros_like(self.multi_goal_states)], dim=-1) + + self.sigma_start_init = sigma_start_init + self.sigma_goal_init = sigma_goal_init + self.sigma_gp_init = sigma_gp_init + + def get_GP_prior( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + tensor_args=None, + ): + if tensor_args is None: + tensor_args = self.tensor_args + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + self.dim, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # NOTE(sasha) Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=tensor_args, + ) + + def const_vel_trajectories( + self, + start_state, + multi_goal_states, + ): + traj_dim = (multi_goal_states.shape[0], self.n_support_points, self.dim) + state_traj = torch.zeros(traj_dim, **self.tensor_args) + mean_vel = (multi_goal_states[:, :self.n_dof] - start_state[:, :self.n_dof]) / (self.n_support_points * self.dt) + for i in range(self.n_support_points): + state_traj[:, i, :self.n_dof] = start_state[:, :self.n_dof] * (self.n_support_points - i - 1) / (self.n_support_points - 1) \ + + multi_goal_states[:, :self.n_dof] * i / (self.n_support_points - 1) + state_traj[:, :, self.n_dof:] = mean_vel.unsqueeze(1).repeat(1, self.n_support_points, 1) + return state_traj + + def get_random_trajs(self): + # force torch.float64 + tensor_args = dict(dtype=torch.float64, device=self.tensor_args['device']) + # set zero velocity for GP prior + start_state = torch.cat((self.start_state, torch.zeros_like(self.start_state)), dim=-1).to(**tensor_args) + if self.multi_goal_states is not None: + multi_goal_states = torch.cat((self.multi_goal_states, torch.zeros_like(self.multi_goal_states)), dim=-1).to(**tensor_args) + else: + multi_goal_states = None + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.n_dof * 2, + self.sigma_start_init, + start_state, + tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + tensor_args, + ) + + self.multi_goal_prior_init = [] + if multi_goal_states is not None: + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.n_dof * 2, + self.sigma_goal_init, + multi_goal_states[i], + tensor_args, + ) + ) + self._traj_dist = self.get_GP_prior( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if multi_goal_states is not None else None, + start_state, + goal_states=multi_goal_states, + tensor_args=tensor_args, + ) + particles = self._traj_dist.sample(self.num_particles_per_goal).to(**tensor_args) + self.traj_dim = particles.shape + del self._traj_dist # free memory + return particles.flatten(0, 1).to(**self.tensor_args) + + def _get_traj(self): + """ + Get position-velocity trajectory from control distribution. + """ + trajs = self._particle_means.clone() + if self.pos_only: + # Linear velocity by central finite differencing + vels = finite_difference_vector(trajs, dt=self.dt) + trajs = torch.cat((trajs, vels), dim=1) + return trajs + + def get_traj(self): + return self._get_traj() + + def _get_costs(self, state_trajectories, **observation): + if self.cost is None: + costs = torch.zeros(self.num_particles, ) + else: + costs = self.cost(state_trajectories, **observation) + return costs + + def render(self, ax, **kwargs): + raise NotImplementedError diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/chomp.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/chomp.py new file mode 100644 index 0000000000000000000000000000000000000000..aec0bea39863ccd10e423ce10a7d8513db0dc72c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/chomp.py @@ -0,0 +1,169 @@ +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import batched_weighted_dot_prod + + +class CHOMP(OptimizationPlanner): + # https://www.ri.cmu.edu/pub_files/2009/5/icra09-chomp.pdf + + def __init__( + self, + n_dof: int, + n_support_points: int, + num_particles_per_goal: int, + opt_iters: int, + dt: float, + start_state: torch.Tensor, + cost=None, + weight_prior_cost=0.1, + initial_particle_means=None, + step_size: float = 1., + grad_clip: float = .01, + multi_goal_states: torch.Tensor = None, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=10., + pos_only: bool = True, + **kwargs + ): + super(CHOMP, self).__init__(name='CHOMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + cost=cost, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=pos_only, + **kwargs) + + # CHOMP params + self.lr = step_size + self.grad_clip = grad_clip + + self._particle_means = None + # Precision matrix, shape: [ctrl_dim, n_support_points, n_support_points] + self.Sigma_inv = self._get_R_mat(dt=self.dt, n_support_points=self.n_support_points, tensor_args=self.tensor_args) + self.Sigma = torch.inverse(self.Sigma_inv) + self.reset(initial_particle_means=initial_particle_means) + + # Weight prior + self.weight_prior_cost = weight_prior_cost + + def _get_R_mat2(self): + """ + STOMP time-correlated Precision matrix. + Central finite difference velocity. + """ + upper_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=1) + lower_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=-1,) + diag = -2 * torch.eye(self.n_support_points) + A_mat = upper_diag + diag + lower_diag + A_mat = torch.cat( + (torch.zeros(1, self.n_support_points), + A_mat, + torch.zeros(1, self.n_support_points)), + dim=0, + ) + A_mat[0, 0] = 1. + A_mat[-1, -1] = 1. + A_mat = A_mat * 1./self.dt**2 + R_mat = A_mat.t() @ A_mat + return R_mat.to(**self.tensor_args) + + @classmethod + def _get_R_mat( + cls, + dt=0.01, + n_support_points=64, + tensor_args=None, + **kwargs + ): + """ + CHOMP time-correlated Precision matrix. + Backward finite difference velocity. + """ + lower_diag = -torch.diag(torch.ones(n_support_points - 1), diagonal=-1) + diag = 1 * torch.eye(n_support_points) + K_mat = diag + lower_diag + K_mat = torch.cat((K_mat, torch.zeros(1, n_support_points)), dim=0) + K_mat[-1, -1] = -1. + K_mat = K_mat * 1. / dt ** 2 + R_mat = K_mat.t() @ K_mat + + return R_mat.to(**tensor_args) + + def reset( + self, + initial_particle_means=None, + ): + # Straight line position-trajectory from start to goal with const vel + if initial_particle_means is not None: + self._particle_means = initial_particle_means.clone() + else: + self._particle_means = self.get_random_trajs() + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + + self._run_optimization(opt_iters, **observation) + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + if opt_iters is None: + opt_iters = self.opt_iters + for opt_step in range(opt_iters): + self._particle_means.requires_grad_(True) + costs = self._eval(self._particle_means, **observation) + + # Get grad + costs.sum().backward(retain_graph=True) + # For stabilizing and preventing high gradients + self._particle_means.grad.data.clamp_(-self.grad_clip, self.grad_clip) + # zeroing grad at start and goal + self._particle_means.grad.data[..., 0, :] = 0. + self._particle_means.grad.data[..., -1, :] = 0. + + # Update trajectory + self._particle_means.data.add_(-self.lr * self._particle_means.grad.data) + self._particle_means.grad.detach_() + self._particle_means.grad.zero_() + + self._particle_means = self._particle_means.detach() + + def _eval(self, x, **observation): + """ + Evaluate costs. + """ + if x.ndim == 2: + x = x.unsqueeze(0) + + # Evaluate (collision, ...) costs + costs = self._get_costs(x, **observation) + + # Add smoothness term (prior) + R_mat = self.Sigma_inv + smooth_cost = batched_weighted_dot_prod(x, R_mat, x).sum() + + costs += self.weight_prior_cost * smooth_cost + + return costs diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..438145c695f69b15a5758545104d1435d670c46d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/cost_functions.py @@ -0,0 +1,745 @@ +from abc import ABC, abstractmethod + +import einops +import numpy as np +import torch +from typing import List, Optional + +from mp_baselines.planners.chomp import CHOMP +from mp_baselines.planners.costs.factors.field_factor import FieldFactor +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor +from torch_robotics.torch_planning_objectives.fields.distance_fields import interpolate_points_v1 +from torch_robotics.torch_utils.torch_utils import batched_weighted_dot_prod +from torch_robotics.trajectory.utils import finite_difference_vector + + +class Cost(ABC): + def __init__(self, robot, n_support_points, tensor_args=None, **kwargs): + self.robot = robot + self.n_dof = robot.q_dim + self.dim = 2 * self.n_dof # position + velocity + self.n_support_points = n_support_points + + self.tensor_args = tensor_args + + def set_cost_factors(self): + pass + + def __call__(self, trajs, **kwargs): + return self.eval(trajs, **kwargs) + + @abstractmethod + def eval(self, trajs, **kwargs): + pass + + @abstractmethod + def get_linear_system(self, trajs, **kwargs): + pass + + def get_q_pos_vel_and_fk_map(self, trajs, **kwargs): + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: + N, B, H, D = trajs.shape # n_goals (or steps), batch of trajectories, length, dim + trajs = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + + q_pos = self.robot.get_position(trajs) + q_vel = self.robot.get_velocity(trajs) + H_positions = self.robot.fk_map_collision( + q_pos) # I, taskspaces, x_dim+1, x_dim+1 (homogeneous transformation matrices) + return trajs, q_pos, q_vel, H_positions + + +class CostComposite(Cost): + + def __init__( + self, + robot, + n_support_points, + cost_list, + weights_cost_l=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.cost_l = cost_list + self.weight_cost_l = weights_cost_l if weights_cost_l is not None else [1.0] * len(cost_list) + + def eval(self, trajs, trajs_interpolated=None, return_invidual_costs_and_weights=False, **kwargs): + trajs, q_pos, q_vel, H_positions = self.get_q_pos_vel_and_fk_map(trajs) + + if not return_invidual_costs_and_weights: + cost_total = 0 + for cost, weight_cost in zip(self.cost_l, self.weight_cost_l): + if trajs_interpolated is not None: + # Compute only collision costs with interpolated trajectories. + # Other costs are computed with non-interpolated trajectories, e.g. smoothness + if isinstance(cost, CostCollision): + trajs_tmp = trajs_interpolated + else: + trajs_tmp = trajs + else: + trajs_tmp = trajs + cost_tmp = weight_cost * cost(trajs_tmp, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, **kwargs) + cost_total += cost_tmp + return cost_total + else: + cost_l = [] + for cost in self.cost_l: + if trajs_interpolated is not None: + # Compute only collision costs with interpolated trajectories. + # Other costs are computed with non-interpolated trajectories, e.g. smoothness + if isinstance(cost, CostCollision): + trajs_tmp = trajs_interpolated + else: + trajs_tmp = trajs + else: + trajs_tmp = trajs + + cost_tmp = cost(trajs_tmp, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, **kwargs) + cost_l.append(cost_tmp) + + if return_invidual_costs_and_weights: + return cost_l, self.weight_cost_l + + def get_linear_system(self, trajs, n_interpolated_points=None, **kwargs): + trajs.requires_grad = True + # TODO - join fk map into one call for trajs and trajs_interp + trajs, q_pos, q_vel, H_positions = self.get_q_pos_vel_and_fk_map(trajs) + + # Upsample trajectory for finer collision checking + # Interpolate in joint space + # TODO - change from linear interpolation to GP interpolation + if n_interpolated_points is None: + trajs_interp, q_pos_interp, q_vel_interp, H_positions_interp = None, None, None, None + else: + trajs_interp = interpolate_points_v1(trajs, n_interpolated_points) + trajs_interp, q_pos_interp, q_vel_interp, H_positions_interp = self.get_q_pos_vel_and_fk_map(trajs_interp) + + batch_size = trajs.shape[0] + As, bs, Ks = [], [], [] + optim_dim = 0 + for cost, weight_cost in zip(self.cost_l, self.weight_cost_l): + A, b, K = cost.get_linear_system( + trajs, q_pos=q_pos, q_vel=q_vel, H_positions=H_positions, + trajs_interp=trajs_interp, q_pos_interp=q_pos_interp, q_vel_interp=q_vel_interp, + H_positions_interp=H_positions_interp, + **kwargs) + if A is None or b is None or K is None: + continue + optim_dim += A.shape[1] + As.append(A.detach()) + bs.append(b.detach()) + Ks.append(K.detach()) + + A = torch.cat(As, dim=1) + b = torch.cat(bs, dim=1) + K = torch.zeros(batch_size, optim_dim, optim_dim, **self.tensor_args) + offset = 0 + for i in range(len(Ks)): + dim = Ks[i].shape[1] + K[:, offset:offset + dim, offset:offset + dim] = Ks[i] + offset += dim + return A, b, K + + +class CostCollision(Cost): + + def __init__( + self, + robot, + n_support_points, + field=None, + sigma_coll=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.obst_factor = None + self.field = field + self.sigma_coll = sigma_coll + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.obst_factor = FieldFactor( + self.n_dof, + self.sigma_coll, + # [45, 50] # Can consider also only a subset of the trajectory. + [1, None] # Take the whole trajectory except for the first point. + ) + + def eval(self, trajs, q_pos=None, q_vel=None, H_positions=None, **observation): + costs = 0 + if self.field is not None: + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + H_pos = H_positions + err_obst = self.obst_factor.get_error( + trajs, + self.field, + q_pos=q_pos, + q_vel=q_vel, + H_pos=H_pos, + calc_jacobian=False, + obstacle_spheres=observation.get('obstacle_spheres', None) + ) + w_mat = self.obst_factor.K + obst_costs = w_mat * err_obst.sum(1) + costs = obst_costs + + return costs + + def get_linear_system(self, trajs, q_pos=None, q_vel=None, H_positions=None, + trajs_interp=None, q_pos_interp=None, q_vel_interp=None, H_positions_interp=None, + **observation): + A, b, K = None, None, None + if self.field is not None: + batch_size = trajs.shape[0] + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + # H_pos = link_pos_from_link_tensor(H) # get translation part from transformation matrices + H_pos = H_positions + + # Get H_obst wrt to the interpolated trajectory. This is computed inside get_error + + err_obst, H_obst = self.obst_factor.get_error( + trajs, + self.field, + q_pos=q_pos, + q_vel=q_vel, + H_pos=H_pos, + trajs_interp=trajs_interp, + q_pos_interp=q_pos_interp, + q_vel_interp=q_vel_interp, + H_pos_interp=H_positions_interp, + calc_jacobian=True, + obstacle_spheres=observation.get('obstacle_spheres', None) + ) + + A = torch.zeros(batch_size, self.n_support_points - 1, self.dim * self.n_support_points, **self.tensor_args) + A[:, :, :H_obst.shape[-1]] = H_obst + # shift each row by self.dim + idxs = torch.arange(A.shape[-1], **self.tensor_args).repeat(A.shape[-2], 1) + idxs = (idxs - torch.arange(self.dim, (idxs.shape[0] + 1) * self.dim, self.dim, **self.tensor_args).view(-1, 1)) % idxs.shape[-1] + idxs = idxs.to(torch.int64) + A = torch.gather(A, -1, idxs.repeat(batch_size, 1, 1)) + + # old code not vectorized + # https://github.com/anindex/stoch_gpmp/blob/main/stoch_gpmp/costs/cost_functions.py#L275 + + b = err_obst.unsqueeze(-1) + K = self.obst_factor.K * torch.eye((self.n_support_points - 1), **self.tensor_args).repeat(batch_size, 1, 1) + + return A, b, K + + +# class CostConstraint(Cost): +# """ +# Currently implementing a "vertex constraint." +# Avoid this configuration at this time (interval). +# """ +# def __init__( +# self, +# robot, +# n_support_points, +# q: torch.Tensor, # Shape: (n_dof,) +# traj_range: tuple, +# radius: float, +# sigma=None, +# **kwargs +# ): +# super().__init__(robot, n_support_points, **kwargs) +# self.q = q.to(**self.tensor_args) +# self.traj_range = traj_range +# self.radius = radius +# self.sigma = sigma +# +# def eval(self, trajs, **observation): +# costs = 0 +# if self.q is not None: +# q_pos = self.robot.get_position(trajs) +# q_pos = q_pos[:, self.traj_range[0]:self.traj_range[1], :] +# dist_constraint = torch.norm(q_pos - self.q, dim=-1) +# mask = dist_constraint > self.radius +# dist_constraint = torch.where(mask, torch.zeros_like(dist_constraint), dist_constraint) +# costs = self.radius - dist_constraint +# costs = costs.sum(-1) +# return costs +# +# def get_linear_system(self, trajs, **kwargs): +# pass + + +class CostConstraint(Cost): + """ + Implements multiple "vertex constraints" in a fully vectorized manner. + Each constraint is associated with a specific configuration and time interval. + """ + + def __init__( + self, + robot, + n_support_points, + q_l: List[torch.Tensor], # Length n. + traj_range_l: List[tuple], # Length n. A tuple (start, end) for each constraint. Inclusive. + radius_l: List[float], # Length n. The radius for each constraint. + is_soft: bool = False, # Sigma stands for the standard deviation of the cost. + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.qs = torch.stack([q.to(**self.tensor_args) for q in q_l], dim=0) # (n, q_dim) + self.traj_ranges = torch.tensor(traj_range_l, **self.tensor_args) # (n, 2) + self.radii = torch.tensor(radius_l, device=self.qs.device) # (n,) + self.is_soft = is_soft + + def eval(self, trajs, **observation): + q_pos = self.robot.get_position(trajs) # (B, H, q_dim) + # Get the start and end indices for each range. + start_indices = self.traj_ranges[:, 0] # (n,) + end_indices = self.traj_ranges[:, 1] # (n,) + + # Prepare a mask to extract relevant positions in the trajectory for each constraint. + mask = torch.arange(q_pos.shape[1], device=q_pos.device).unsqueeze(0).unsqueeze(0) # (1, 1, H) + mask = (mask >= start_indices.view(-1, 1, 1)) & (mask < end_indices.view(-1, 1, 1)) # (n, 1, H) + + # Repeat and reshape q_pos to match the constraints dimensions. + q_pos = q_pos.unsqueeze(0).expand(self.qs.shape[0], -1, -1, -1) # (n, B, H, q_dim) + q_pos_masked = q_pos * mask.unsqueeze(-1) # (n, B, H, q_dim) + + # Compute the distance between each position in the range and the corresponding q. + dist_constraint = torch.norm(q_pos_masked - self.qs.view(-1, 1, 1, q_pos.shape[-1]), + dim=-1) # (n, B, H) + + # Apply the radius constraints. + mask_radius = dist_constraint > self.radii.view(-1, 1, 1) # (n, B, H) + dist_constraint = torch.where(mask_radius, torch.zeros_like(dist_constraint), + dist_constraint) # Only penalize inside the radius + + # Compute the cost. + costs = (self.radii.view(-1, 1, 1) - dist_constraint).sum(dim=-1) # (n, B) + + # Sum the costs across all constraints. + total_costs = costs.sum() # (1,) + + return total_costs + + def get_linear_system(self, trajs, **kwargs): + pass + + +class CostMaxVelocity(Cost): + def __init__( + self, + robot, + n_support_points, + max_vel=None, + sigma_max_vel=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.max_vel = max_vel + self.sigma_max_vel = sigma_max_vel + + def eval(self, trajs, **observation): + costs = 0 + q_pos_trajs = self.robot.get_position(trajs) + if self.max_vel is not None: + vel = finite_difference_vector(q_pos_trajs, dt=self.robot.dt, method='central') + cost = torch.pow(vel - self.max_vel, 2) + costs = cost.sum(-1) + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostVelocityAndDirectionAlignment(Cost): + def __init__( + self, + robot, + n_support_points, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + + def eval(self, trajs, **observation): + q_direction_trajs = self.robot.get_direction_vector(trajs) + q_spatial = self.robot.get_spatial_component(trajs) + q_spatial_vel = finite_difference_vector(q_spatial, dt=self.robot.dt, method='central') + q_spatial_vel = q_spatial_vel / torch.norm(q_spatial_vel, dim=-1, keepdim=True) + cost = torch.sum(q_direction_trajs * q_spatial_vel, dim=-1) # Just dot. + costs = cost.sum(-1) + return costs + + def get_linear_system(self, trajs, **observation): + batch_size = trajs.shape[0] + dim = trajs.shape[-1] + + # Initialize the linear system components A, b, and K. + A = torch.zeros(batch_size, dim * self.n_support_points, dim * self.n_support_points, **self.tensor_args) + b = torch.zeros(batch_size, dim * self.n_support_points, 1, **self.tensor_args) + K = torch.zeros(batch_size, dim * self.n_support_points, dim * self.n_support_points, **self.tensor_args) + + # Get direction and velocity trajectories + q_direction_trajs = self.robot.get_direction_vector(trajs) + q_spatial = self.robot.get_spatial_component(trajs) + q_spatial_vel = finite_difference_vector(q_spatial, dt=self.robot.dt, method='central') + q_spatial_vel = q_spatial_vel / torch.norm(q_spatial_vel, dim=-1, keepdim=True) + + # Calculate the error as the difference between direction and normalized velocity vectors + error = q_direction_trajs - q_spatial_vel + + # Now, populate A, b, and K matrices. + # A will represent the linearized relationship between the deviation and the state + # b will represent the error between the direction and velocity + # K will represent a weighting matrix for this cost (often identity or diagonal) + + # For simplicity, we assume a diagonal weighting matrix (you can adjust based on problem setup). + K_weight = torch.eye(dim, **self.tensor_args) + K_weight = K_weight.unsqueeze(0).repeat(batch_size, self.n_support_points, 1, 1) + + # Fill in the matrices with appropriate dimensions + for i in range(self.n_support_points): + idx_start = i * dim + idx_end = (i + 1) * dim + + # Populate A matrix for this support point (Jacobian of the error wrt the state) + A[:, idx_start:idx_end, idx_start:idx_end] = K_weight[:, i] + + # Populate b with the error at this support point + b[:, idx_start:idx_end] = error[:, i].unsqueeze(-1) + + # Populate K with the weighting matrix + K[:, idx_start:idx_end, idx_start:idx_end] = K_weight[:, i] + + return A, b, K + + +class CostGP(Cost): + + def __init__( + self, + robot, + n_support_points, + start_state, + dt, + sigma_params, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.start_state = start_state + self.dt = dt + + self.sigma_start = sigma_params['sigma_start'] + self.sigma_gp = sigma_params['sigma_gp'] + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.start_prior = UnaryFactor( + self.dim, + self.sigma_start, + self.start_state, + self.tensor_args, + ) + + self.gp_prior = GPFactor( + self.n_dof, + self.sigma_gp, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + def eval(self, trajs, **observation): + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + # Start cost + err_p = self.start_prior.get_error(trajs[:, [0]], calc_jacobian=False) + w_mat = self.start_prior.K + start_costs = err_p @ w_mat.unsqueeze(0) @ err_p.transpose(1, 2) + start_costs = start_costs.squeeze() + + # GP Trajectory cost + err_gp = self.gp_prior.get_error(trajs, calc_jacobian=False) + w_mat = self.gp_prior.Q_inv[0] # repeated Q_inv + w_mat = w_mat.reshape(1, 1, self.dim, self.dim) + gp_costs = err_gp.transpose(2, 3) @ w_mat @ err_gp + gp_costs = gp_costs.sum(1) + gp_costs = gp_costs.squeeze() + + costs = start_costs + gp_costs + + return costs + + def get_linear_system(self, trajs, **observation): + batch_size = trajs.shape[0] + A = torch.zeros(batch_size, self.dim * self.n_support_points, self.dim * self.n_support_points, + **self.tensor_args) + b = torch.zeros(batch_size, self.dim * self.n_support_points, 1, **self.tensor_args) + K = torch.zeros(batch_size, self.dim * self.n_support_points, self.dim * self.n_support_points, + **self.tensor_args) + + # Start prior factor + err_p, H_p = self.start_prior.get_error(trajs[:, [0]]) + A[:, :self.dim, :self.dim] = H_p + b[:, :self.dim] = err_p + K[:, :self.dim, :self.dim] = self.start_prior.K + + # GP factors + err_gp, H1_gp, H2_gp = self.gp_prior.get_error(trajs) + + A[:, self.dim:, :-self.dim] = torch.block_diag(*H1_gp) + A[:, self.dim:, self.dim:] += torch.block_diag(*H2_gp) + b[:, self.dim:] = einops.rearrange(err_gp, "b h d 1 -> b (h d) 1") + K[:, self.dim:, self.dim:] += torch.block_diag(*self.gp_prior.Q_inv) + + # old code not vectorized + # https://github.com/anindex/stoch_gpmp/blob/main/stoch_gpmp/costs/cost_functions.py#L161 + + return A, b, K + + +class CostGPTrajectory(Cost): + + def __init__( + self, + robot, + n_support_points, + dt, + sigma_gp=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.dt = dt + + self.sigma_gp = sigma_gp + + self.set_cost_factors() + + def set_cost_factors(self): + # ========= Cost factors =============== + self.gp_prior = GPFactor( + self.n_dof, + self.sigma_gp, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + def eval(self, trajs, **observation): + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + + # GP cost + err_gp = self.gp_prior.get_error(trajs, calc_jacobian=False) + w_mat = self.gp_prior.Q_inv[0] # repeated Q_inv + w_mat = w_mat.reshape(1, 1, self.dim, self.dim) + gp_costs = err_gp.transpose(2, 3) @ w_mat @ err_gp + gp_costs = gp_costs.sum(1).squeeze() + costs = gp_costs + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostGPTrajectoryPositionOnlyWrapper(CostGPTrajectory): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def eval(self, trajs, **observation): + vel = finite_difference_vector(trajs, dt=self.dt, method='central') + trajs_tmp = torch.cat((trajs, vel), dim=-1) + return super().eval(trajs_tmp, **observation) + + +class CostSmoothnessCHOMP(Cost): + + def __init__( + self, + robot, + n_support_points, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.dt = robot.dt + + self.Sigma_inv = CHOMP._get_R_mat(dt=self.dt, n_support_points=n_support_points, **kwargs) + + def eval(self, trajs, **observation): + R_mat = self.Sigma_inv + cost = batched_weighted_dot_prod(trajs, R_mat, trajs) + return cost + + def get_linear_system(self, trajs, **observation): + pass + + +class CostJointLimits(Cost): + + def __init__( + self, + robot, + n_support_points, + eps=np.deg2rad(3), + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + + self.eps = eps + + def eval(self, trajs, **observation): + assert trajs.ndim == 3 + + # trajs = trajs.reshape(-1, self.n_support_points, self.dim) + trajs_pos = self.robot.get_position(trajs) + + idxs_lower = torch.argwhere(trajs_pos < self.robot.q_min + self.eps) + cost_lower = torch.pow( + self.robot.q_min[idxs_lower[:, 2]] + self.eps - trajs_pos[ + idxs_lower[:, 0], idxs_lower[:, 1], idxs_lower[:, 2]], + 2 + ).sum(-1) + + idxs_upper = torch.argwhere(trajs_pos > self.robot.q_max - self.eps) + cost_upper = torch.pow( + self.robot.q_max[idxs_upper[:, 2]] - self.eps - trajs_pos[ + idxs_upper[:, 0], idxs_upper[:, 1], idxs_upper[:, 2]], + 2 + ).sum(-1) + + costs = (cost_lower + cost_upper) + + return costs + + def get_linear_system(self, trajs, **observation): + pass + + +class CostGoal(Cost): + + def __init__( + self, + robot, + n_support_points, + field=None, + sigma_goal=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.field = field + self.sigma_goal = sigma_goal + + self.set_cost_factors() + + def set_cost_factors(self): + #========= Cost factors =============== + self.goal_factor = FieldFactor( + self.n_dof, + self.sigma_goal, + [-1, None] # only take last point + ) + + def eval(self, trajs, x_trajs=None, **observation): + costs = 0 + if self.field is not None: + err_obst = self.goal_factor.get_error( + trajs, + self.field, + x_trajs=x_trajs, + calc_jacobian=False, # NOTE(an): no need for grads in StochGPMP + ) + w_mat = self.goal_factor.K + obst_costs = w_mat * err_obst.sum(1) + costs = obst_costs + + return costs + + def get_linear_system(self, trajs, x_trajs=None, **observation): + A, b, K = None, None, None + if self.field is not None: + batch_size = trajs.shape[0] + A = torch.zeros(batch_size, 1, self.dim * self.n_support_points, **self.tensor_args) + err_goal, H_goal = self.goal_factor.get_error( + trajs, + self.field, + x_trajs=x_trajs, + calc_jacobian=True, + ) + A[:, :, -self.dim:(-self.dim + self.n_dof)] = H_goal + b = err_goal.unsqueeze(-1) + K = self.goal_factor.K * torch.eye(1, **self.tensor_args).repeat(batch_size, 1, 1) + return A, b, K + + +class CostGoalPrior(Cost): + + def __init__( + self, + robot, + n_support_points, + multi_goal_states=None, # num_goal x n_dim (pos + vel) + num_particles_per_goal=None, + num_samples=None, + sigma_goal_prior=None, + **kwargs + ): + super().__init__(robot, n_support_points, **kwargs) + self.multi_goal_states = multi_goal_states + self.num_goals = multi_goal_states.shape[0] + self.num_particles_per_goal = num_particles_per_goal + self.num_particles = num_particles_per_goal * self.num_goals + self.num_samples = num_samples + self.sigma_goal_prior = sigma_goal_prior + + self.set_cost_factors() + + def set_cost_factors(self): + self.multi_goal_prior = [] + # TODO: remove this for loop + for i in range(self.num_goals): + self.multi_goal_prior.append( + UnaryFactor( + self.dim, + self.sigma_goal_prior, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def eval(self, trajs, **observation): + costs = 0 + if self.multi_goal_states is not None: + x = trajs.reshape(self.num_goals, self.num_particles_per_goal * self.num_samples, self.n_support_points, + self.dim) + costs = torch.zeros(self.num_goals, self.num_particles_per_goal * self.num_samples, **self.tensor_args) + # TODO: remove this for loop + for i in range(self.num_goals): + err_g = self.multi_goal_prior[i].get_error(x[i, :, [-1]], calc_jacobian=False) + w_mat = self.multi_goal_prior[i].K + goal_costs = err_g @ w_mat.unsqueeze(0) @ err_g.transpose(1, 2) + goal_costs = goal_costs.squeeze() + costs[i] += goal_costs + costs = costs.flatten() + return costs + + def get_linear_system(self, trajs, **observation): + A, b, K = None, None, None + if self.multi_goal_states is not None: + npg = self.num_particles_per_goal + batch_size = npg * self.num_goals + x = trajs.reshape(self.num_goals, self.num_particles_per_goal, self.n_support_points, self.dim) + A = torch.zeros(batch_size, self.dim, self.dim * self.n_support_points, **self.tensor_args) + b = torch.zeros(batch_size, self.dim, 1, **self.tensor_args) + K = torch.zeros(batch_size, self.dim, self.dim, **self.tensor_args) + # TODO: remove this for loop + for i in range(self.num_goals): + err_g, H_g = self.multi_goal_prior[i].get_error(x[i, :, [-1]]) + A[i * npg: (i + 1) * npg, :, -self.dim:] = H_g + b[i * npg: (i + 1) * npg] = err_g + K[i * npg: (i + 1) * npg] = self.multi_goal_prior[i].K + + return A, b, K diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..e3e23c9b2c871e659dd4f3ee97dd28f91e9ddadf --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/field_factor.py @@ -0,0 +1,68 @@ +import torch + +""" +This file implements the FieldFactor class, which is used to compute the error of a trajectory with respect to a field. + +""" + +class FieldFactor: + + def __init__( + self, + n_dof, + sigma, + traj_range, + ): + self.sigma = sigma + self.n_dof = n_dof + self.traj_range = traj_range + self.K = 1. / (sigma**2) + + """ + Compute the error of the trajectory with respect to the field. The error is the negative log likelihood of the trajectory + """ + def get_error( + self, + q_trajs, + field, + q_pos=None, + q_vel=None, + H_pos=None, + q_trajs_interp=None, + q_pos_interp=None, + q_vel_interp=None, + H_pos_interp=None, + calc_jacobian=True, + **kwargs + ): + batch = q_trajs.shape[0] + + if H_pos is not None: + states = H_pos[:, self.traj_range[0]:self.traj_range[1]] + else: + states = q_trajs[:, self.traj_range[0]:self.traj_range[1], :self.n_dof].reshape(-1, self.n_dof) + q_pos_new = q_pos[:, self.traj_range[0]:self.traj_range[1], :] + length = q_pos_new.shape[-2] + error = field.compute_cost(q_pos_new, states, **kwargs).reshape(batch, length) + # Clip out all errors that are smaller than -0.02. + error = torch.clamp(error, min=-0.02) + + if calc_jacobian: + # compute jacobian wrt to the error of the interpolated trajectory + error_interp = error + if H_pos_interp is not None or q_trajs_interp is not None: + # interpolated trajectory + if H_pos_interp is not None: + states = H_pos_interp[:, self.traj_range[0]:self.traj_range[1]] + else: + states = q_trajs_interp[:, self.traj_range[0]:self.traj_range[1], :self.n_dof].reshape(-1, self.n_dof) + q_pos_new = q_pos_interp[:, self.traj_range[0]:self.traj_range[1], :] + length = q_pos_new.shape[-2] + error_interp = field.compute_cost(q_pos_new, states, **kwargs).reshape(batch, length) + + H = -torch.autograd.grad(error_interp.sum(), q_trajs, retain_graph=True)[0][:, self.traj_range[0]:self.traj_range[1], :self.n_dof] + error = error.detach() + field.zero_grad() + return error, H + else: + return error diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..a47cd1b6fd6b6d09d72427bd418ce434d3bacb15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/gp_factor.py @@ -0,0 +1,65 @@ +import torch + + +class GPFactor: + + def __init__( + self, + dim, + sigma, + d_t, + num_factors, + tensor_args=None, + Q_c_inv=None, + ): + self.dim = dim + self.d_t = d_t + self.tensor_args = tensor_args + self.state_dim = self.dim * 2 # position and velocity + self.num_factors = num_factors + self.idx1 = torch.arange(0, self.num_factors, device=tensor_args['device']) + self.idx2 = torch.arange(1, self.num_factors+1, device=tensor_args['device']) + self.phi = self.calc_phi() + if Q_c_inv is None: + Q_c_inv = torch.eye(dim, **tensor_args) / sigma**2 + self.Q_c_inv = torch.zeros(num_factors, dim, dim, **tensor_args) + Q_c_inv + self.Q_inv = self.calc_Q_inv() # shape: [num_factors, state_dim, state_dim] + + ## Pre-compute constant Jacobians + self.H1 = self.phi.unsqueeze(0).repeat(self.num_factors, 1, 1) + self.H2 = -1. * torch.eye(self.state_dim, **self.tensor_args).unsqueeze(0).repeat( + self.num_factors, 1, 1, + ) + + def calc_phi(self): + I = torch.eye(self.dim, **self.tensor_args) + Z = torch.zeros(self.dim, self.dim, **self.tensor_args) + phi_u = torch.cat((I, self.d_t * I), dim=1) + phi_l = torch.cat((Z, I), dim=1) + phi = torch.cat((phi_u, phi_l), dim=0) + return phi + + def calc_Q_inv(self): + m1 = 12. * (self.d_t ** -3.) * self.Q_c_inv + m2 = -6. * (self.d_t ** -2.) * self.Q_c_inv + m3 = 4. * (self.d_t ** -1.) * self.Q_c_inv + + Q_inv_u = torch.cat((m1, m2), dim=-1) + Q_inv_l = torch.cat((m2, m3), dim=-1) + Q_inv = torch.cat((Q_inv_u, Q_inv_l), dim=-2) + return Q_inv + + def get_error(self, x_traj, calc_jacobian=True): + batch, horizon = x_traj.shape[0], x_traj.shape[1] + state_1 = torch.index_select(x_traj, 1, self.idx1).unsqueeze(-1) + state_2 = torch.index_select(x_traj, 1, self.idx2).unsqueeze(-1) + error = state_2 - self.phi @ state_1 + + if calc_jacobian: + H1 = self.H1 + H2 = self.H2 + # H1 = self.H1.unsqueeze(0).repeat(batch, 1, 1, 1) + # H2 = self.H2.unsqueeze(0).repeat(batch, 1, 1, 1) + return error, H1, H2 + else: + return error diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py new file mode 100644 index 0000000000000000000000000000000000000000..e57804bac8d5740e0bca4df71b8688a86b546a38 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/mp_priors_multi.py @@ -0,0 +1,259 @@ +""" +Multi-Modal Gaussian trajectory prior. +Original source: https://github.com/sashalambert/mpc_trajopt +""" + +__author__ = "Alexander Lambert" +__license__ = "MIT" + +from copy import copy + +import torch +import torch.distributions as dist + + +class MultiMPPrior: + + def __init__( + self, + num_steps, + dt, + state_dim, + dof, + K_s_inv, + K_gp_inv, + start_state, + means=None, + K_g_inv=None, + goal_states=None, + use_numpy=False, + tensor_args=None, + ): + """ + Motion-Planning prior. + + reference: "Continuous-time Gaussian process motion planning via + probabilistic inference", Mukadam et al. (IJRR 2018) + + Parameters + ---------- + num_steps : int + Planning horizon length (not including start state). + dt : float + Time-step size. + state_dim : int + State state_dimension. + K_s_inv : Tensor + Start-state inverse covariance. Shape: [state_dim, state_dim] + K_gp_inv : Tensor + Gaussian-process single-step inverse covariance i.e. 'Q_inv'. + Assumed constant, meaning homoscedastic noise with constant step-size. + Shape: [2 * state_dim, 2 * state_dim] + start_state : Tensor + Shape: [state_dim] + (Optional) K_g_inv : Tensor + Goal-state inverse covariance. Shape: [state_dim, state_dim] + (Optional) goal_state : Tensor + Shape: [state_dim] + (Optional) dof : int + Degrees of freedom. + """ + + self.state_dim = state_dim + self.dof = dof + self.num_steps = num_steps + self.M = state_dim * (num_steps + 1) + # self.num_steps = num_steps - 1 + # self.M = state_dim * num_steps + self.tensor_args = tensor_args + self.use_numpy = use_numpy + + self.goal_directed = (goal_states is not None) + + if means is None: + self.num_modes = goal_states.shape[0] if self.goal_directed else 1 + means = self.get_const_vel_mean( + start_state, + goal_states, + dt, + num_steps, + dof) + else: + self.num_modes = means.shape[0] + + # Flatten mean trajectories + self.means = means.reshape(self.num_modes, -1) + + # TODO - Add different goal Covariances + # Assume same goal Cov. for now + Sigma_inv = self.get_const_vel_covariance( + dt, + K_s_inv, + K_gp_inv, + K_g_inv, + ) + + self.Sigma_inv = Sigma_inv + self.Sigma_invs = self.Sigma_inv.repeat(self.num_modes, 1, 1) + self.update_dist(self.means, self.Sigma_invs) + + def update_dist( + self, + means, + Sigma_invs, + ): + # Create Multi-variate Normal Distribution + self.dist = dist.MultivariateNormal( + means, + precision_matrix=Sigma_invs, + # covariance_matrix=torch.inverse(Sigma_invs), + ) + + def get_mean(self, reshape=True): + if reshape: + return self.means.clone().detach().reshape( + self.num_modes, self.num_steps + 1, self.state_dim, + ) + else: + self.means.clone().detach() + + def set_mean(self, means_new): + assert means_new.shape == self.means.shape + self.means = means_new.clone().detach() + self.update_dist(self.means, self.Sigma_invs) + + def set_Sigma_invs(self, Sigma_invs_new): + assert Sigma_invs_new.shape == self.Sigma_invs.shape + self.Sigma_invs = Sigma_invs_new.clone().detach() + self.update_dist(self.means, self.Sigma_invs) + + @classmethod + def const_vel_trajectory( + cls, + start_state, + goal_state, + dt, + num_steps, + dof, + set_initial_final_vel_to_zero=True, + tensor_args=None + ): + state_traj = torch.zeros(num_steps + 1, 2 * dof, **tensor_args) + mean_vel = (goal_state[:dof] - start_state[:dof]) / (num_steps * dt) + for i in range(num_steps + 1): + state_traj[i, :dof] = start_state[:dof] * (num_steps - i) * 1. / num_steps \ + + goal_state[:dof] * i * 1./num_steps + if set_initial_final_vel_to_zero: + # initial and final velocities are set to zero + state_traj[1:-1, dof:] = mean_vel.unsqueeze(0) + else: + state_traj[:, dof:] = mean_vel.unsqueeze(0) + return state_traj + + def get_const_vel_mean( + self, + start_state, + goal_states, + dt, + num_steps, + dof, + ): + + # Make mean goal-directed if goal_state is provided. + if self.goal_directed: + means = [] + for i in range(self.num_modes): + means.append(self.const_vel_trajectory( + start_state, + goal_states[i], + dt, + num_steps, + dof, + tensor_args=self.tensor_args + )) + return torch.stack(means, dim=0) + else: + return start_state.repeat(num_steps + 1, 1) + + # def get_const_vel_covariance( + # self, + # dt, + # K_s_inv, + # K_gp_inv, + # K_g_inv, + # precision_matrix=True, + # ): + # # Transition matrix + # Phi = torch.eye(self.state_dim, **self.tensor_args) + # Phi[:self.dof, self.dof:] = torch.eye(self.dof, **self.tensor_args) * dt + # diag_Phis = Phi + # for _ in range(self.num_steps - 1): + # diag_Phis = torch.block_diag(diag_Phis, Phi) + # + # A = torch.eye(self.M, **self.tensor_args) + # A[self.state_dim:, :-self.state_dim] += -1. * diag_Phis + # if self.goal_directed: + # b = torch.zeros(self.state_dim, self.M, **self.tensor_args) + # b[:, -self.state_dim:] = torch.eye(self.state_dim, **self.tensor_args) + # A = torch.cat((A, b)) + # + # Q_inv = K_s_inv + # for _ in range(self.num_steps): + # Q_inv = torch.block_diag(Q_inv, K_gp_inv).to(**self.tensor_args) + # if self.goal_directed: + # Q_inv = torch.block_diag(Q_inv, K_g_inv).to(**self.tensor_args) + # + # K_inv = A.t() @ Q_inv @ A + # if precision_matrix: + # return K_inv + # else: + # return torch.inverse(K_inv) + + + def get_const_vel_covariance( + self, + dt, + K_s_inv, + K_gp_inv, + K_g_inv, + precision_matrix=True, + ): + # To construct the covariance we need to use float64, because the small covariances of the start, GP and goal + # state distributions lead to very large inverse covariances during the construction of these matrices. + + tensor_args_tmp = copy(self.tensor_args) + tensor_args_tmp['dtype'] = torch.float64 + + # Transition matrix + Phi = torch.eye(self.state_dim, **tensor_args_tmp) + Phi[:self.dof, self.dof:] = torch.eye(self.dof, **tensor_args_tmp) * dt + diag_Phis = Phi + for _ in range(self.num_steps - 1): + diag_Phis = torch.block_diag(diag_Phis, Phi) + + A = torch.eye(self.M, **tensor_args_tmp) + A[self.state_dim:, :-self.state_dim] += -1. * diag_Phis + if self.goal_directed: + b = torch.zeros(self.state_dim, self.M, **tensor_args_tmp) + b[:, -self.state_dim:] = torch.eye(self.state_dim, **tensor_args_tmp) + A = torch.cat((A, b)) + + Q_inv = K_s_inv + for _ in range(self.num_steps): + Q_inv = torch.block_diag(Q_inv, K_gp_inv).to(**tensor_args_tmp) + if self.goal_directed: + Q_inv = torch.block_diag(Q_inv, K_g_inv).to(**tensor_args_tmp) + + K_inv = A.t() @ Q_inv @ A + if precision_matrix: + return K_inv.to(**self.tensor_args) + else: + return torch.inverse(K_inv).to(**self.tensor_args) + + def sample(self, num_samples): + return self.dist.sample((num_samples,)).view( + num_samples, self.num_modes, self.num_steps + 1, self.state_dim, + ).transpose(1, 0) + + def log_prob(self, x): + return self.dist.log_prob(x) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py new file mode 100644 index 0000000000000000000000000000000000000000..2dd3ca66c6e63e4efa819f3fc240815bdf1ed099 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/costs/factors/unary_factor.py @@ -0,0 +1,34 @@ +import torch +""" +A unary factor is a factor that depends on a single variable. +""" + +class UnaryFactor: + + def __init__( + self, + dim, + sigma, + mean=None, + tensor_args=None, + ): + self.sigma = sigma + if mean is None: + self.mean = torch.zeros(dim, **tensor_args) + else: + self.mean = mean + self.tensor_args = tensor_args + self.K = torch.eye(dim, **tensor_args) / sigma**2 # weight matrix + self.dim = dim + + def get_error(self, x, calc_jacobian=True): + error = self.mean - x + + if calc_jacobian: + H = torch.eye(self.dim, **self.tensor_args).unsqueeze(0).repeat(x.shape[0], 1, 1) + return error.view(x.shape[0], self.dim, 1), H + else: + return error + + def set_mean(self, x): + self.mean = x.clone().detach() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/dynamics/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/dynamics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py new file mode 100644 index 0000000000000000000000000000000000000000..b4c0520981b6a81c720d686d036fa1594f82665b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/dynamics/point.py @@ -0,0 +1,226 @@ +import numpy as np +import torch + + +class PointParticleDynamics: + def __init__( + self, + rollout_steps=None, + control_dim=2, + state_dim=2, + dt=0.01, + discount=1.0, + deterministic=True, + start_state=None, + goal_state=None, + ctrl_min: list = None, + ctrl_max: list = None, + control_type='velocity', + dyn_std=np.zeros(4,), + c_weights=None, + verbose=False, + tensor_args=None, + ): + if tensor_args is None: + tensor_args = {'device': torch.device('cpu'), 'dtype': torch.float32} + self.tensor_args = tensor_args + + self.control_dim = control_dim + if control_type == 'velocity': + self.state_dim = state_dim + elif control_type == 'acceleration': + self.state_dim = state_dim * 2 + else: + raise IOError('control_type "{}" not recognized'.format(control_type)) + if c_weights is None: + self._c_weights = { + 'pos': 10., + 'vel': 10., + 'ctrl': 0., + 'pos_T': 10., + 'vel_T': 0., + } + else: + self._c_weights = c_weights + + assert len(ctrl_min) == self.control_dim + assert len(ctrl_max) == self.control_dim + self.ctrl_min = torch.tensor(ctrl_min).to(**tensor_args) + self.ctrl_max = torch.tensor(ctrl_max).to(**tensor_args) + + self.discount_seq = self._get_discount_seq(discount, rollout_steps) + + if start_state is not None: + self.start_state = torch.from_numpy(start_state).to(**self.tensor_args) + else: + self.start_state = torch.zeros(self.state_dim, **self.tensor_args) + self.state = self.start_state.clone() + if goal_state is not None: + if isinstance(goal_state, np.ndarray): + self.goal_state = torch.from_numpy(goal_state).to(**self.tensor_args) + elif isinstance(goal_state, torch.Tensor): + self.goal_state = goal_state + else: + raise IOError + else: + self.goal_state = torch.zeros(self.state_dim, **self.tensor_args) + self.rollout_steps = rollout_steps + self.dt = dt + self.dyn_std = torch.from_numpy(dyn_std).to(**self.tensor_args) + self.control_type = control_type + self.discount = discount + self.verbose = verbose + self.deterministic = deterministic + + @property + def state(self): + return self._state.clone().detach() + + @state.setter + def state(self, value): + self._state = value + + def reset(self): + self.state = self.start_state.clone() + cost, _ = self.traj_cost( + self.state.reshape(1, 1, 1, -1), + torch.zeros(1, 1, 1, self.control_dim), + ) + return self.state, cost + + def step(self, action): + state = self.state.reshape(1, 1, -1) + action = action.reshape(1, 1, -1) + self.state = self.dynamics(state, action) + + state = state.reshape(1, 1, 1, -1) + action = action.reshape(1, 1, 1, -1) + cost, _ = self.traj_cost(state, action) + + return self.state.squeeze(), cost.squeeze() + + def dynamics( + self, + x, + u_s, + use_crn=False, + ): + num_ctrl_samples, num_state_samples = x.size(0), x.size(1) + ctrl_dim = u_s.size(-1) + # clamp controls + u = u_s.clamp(min=self.ctrl_min, max=self.ctrl_max) + + if self.deterministic: + xdot = torch.cat( + (x[..., self.state_dim:], u), + dim=2, + ) + else: + # Noise in control channel + if use_crn: + noise = self.dyn_std * torch.randn( + num_state_samples, + ctrl_dim, + **self.tensor_args, + ) + else: + noise = self.dyn_std * torch.randn( + num_ctrl_samples, + num_state_samples, + ctrl_dim, + **self.tensor_args, + ) + + u_s = u + noise + xdot = torch.cat( + (x[..., self.state_dim:], u_s), + dim=2, + ) + x_next = x + xdot * self.dt + return x_next + + def render(self, state=None, mode='human'): + pass + + def _get_discount_seq(self, discount, rollout_steps): + # Discount factors + discount_seq = torch.cumprod( + torch.ones(rollout_steps, **self.tensor_args) * discount, + dim=0, + ) + discount_seq /= discount # start with weight one + return discount_seq + + def traj_cost( + self, + X_in, U_in, + **observation + ): + """ + Implements quadratic trajectory cost. + Args + ---- + X_in : Tensor + State trajectories, of shape + [steps+1, num_ctrl_samples, num_state_samples, state dim] + U_in : Tensor + Control trajectories, of shape + [steps+1, num_ctrl_samples, num_state_samples, control dim] + Returns + ------- + cost : Tensor + Trajectory costs, of shape [num_ctrl_samples, num_state_samples] + """ + + rollout_steps, num_ctrl_samples, num_state_samples, state_dim = X_in.shape + + batch_size = num_ctrl_samples * num_state_samples + # New shape: X: [batch, steps+1, particles, state_dim] + X = X_in.view( + rollout_steps, + batch_size, + state_dim, + ).transpose(0, 1) + + U = U_in.view( + rollout_steps, + batch_size, + self.control_dim, + ).transpose(0, 1) + + goal_state = observation.get('goal_state', self.goal_state) + cost = observation.get('cost', None) + + if cost is not None: + full_traj = torch.cat((X, U), dim=-1) + energy_cost = cost.eval(full_traj).sum(-1) # sum over the horizon + else: + energy_cost = 0. + + # Distance to goal + dX = X - goal_state[..., :self.state_dim] + dX_final = dX[:, -1, :] + + # Discounted Costs + discount_seq = self.discount_seq.unsqueeze(0) + pos_cost = (torch.square(dX[..., :self.state_dim]) * self._c_weights['pos']).sum(-1) * discount_seq + vel_cost = (torch.square(dX[..., self.state_dim:self.control_dim]) * self._c_weights['vel']).sum(-1) * discount_seq + control_cost = (torch.square(U) * self._c_weights['ctrl']).sum(-1) * discount_seq + terminal_cost = (torch.square(dX_final) * self._c_weights['pos_T']).sum(-1) * discount_seq[..., -1] + + # Sum along trajectory timesteps + pos_cost = pos_cost.sum(1) + vel_cost = vel_cost.sum(1) + control_cost = control_cost.sum(1) + + if self.verbose: + print('pos_cost: {:5.4f}'.format(pos_cost.mean().detach().cpu().numpy())) + print('vel_cost: {:5.4f}'.format(vel_cost.mean().detach().cpu().numpy())) + print('control_cost: {:5.4f}'.format(control_cost.mean().detach().cpu().numpy())) + print('terminal_cost: {:5.4f}'.format(terminal_cost.mean().detach().cpu().numpy())) + if cost is not None: + print('energy_cost: {:5.4f}'.format(energy_cost.mean().detach().cpu().numpy())) + print('') + + costs = pos_cost + vel_cost + control_cost + terminal_cost + energy_cost + return costs.view(num_ctrl_samples, num_state_samples) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py new file mode 100644 index 0000000000000000000000000000000000000000..8bd86ca933ca7f8a46fb7df83548d920e3e45e29 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/gpmp2.py @@ -0,0 +1,510 @@ +# Adapted from https://github.com/anindex/stoch_gpmp +from copy import copy + +import einops +import numpy as np +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from mp_baselines.planners.costs.cost_functions import CostGP, CostGoalPrior, CostCollision, CostComposite +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor + +try: + import cholespy +except ModuleNotFoundError: + pass + +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +def build_gpmp2_cost_composite( + robot=None, + n_support_points=None, + dt=None, + start_state=None, + multi_goal_states=None, + num_particles_per_goal=None, + collision_fields=None, + extra_costs=[], + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_coll=1e-5, + sigma_goal_prior=1e-5, + num_samples: int = 64, + tensor_args=None, + **kwargs, +): + """ + Construct cost composite function for GPMP and StochGPMP + """ + cost_func_list = [] + + # Start state + GP cost + cost_sigmas = dict( + sigma_start=sigma_start, + sigma_gp=sigma_gp, + ) + start_state_zero_vel = torch.cat((start_state, torch.zeros(start_state.nelement(), **tensor_args))) + cost_gp_prior = CostGP( + robot, n_support_points, start_state_zero_vel, dt, + cost_sigmas, + tensor_args=tensor_args + ) + cost_func_list.append(cost_gp_prior) + + # Goal state cost + if multi_goal_states is not None: + multi_goal_states_zero_vel = torch.cat((multi_goal_states, torch.zeros_like(multi_goal_states)), + dim=-1).unsqueeze(0) # add batch dim for interface + cost_goal_prior = CostGoalPrior( + robot, n_support_points, multi_goal_states=multi_goal_states_zero_vel, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + sigma_goal_prior=sigma_goal_prior, + tensor_args=tensor_args + ) + cost_func_list.append(cost_goal_prior) + + # Collision costs + for collision_field in collision_fields: + cost_collision = CostCollision( + robot, n_support_points, + field=collision_field, + sigma_coll=sigma_coll, + tensor_args=tensor_args + ) + cost_func_list.append(cost_collision) + + # Other costs + if extra_costs: + cost_func_list.append(*extra_costs) + + cost_composite = CostComposite( + robot, n_support_points, cost_func_list, + tensor_args=tensor_args + ) + return cost_composite + + +class GPMP2(OptimizationPlanner): + + def __init__( + self, + robot=None, + n_dof: int = None, + n_support_points: int = None, + n_interpolated_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + step_size=1., + multi_goal_states=None, + initial_particle_means=None, + sigma_start_init=None, + sigma_start_sample=None, + sigma_goal_init=None, + sigma_goal_sample=None, + sigma_gp_init=None, + solver_params=None, + stop_criteria=None, # or None + **kwargs + ): + super(GPMP2, self).__init__( + name='GPMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=False, + robot=None, + **kwargs + ) + + self.d_state_opt = 2 * self.n_dof + + self.goal_directed = (multi_goal_states is not None) + if not self.goal_directed: + self.num_goals = 1 + else: + assert multi_goal_states.dim() == 2 + self.num_goals = multi_goal_states.shape[0] + + self.step_size = step_size + self.sigma_start_sample = sigma_start_sample + self.sigma_goal_sample = sigma_goal_sample + + self.solver_params = solver_params + + self.N = self.d_state_opt * self.n_support_points + + self._mean = None + self._weights = None + self._dist = None + + self.stop_criteria = stop_criteria + + self.robot = robot + + ############################################## + # Construct cost function + self.cost = build_gpmp2_cost_composite( + robot=robot, + n_support_points=n_support_points, + dt=dt, + start_state=start_state, + multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + **kwargs + ) + + ############################################## + # Initialize particles + self.reset(initial_particle_means=initial_particle_means) + + def reset( + self, + start_state=None, + multi_goal_states=None, + initial_particle_means=None, + ): + if start_state is not None: + self.start_state = start_state.clone() + + if multi_goal_states is not None: + self.multi_goal_states = multi_goal_states.clone() + + self.set_prior_factors() + + # Initialization particles from prior distribution + if initial_particle_means is not None: + self._particle_means = initial_particle_means + else: + self._init_dist = self.get_dist( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if self.goal_directed else None, + self.start_state, + goal_states=self.multi_goal_states, + ) + self._particle_means = self._init_dist.sample(self.num_particles_per_goal).to(**self.tensor_args) + del self._init_dist # freeing memory + self._particle_means = self._particle_means.flatten(0, 1) + + def set_prior_factors(self): + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.d_state_opt, + self.sigma_start_init, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + if self.goal_directed: + self.multi_goal_prior_init = [] + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_init, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + #========= Sampling factors =============== + self.start_prior_sample = UnaryFactor( + self.d_state_opt, + self.sigma_start_sample, + self.start_state, + self.tensor_args, + ) + + if self.goal_directed: + self.multi_goal_prior_sample = [] + for i in range(self.num_goals): + self.multi_goal_prior_sample.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_sample, + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def get_dist( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + ): + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + 2 * self.n_dof, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=self.tensor_args + ) + + def optimize( + self, + opt_iters=None, + debug=False, + **observation + ): + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + b, K = self._step(debug=debug, **observation) + + # stop criteria + if self.stop_criteria is not None: + costs = self._get_costs(b, K) + if opt_step == 0: + costs_previous = costs.clone() + continue + if torch.all(torch.abs((costs - costs_previous)/costs) < self.stop_criteria): + break + costs_previous = costs.clone() + + self.costs = self._get_costs(b, K) + + position_seq_mean = self._particle_means[..., :self.n_dof].clone() + velocity_seq_mean = self._particle_means[..., -self.n_dof:].clone() + # costs = self.costs.clone() + + self._recent_state_trajectories = position_seq_mean + self._recent_control_particles = velocity_seq_mean + + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + def _step(self, debug=False, **observation): + with TimerCUDA() as t_grad: + A, b, K = self.cost.get_linear_system( + self._particle_means, + n_interpolated_points=self.n_interpolated_points, + **observation) + # if debug: + # print(f't_grad {t_grad}') + + J_t_J, g = self._get_grad_terms( + A, b, K, + delta=self.solver_params['delta'], + trust_region=self.solver_params.get('trust_region', False), + sparse_computation=self.solver_params.get('sparse_computation', False), + sparse_computation_block_diag=self.solver_params.get('sparse_computation_block_diag', False) + ) + + with TimerCUDA() as t_solve: + d_theta = self.get_torch_solve( + J_t_J, g, + method=self.solver_params['method'], + ) + # if debug: + # print(f't_solve {t_solve}') + + d_theta = d_theta.view( + self.num_particles, + self.n_support_points, + self.d_state_opt, + ) + + self._particle_means = self._particle_means + self.step_size * d_theta + self._particle_means.detach_() + + return b, K + + def _get_grad_terms( + self, + A, b, K, + delta=0., + trust_region=False, + sparse_computation=False, + sparse_computation_block_diag=False + ): + # Levenberg - Marquardt approximation + + B, M, N = A.shape + if not sparse_computation: + # Original implementation with dense matrices + I = torch.eye(self.N, self.N, device=self.tensor_args['device'], dtype=A.dtype) + A_t_K = A.transpose(-2, -1) @ K + A_t_A = A_t_K @ A + + if not trust_region: + J_t_J = A_t_A + delta * I + else: + # J_t_J = A_t_A + delta * I * torch.diagonal(A_t_A, dim1=-2, dim2=-1).unsqueeze(-1) + # Since hessian will be averaged over particles, add diagonal matrix of the mean. + diag_A_t_A = A_t_A.mean(0) * I + J_t_J = A_t_A + delta * diag_A_t_A + g = A_t_K @ b + else: + ################################################ + # Sparse matrix with loop over batch dimension + if not sparse_computation_block_diag: + I_sparse = torch.sparse_coo_tensor( + torch.arange(0, self.N).repeat(2, 1), + torch.ones(self.N), + (self.N, self.N), + device=self.tensor_args['device'] + ) + + J_t_J_sparse_l = [] + g_sparse_l = [] + for A_, K_, b_ in zip(A, K, b): + A_t_K_sparse = torch.sparse.mm(A_.transpose(-2, -1).to_sparse(), K_.to_sparse()) + A_t_A_sparse = torch.sparse.mm(A_t_K_sparse, A_.to_sparse()) + if not trust_region: + J_t_J_sparse = A_t_A_sparse + delta * I_sparse + else: + diag_A_t_A_sparse = A_t_A_sparse.values().mean() * I_sparse + J_t_J_sparse = A_t_A_sparse + delta * diag_A_t_A_sparse + + J_t_J_sparse_l.append(J_t_J_sparse) + g_sparse = torch.sparse.mm(A_t_K_sparse, b_.to_sparse()) + g_sparse_l.append(g_sparse) + + J_t_J = torch.stack([x.to_dense() for x in J_t_J_sparse_l]) + g = torch.stack([x.to_dense() for x in g_sparse_l]) + else: + ################################################ + # Sparse matrix with block diagonal, then extract block diagonals + # TODO - ATTENTION! + # This implementation takes a lot of memory because of the block diagonal construction. + I_sparse = torch.sparse_coo_tensor( + torch.arange(0, B * self.N).repeat(2, 1), + torch.ones(B * self.N), + (B * self.N, B * self.N), + device=self.tensor_args['device'] + ) + A_block_diag_sparse = torch.block_diag(*A).to_sparse() + A_t_block_diag_sparse = torch.block_diag(*A.transpose(-2, -1)).to_sparse() + K_block_diag_sparse = torch.block_diag(*K).to_sparse() + + A_t_K_sparse = torch.sparse.mm(A_t_block_diag_sparse, K_block_diag_sparse) + A_t_A_sparse = torch.sparse.mm(A_t_K_sparse, A_block_diag_sparse) + if not trust_region: + J_t_J_sparse = A_t_A_sparse + delta * I_sparse + else: + raise NotImplementedError + # TODO - compute sparse version of diag_A_t_A_sparse + # diag_A_t_A_sparse = A_t_A.mean(0) * I_sparse + J_t_J_sparse = A_t_A_sparse + delta * diag_A_t_A_sparse + + J_t_J_block_diag = J_t_J_sparse.to_dense() + g_sparse = torch.sparse.mm(A_t_K_sparse, torch.block_diag(*b).to_sparse()) + g_block_diag = g_sparse.to_dense() + + # TODO - remove for loop to extract block diagonals + J_t_J = torch.stack([J_t_J_block_diag[i*N:(i+1)*N, i*N:(i+1)*N] for i in range(B)]) + g = torch.stack([g_block_diag[i * N:(i + 1) * N, i].unsqueeze(-1) for i in range(B)]) + + return J_t_J, g + + def get_torch_solve( + self, + A, b, + method, + ): + if method == 'inverse': + res = torch.linalg.solve(A, b) + elif method == 'cholesky': + # method 1 + # old implementation - recheck torch.allclose(res, torch.linalg.solve(A, b)) + # l = torch.linalg.cholesky(A) + # z = torch.linalg.solve_triangular(l, b, upper=False) + # res = torch.linalg.solve_triangular(l.mT, z, upper=False) + + # method 2 + # z = torch.triangular_solve(b, l, transpose=False, upper=False)[0] + # res = torch.triangular_solve(z, l, transpose=True, upper=False)[0] + + # method 3 + l, _ = torch.linalg.cholesky_ex(A) + res = torch.cholesky_solve(b, l) + + elif method == 'cholesky-sparse': + # TODO - ATTENTION! + # This implementation takes a lot of memory because of the block diagonal construction. + raise NotImplementedError + # if self.tensor_args['dtype'] == torch.float32: + # cholesky_fn = cholespy.CholeskySolverF + # elif self.tensor_args['dtype'] == torch.float64: + # cholesky_fn = cholespy.CholeskySolverD + # else: + # raise NotImplementedError + + # convert batch to block diagonal - https://github.com/rgl-epfl/cholespy/issues/26 + # TODO - creating a big block diagonal matrix leads to memory problems + A_block_diag = torch.block_diag(*A) + b_stacked = einops.rearrange(b, "b d 1 -> (b d) 1") + + A_sparse = A_block_diag.to_sparse(layout=torch.sparse_coo) + + cholesky_fn = cholespy.CholeskySolverF + solver = cholesky_fn( + A_sparse.size()[0], + A_sparse.indices()[0], A_sparse.indices()[1], A_sparse.values(), + cholespy.MatrixType.COO + ) + res_ = torch.zeros_like(b_stacked) + + solver.solve(b_stacked, res_) + + # batchify results + res = einops.rearrange(res_, "(b d) 1 -> b d 1", b=A.shape[0]) + + elif method == 'lstq': + # empirically slower + res = torch.linalg.lstsq(A, b)[0] + else: + raise NotImplementedError + + return res + + def _get_costs(self, errors, w_mat): + costs = errors.transpose(1, 2) @ w_mat.unsqueeze(0) @ errors + return costs.reshape(self.num_particles,) + + def get_recent_samples(self): + vel = self._recent_control_particles.detach().clone() + vel = einops.rearrange(vel, '(m b) h d -> m b h d', m=self.num_goals) + pos = self._recent_state_trajectories.detach().clone() + pos = einops.rearrange(pos, '(m b) h d -> m b h d', m=self.num_goals) + # pos_mean = self._particle_means[..., :self.n_dof].detach().clone() + # vel_mean = self._particle_means[..., -self.n_dof:].detach().clone() + + return ( + pos, + vel, + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..8a1226866f4a755283e4fd68dad87ab52e9a724c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/hybrid_planner.py @@ -0,0 +1,129 @@ +import einops +import torch +from typing import List + +from mp_baselines.planners.base import MPPlanner +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import tensor_linspace_v1 +from torch_robotics.trajectory.utils import smoothen_trajectory, finite_difference_vector, smoothen_trajectory_with_bc + +class HybridPlanner(MPPlanner): + """ + Runs a sampled-based planner to get an initial trajectory in position, followed by an optimization-based planner + to optimize for positions and velocities. + """ + + def __init__( + self, + sample_based_planners: List, + opt_based_planner, + **kwargs + ): + super().__init__( + "HybridSampleAndOptimizationPlanner", + **kwargs + ) + + # A list of sampling based planners. Those already encode the start and goal states. + # Each planner is between different start and goal states. + # If there are more than one planners there, then the paths are stacked (path1, path2, ..., pathN). + self.sample_based_planners = sample_based_planners + self.opt_based_planner = opt_based_planner + + def render(self, ax, **kwargs): + raise NotImplementedError + + def optimize(self, debug=False, print_times=False, return_iterations=False, **kwargs): + with TimerCUDA() as t_hybrid: + ################################################# + # Get initial position solutions with a sample-based planner + # Optimize in parallel + with TimerCUDA() as t_sample_based: + if len(self.sample_based_planners) > 1: + trajs_l = [] + for planner in self.sample_based_planners: + traj_l_planner = planner.optimize(refill_samples_buffer=True, debug=debug, **kwargs) + + # Replace any None entry with a linear interpolation. + traj_l = [] + for traj in traj_l_planner: + if traj is not None: + traj_l.append(traj) + else: + # If no solution was found, create a linear interpolated trajectory between start and + # finish, even if is not collision-free + traj = tensor_linspace_v1( + planner.start_state_pos, planner.goal_state_pos, steps=10).T + traj_l.append(traj) + trajs_l.append(traj_l) + + traj_l = [torch.cat([trajs_l[p][t] for p in range(len(trajs_l))]) for t in range(len(trajs_l[0]))] + + else: + traj_l = self.sample_based_planners[0].optimize(refill_samples_buffer=True, debug=debug, **kwargs) + + if debug or print_times: + print(f'Sample-based Planner -- Optimization time: {t_sample_based.elapsed:.3f} sec') + + ################################################# + # Interpolate initial trajectory to desired trajectory length, smooth and set average velocity + traj_pos_vel_l = [] + for traj in traj_l: + # If no solution was found, create a linear interpolated trajectory between start and finish, even + # if is not collision-free + if traj is None: + traj = tensor_linspace_v1( + self.sample_based_planners[0].start_state_pos, self.sample_based_planners[-1].goal_state_pos, + steps=self.opt_based_planner.n_support_points).T + if 'bc_grad_start' in kwargs and 'bc_grad_goal' in kwargs: + print("=====\nUsing bc_grad_start and bc_grad_goal") + traj_pos, traj_vel = smoothen_trajectory_with_bc( + traj, grad_start=kwargs['bc_grad_start'], grad_goal=kwargs['bc_grad_goal'], + n_support_points=self.opt_based_planner.n_support_points, dt=self.opt_based_planner.dt, + set_average_velocity=True, zero_velocity=False, tensor_args=self.tensor_args + ) + else: + traj_pos, traj_vel = smoothen_trajectory( + traj, n_support_points=self.opt_based_planner.n_support_points, dt=self.opt_based_planner.dt, + set_average_velocity=True, zero_velocity=False, tensor_args=self.tensor_args + ) + + # Reshape for gpmp/sgpmp interface + initial_traj_pos_vel = torch.cat((traj_pos, traj_vel), dim=-1) + + traj_pos_vel_l.append(initial_traj_pos_vel) + + initial_traj_pos_vel = torch.stack(traj_pos_vel_l) + # TODO - now it only accepts 1 goal + initial_traj_pos_vel = einops.rearrange(initial_traj_pos_vel, 'n h d -> 1 n h d') + + ################################################# + # Fine tune with an optimization-based planner + + # set initial position and velocity trajectory + self.opt_based_planner.reset(initial_particle_means=initial_traj_pos_vel) + + # Optimize + trajs_0 = self.opt_based_planner.get_traj() + trajs_iters = torch.empty((self.opt_based_planner.opt_iters + 1, *trajs_0.shape), **self.tensor_args) + trajs_iters[0] = trajs_0 + with TimerCUDA() as t_opt_based: + for i in range(self.opt_based_planner.opt_iters): + trajs = self.opt_based_planner.optimize(opt_iters=1, debug=debug, **kwargs) + trajs_iters[i + 1] = trajs + if debug or print_times: + print(f'Optimization-based Planner -- Optimization time: {t_opt_based.elapsed:.3f} sec') + if debug or print_times: + print(f'Hybrid-based Planner -- Optimization time: {t_hybrid.elapsed:.3f} sec') + + # Show the pre-optimization traj and the post-optimization traj + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots(1, 1) + # ax.plot(initial_traj_pos_vel[0, 0, :, 0].cpu().numpy(), initial_traj_pos_vel[0, 0, :, 1].cpu().numpy(), 'r') + # ax.plot(trajs_iters[-1][0, :, 0].cpu().numpy(), trajs_iters[-1][0, :, 1].cpu().numpy(), 'b') + # plt.show() + + if return_iterations: + return trajs_iters + else: + return trajs_iters[-1] diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..da16dccd5a42f494e1fb287505982e0a8be0e2b0 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/identity_planner.py @@ -0,0 +1,58 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import torch + +# Project imports. +from mp_baselines.planners.base import MPPlanner + + +class IdentityPlanner(MPPlanner): + + def __init__( + self, + fixed_path: torch.Tensor = None, # Shape (num_points, 2). + tensor_args: dict = None, + **kwargs + ): + assert fixed_path is not None + + super().__init__(name="IdentityPlanner", tensor_args=tensor_args) + + self.start_state_pos = fixed_path[0] + self.goal_state_pos = fixed_path[-1] + self.fixed_path = fixed_path + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + return self.fixed_path + + def render(self, ax, **kwargs): + ax.plot(self.fixed_path[:, 0], self.fixed_path[:, 1], color='r', linewidth=2, marker='o') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/mppi.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/mppi.py new file mode 100644 index 0000000000000000000000000000000000000000..5fcbea27995f98a2b37d307fcf6725c8f430e283 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/mppi.py @@ -0,0 +1,213 @@ +import torch +from mp_baselines.planners.priors.gaussian import get_multivar_gaussian_prior +from mp_baselines.planners.base import MPPlanner + + +class MPPI(MPPlanner): + + def __init__( + self, + system, + num_ctrl_samples, + rollout_steps, + opt_iters, + control_std=None, + initial_mean=None, + step_size=1., + temp=1., + cov_prior_type='indep_ctrl', + tensor_args=None, + **kwargs + ): + super(MPPI, self).__init__(name='MPPI', tensor_args=tensor_args) + self.system = system + self.state_dim = system.state_dim + self.control_dim = system.control_dim + self.rollout_steps = rollout_steps + self.num_ctrl_samples = num_ctrl_samples + self.opt_iters = opt_iters + + self.step_size = step_size + self.temp = temp + self._mean = torch.zeros( + self.rollout_steps, + self.control_dim, + **self.tensor_args, + ) + self.control_std = control_std + self.cov_prior_type = cov_prior_type + self.weights = None + + self.ctrl_dist = get_multivar_gaussian_prior( + control_std, + rollout_steps, + self.control_dim, + Cov_type=cov_prior_type, + mu_init=self._mean, + tensor_args=self.tensor_args, + ) + + Cov_inv = [] + for i in range(self.control_dim): + Cov_inv.append(self.ctrl_dist.Cov[..., i].inverse()) + self.Cov_inv = torch.stack(Cov_inv) + self.best_cost = torch.inf + self.reset(initial_mean=initial_mean) + + def reset(self, initial_mean=None): + if initial_mean is not None: + self._mean = initial_mean.clone() + else: + self._mean = torch.zeros( + self.rollout_steps, + self.control_dim, + **self.tensor_args, + ) + self.update_ctrl_dist() + + def update_ctrl_dist(self): + # Update mean + self.ctrl_dist.update_means(self._mean) + + def update_controller(self, costs, U_sampled): + weights = torch.softmax( + -costs / self.temp, + dim=0, + ) + self.weights = weights.clone() + + weights = weights.reshape(-1, 1, 1) + self._mean.add_( + self.step_size * ( + weights * (U_sampled - self._mean.unsqueeze(0)) + ).sum(0) + ) + + self.update_ctrl_dist() + + def sample_and_eval(self, **observation): + # state_trajectories = torch.empty( + # self.num_ctrl_samples, + # self.rollout_steps, # self.rollout_steps + 1, + # self.state_dim, + # **self.tensor_args, + # ) + # + # # Sample control sequences + # control_samples = self.ctrl_dist.sample(self.num_ctrl_samples) + # + # # Roll-out dynamics + # state_trajectories[:, 0] = observation['state'] + # for i in range(self.rollout_steps - 1): + # state_trajectories[:, i+1] = self.system.dynamics( + # state_trajectories[:, i].unsqueeze(1), + # control_samples[:, i].unsqueeze(1), + # ).squeeze(1) + # self.state_trajectories = state_trajectories.clone() + + + # Sample control sequences + control_samples = self.ctrl_dist.sample(self.num_ctrl_samples) + # Rollout dynamics + state_trajectories = self.get_state_trajectories_rollout( + controls=control_samples, num_ctrl_samples=self.num_ctrl_samples, **observation + ) + self.state_trajectories = state_trajectories.clone() + + # Evaluate costs + self.costs = self.system.traj_cost( + state_trajectories.transpose(0, 1).unsqueeze(2), + control_samples.transpose(0, 1).unsqueeze(2), + **observation + ) + + # Add cost from importance-sampling ratio + for i in range(self.control_dim): + V = control_samples[..., i] + U = self._mean[..., i] + self.costs += self.temp * (V @ self.Cov_inv[i] @ U).reshape(-1, 1) + + return ( + control_samples, + state_trajectories, + self.costs + ) + + def optimize( + self, + opt_iters=None, + **observation + ): + + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + with torch.no_grad(): + (control_samples, + state_trajectories, + costs) = self.sample_and_eval(**observation) + self._save_best() + self.update_controller(costs, control_samples) + self._mean = self._mean.detach() + + self._recent_control_samples = control_samples + self._recent_state_trajectories = state_trajectories + self._recent_weights = self.weights + + return ( + control_samples, + state_trajectories, + costs + ) + + def _save_best(self): + best_cost = torch.min(self.costs) + idx = torch.argmin(self.costs) + if best_cost < self.best_cost: + self.best_cost = best_cost + self.best_traj = self.state_trajectories[idx,...] + + def pop(self): + action = self._mean[0, :].clone().detach() + self.shift() + return action + + def shift(self): + self._mean = self._mean.roll(shifts=-1, dims=-1) + self._mean[-1:] = 0. + + def get_recent_samples(self): + return ( + self._recent_control_samples.detach().clone(), + self._recent_state_trajectories.detach().clone(), + self._recent_weights.detach().clone() + ) + + def get_mean_controls(self): + return self._mean + + def get_state_trajectories_rollout(self, controls=None, num_ctrl_samples=None, **observation): + state_trajectories = torch.empty( + 1 if num_ctrl_samples is None else num_ctrl_samples, + self.rollout_steps, # self.rollout_steps + 1, + self.state_dim, + **self.tensor_args, + ) + + if controls is None: + control_samples = self._mean.unsqueeze(0) + else: + control_samples = controls + + # Roll-out dynamics + state_trajectories[:, 0] = observation['state'] + for i in range(self.rollout_steps - 1): + state_trajectories[:, i+1] = self.system.dynamics( + state_trajectories[:, i].unsqueeze(1), + control_samples[:, i].unsqueeze(1), + ).squeeze(1) + return state_trajectories.clone() + + def render(self, ax, **kwargs): + raise NotImplementedError diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py new file mode 100644 index 0000000000000000000000000000000000000000..132c6de4d27d661a5dbf6570fa0a676779512a87 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_processing.py @@ -0,0 +1,29 @@ +import torch.multiprocessing as mp + + +class MultiProcessor: + # Adapated from https://stackoverflow.com/a/45829852 + + def __init__(self, max_processes=-1, start_method='forkserver', **kwargs): + mp.set_start_method(start_method) + self.pool = mp.Pool(processes=mp.cpu_count() if max_processes == -1 else max_processes) + self.async_results = [] + + @staticmethod + def _wrapper(func, args, kwargs): + ret = func(*args, **kwargs) + return ret + + def run(self, func, *args, **kwargs): + args2 = [func, args, kwargs] + async_res = self.pool.apply_async(self._wrapper, args=args2) + self.async_results.append(async_res) + + def wait(self): + self.pool.close() + self.pool.join() + res_l = [] + for async_res in self.async_results: + res = async_res.get() + res_l.append(res) + return res_l diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..7cd219b95a1a5effd94aa0861877578da12ec013 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/multi_sample_based_planner.py @@ -0,0 +1,42 @@ +from copy import copy + +from mp_baselines.planners.multi_processing import MultiProcessor + + +class MultiSampleBasedPlanner: + + def __init__( + self, + planner, + n_trajectories=2, + optimize_sequentially=False, + **kwargs + ): + self.planner = planner + self.n_trajectories = n_trajectories + self.multi_processer = None + if not optimize_sequentially: + self.planners_l = [copy(planner) for _ in range(n_trajectories)] + self.multi_processer = MultiProcessor(**kwargs) + + def optimize(self, **kwargs): + if self.multi_processer is not None: + # optimize in parallel + for p in self.planners_l: # queue up multiple tasks + self.multi_processer.run(p.optimize, **kwargs) + trajs_l = self.multi_processer.wait() # get all results + else: + # optimize sequentially + trajs_l = [] + for _ in range(self.n_trajectories): # queue up multiple tasks + traj = self.planner.optimize(**kwargs) + trajs_l.append(traj) + return trajs_l + + @property + def start_state_pos(self): + return self.planner.start_state_pos + + @property + def goal_state_pos(self): + return self.planner.goal_state_pos diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/priors/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/priors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py new file mode 100644 index 0000000000000000000000000000000000000000..44cda3399f52e58c9eca22c9f56e533d66b9a686 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/priors/gaussian.py @@ -0,0 +1,333 @@ +from abc import ABC, abstractmethod +import torch +import torch.distributions as dist +import numpy as np + + +class GMM: + def __init__( + self, + means, + sigmas, + weights, + ): + """ + Parameters + ---------- + means : + shape: [num_particles, steps, ctrl_dim] + sigmas : + shape: [num_particles, steps, ctrl_dim] + weights : + shape: [num_particles] + """ + self.num_particles, self.rollout_steps, self.ctrl_dim = means.shape + components = dist.Independent(dist.Normal(means, sigmas), 2) + mixture = dist.Categorical(weights) + self.dist = dist.mixture_same_family.MixtureSameFamily(mixture, components) + + def sample(self, num_samples): + return self.dist.sample(num_samples).transpose(0, 1) + + def log_prob(self, x): + return self.dist.log_prob(x) + + +def avg_ctrl_to_goal( + state, + target, + rollout_steps, + dt, + max_ctrl=100, + control_type='velocity', +): + """ Average control from state to target. Control must be + first/second-derivative in state/target space. Ex. velocity control for + cartesian states""" + assert control_type in [ + 'velocity', + 'acceleration', + ] + if control_type == 'velocity': + return ( + (target - state)/ rollout_steps / dt + ).clamp(max=max_ctrl) + else: + state_dim = state.dim() + pos_dim = int(state_dim / 2) + return ( + (target[:pos_dim] - state[:pos_dim]) / rollout_steps / dt**2 + ).clamp(max=max_ctrl) + + +def get_indep_gaussian_prior( + sigma_init, + rollout_steps, + control_dim, + mu_init=None, +): + + mu = torch.zeros( + rollout_steps, + control_dim, + ) + if mu_init is not None: + mu[:, :] = mu_init + + sigma = torch.ones( + rollout_steps, + control_dim, + ) * sigma_init + + return dist.Normal(mu, sigma) + + +def get_multivar_gaussian_prior( + sigma, + rollout_steps, + control_dim, + Cov_type='indep_ctrl', + mu_init=None, + tensor_args=None, +): + """ + :param sigma: standard deviation on controls + :param control_dim: + :param rollout_steps: + :param Cov_type: Covariance prior type, 'indep_ctrl': diagonal Cov, + 'const_ctrl': const. ctrl Cov. + :param mu_init: + :return: distribution with MultivariateNormal for each control dimension + """ + assert Cov_type in [ + 'indep_ctrl', + 'const_ctrl', + ], 'Invalid type for control prior dist.' + + mu = torch.zeros( + rollout_steps, + control_dim, + **tensor_args, + ) + + if mu_init is not None: + mu[:, :] = mu_init + + if Cov_type == 'const_ctrl': + # Const-ctrl covariance + Cov_gen = const_ctrl_Cov + + elif Cov_type == 'indep_ctrl': + # Isotropic covariance + Cov_gen = diag_Cov + else: + raise IOError('Cov_type not recognized.') + + Cov = Cov_gen( + sigma, + rollout_steps, + control_dim, + tensor_args=tensor_args, + ) + + # check_Cov_is_valid(Cov) + return ControlTrajectoryGaussian( + rollout_steps, + control_dim, + mu, + Cov, + tensor_args=tensor_args + ) + + +def diag_Cov( + sigma, + length=None, + ctrl_dim=None, + tensor_args=None, +): + """ + Time-independent diagonal covariance matrix. Assumes independence + across control dimension. + """ + Cov = torch.eye( + length, + **tensor_args, + ).unsqueeze(-1).repeat(1, 1, ctrl_dim) + + if isinstance(sigma, list): + Cov = Cov * torch.Tensor(np.array(sigma)).to(**tensor_args)**2 + else: + Cov = Cov * sigma**2 + + return Cov + + +def const_ctrl_Cov( + sigma, + length=None, + ctrl_dim=None, + tensor_args=None, +): + """ + Constant-control covariance prior. Assumes independence across control + dimension. + """ + + if isinstance(sigma, list): + sigma = torch.from_numpy(np.array(sigma)).to(**tensor_args) + + L = torch.tril( + torch.ones( + length, + length-1, + **tensor_args, + ), diagonal=-1, + ) + LL_t = torch.matmul( + L, L.transpose(0, 1) + ) + + LL_t += torch.ones( + length, + length, + **tensor_args, + ) + + Cov = LL_t.unsqueeze(-1).repeat(1, 1, ctrl_dim) * sigma**2 + return Cov + + +def check_Cov_is_valid(Cov): + """ + Check determinant of Cov to pre-empt potential numerical instability + in Multi-variate Gaussian. + For example, dist.logprob(x) >> 1. + :param Cov: covariance matrix (Tensor) of shape [rollout_length, + rollout_length, control_dim] + """ + Cov_np = Cov.cpu().numpy() + for i in range(Cov.shape[-1]): + det = np.linalg.det(Cov_np[:,:,i]) + if det < 1.e-7: + raise ZeroDivisionError( + 'Covariance-determinant too small, potential for underflow. ' + 'Consider increasing sigma.' + ) + + +class ControlTrajectoryPrior(ABC): + """ + Prior distribution on control trajectories, Assumes independence across + ctrl_dim. + """ + def __init__( + self, + rollout_steps, + ctrl_dim, + tensor_args=None, + ): + self.rollout_steps = rollout_steps + self.ctrl_dim = ctrl_dim + self.list_ctrl_dists = [] # TODO - make this a tensor, or list of tensors of same distributions... + if tensor_args is None: + tensor_args = {'device': 'cpu', 'dtype': torch.float32} + self.tensor_args = tensor_args + + @abstractmethod + def make_dist(self): + """ Construct list of sampling distribution, one for each control + dimension""" + pass + + def log_prob( + self, + samples, + cond_inputs=None + ): + """ + :param samples: control samples of shape ( num_particles, rollout_steps, + ctrl_dim) + :return: log_probs, of shape (num_particles.) + """ + assert samples.dim() == 3 + assert samples.size(1) == self.rollout_steps + assert samples.size(2) == self.ctrl_dim + num_particles = samples.size(0) + + log_probs = torch.zeros( + num_particles, + **self.tensor_args, + ) + + for i in range(self.ctrl_dim): + samp = samples[:, :, i] # [num_particles, rollout_steps] + log_probs += self.list_ctrl_dists[i].log_prob( + samp + ) + + return log_probs + + def update_means(self, means): + # TODO - remove this for loop after making self.list_ctrl_dists a tensor + for i in range(self.ctrl_dim): + self.list_ctrl_dists[i].loc = means[..., i].detach().clone() + + def sample( + self, + num_samples, + cond_inputs=None, + ): + """ + :param num_particles: number of control particles + :param cond_inputs: conditional input (not implemented) + :return: control tensor, of size (rollout_steps, num_particles, + ctrl_dim) + """ + U_s = torch.empty( + num_samples, + self.rollout_steps, + self.ctrl_dim, + **self.tensor_args + ) + # TODO - remove this for loop + for i in range(self.ctrl_dim): + U_s[:, :, i] = self.list_ctrl_dists[i].sample( + (num_samples,) + ).to(**self.tensor_args) + return U_s + + +class ControlTrajectoryGaussian(ControlTrajectoryPrior): + """ + Multivariate Gaussian distribution for each control dimension. + """ + def __init__( + self, + rollout_steps, + ctrl_dim, + mu=None, + Cov=None, + tensor_args=None, + ): + + assert mu.size(0) == rollout_steps + assert mu.size(1) == ctrl_dim + + super().__init__( + rollout_steps, + ctrl_dim, + tensor_args=tensor_args, + ) + self.mu = mu + self.Cov = Cov + self.list_ctrl_dists = [] + self.make_dist() + + def make_dist(self): + for i in range(self.ctrl_dim): + self.list_ctrl_dists.append( + dist.MultivariateNormal( + self.mu[:, i], + covariance_matrix=self.Cov[:,:,i] + )) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py new file mode 100644 index 0000000000000000000000000000000000000000..46317401e44ac70da619148be4858dd2fc626f11 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_base.py @@ -0,0 +1,119 @@ +import abc + +import torch + +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.utils import extend_path + + +class RRTBase(MPPlanner): + + def __init__( + self, + name: str = 'RRTBase', + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + goal_state_pos: torch.Tensor = None, + step_size: float = 0.1, + n_radius: float = 1., + max_time: float = 60., + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + **kwargs + ): + assert start_state_pos is not None and goal_state_pos is not None + + super(RRTBase, self).__init__(name=name, tensor_args=tensor_args) + self.task = task + self.n_iters = n_iters + + # RRT params + self.step_size = step_size + self.n_radius = n_radius + self.max_time = max_time + + self.start_state_pos = start_state_pos + self.goal_state_pos = goal_state_pos + + self.n_pre_samples = n_pre_samples + self.pre_samples = pre_samples + self.last_sample_idx = None + self.n_samples_refill = self.n_pre_samples + + self.reset() + + def reset(self): + # Create pre collision-free samples + n_uniform_samples = self.n_pre_samples - (self.pre_samples.shape[0] if self.pre_samples is not None else 0) + uniform_samples = self.create_uniform_samples(n_uniform_samples) + if self.pre_samples is not None: + self.pre_samples = torch.cat((self.pre_samples, uniform_samples), dim=0) + else: + self.pre_samples = uniform_samples + + def create_uniform_samples(self, n_samples, max_samples=1000, **observation): + return self.task.random_coll_free_q(n_samples, max_samples) + + def remove_last_pre_sample(self): + # https://discuss.pytorch.org/t/how-to-remove-an-element-from-a-1-d-tensor-by-index/23109/3 + if len(self.pre_samples) > 0: + i = self.last_sample_idx + self.pre_samples = torch.cat([self.pre_samples[:i], self.pre_samples[i+1:]]) + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + return self._run_optimization(opt_iters, **observation) + + @abc.abstractmethod + def _run_optimization(self, opt_iters, **observation): + raise NotImplementedError + + def random_collision_free(self, **observation): + """ + Returns: random positions in environments space not in collision + """ + refill_samples_buffer = observation.get('refill_samples_buffer', False) + if len(self.pre_samples) > 0: + qs = self.get_pre_sample() + elif refill_samples_buffer: + self.pre_samples = self.create_uniform_samples(self.n_samples_refill, **observation) + qs = self.get_pre_sample() + else: + qs = self.create_uniform_samples(1, **observation) + + return qs + + def get_pre_sample(self): + idx = torch.randperm(len(self.pre_samples))[0] + qs = self.pre_samples[idx] + self.last_sample_idx = idx + return qs + + def collision_fn(self, qs, **observation): + return self.task.compute_collision(qs).squeeze() + + def sample_fn(self, without_collision=True, **observation): + if without_collision: + return self.random_collision_free(**observation) + else: + return self.task.random_q() + + def distance_fn(self, q1, q2): + return self.task.distance_q(q1, q2) + + def extend_fn(self, q1, q2, max_step=0.03, max_dist=0.1): + return extend_path(self.distance_fn, q1, q2, max_step, max_dist, tensor_args=self.tensor_args) + + def get_nearest_node(self, nodes, nodes_torch, target): + distances = self.distance_fn(nodes_torch, target) + min_idx = torch.argmin(distances) + nearest = nodes[min_idx] + return nearest diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py new file mode 100644 index 0000000000000000000000000000000000000000..1b3be9cab83525d5821eee52fb23f92ac46be010 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_connect.py @@ -0,0 +1,205 @@ +import math +import sys +from copy import copy +from operator import itemgetter + +import numpy as np +import torch +import time +import matplotlib.pyplot as plt +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.rrt_base import RRTBase +from mp_baselines.planners.utils import safe_path, purge_duplicates_from_traj, extend_path +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +class TreeNode: + + def __init__(self, config, parent=None): + self.config = config + self.parent = parent + + def retrace(self): + sequence = [] + node = self + while node is not None: + sequence.append(node) + node = node.parent + return sequence[::-1] + + def render(self, ax): + assert ax is not None, "Axis cannot be None" + if self.parent is not None: + if ax.name == '3d': + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot3D([x[0], y[0]], [x[1], y[1]], [x[2], y[2]], color='k', linewidth=0.5) + else: + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot([x[0], y[0]], [x[1], y[1]], color='k', linewidth=0.5) + + def __str__(self): + return self.__class__.__name__ + '(' + str(self.config) + ')' + __repr__ = __str__ + + +def random_swap(nodes1, nodes2): + p = float(len(nodes1)) / (len(nodes1) + len(nodes2)) + swap = (torch.rand(1) < p) + return swap + + +def configs(nodes): + if nodes is None: + return None + return list(map(lambda n: n.config, nodes)) + + +class RRTConnect(RRTBase): + + def __init__( + self, + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + step_size: float = 0.1, + n_radius: float = 1., + max_time: float = 60., + goal_state_pos: torch.Tensor = None, + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + **kwargs + ): + super(RRTConnect, self).__init__( + 'RRTConnect', + task, + n_iters, + start_state_pos, + goal_state_pos, + step_size, + n_radius, + max_time, + tensor_args, + n_pre_samples, + pre_samples + ) + + self.nodes_tree_1 = None + self.nodes_tree_2 = None + + self.nodes_tree_1_torch = None + self.nodes_tree_2_torch = None + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + print_freq = observation.get('print_freq', 150) + debug = observation.get('debug', False) + + if self.collision_fn(self.start_state_pos).squeeze() or self.collision_fn(self.goal_state_pos).squeeze(): + return None + + iteration = -1 + success = False + + self.nodes_tree_1 = [TreeNode(self.start_state_pos)] + self.nodes_tree_2 = [TreeNode(self.goal_state_pos)] + + self.nodes_tree_1_torch = self.nodes_tree_1[0].config + self.nodes_tree_2_torch = self.nodes_tree_2[0].config + + path = None + + with TimerCUDA() as t: + while (t.elapsed < self.max_time) and (iteration < self.n_iters): + iteration += 1 + + if iteration % print_freq == 0 or iteration % (self.n_iters - 1) == 0: + if debug: + self.print_info(iteration, t.elapsed, success) + + # Swap trees + # TODO - implement smart random swap + # swap = random_swap(self.nodes_tree_1, self.nodes_tree_2) + swap = True + if swap: + self.nodes_tree_1, self.nodes_tree_2 = self.nodes_tree_2, self.nodes_tree_1 + self.nodes_tree_1_torch, self.nodes_tree_2_torch = self.nodes_tree_2_torch, self.nodes_tree_1_torch + + # Sample new node + target = self.sample_fn(**observation) + + ############################################################### + # nearest node in Tree1 to the target node + nearest = self.get_nearest_node(self.nodes_tree_1, self.nodes_tree_1_torch, target) + + # create a safe path from the target node to the nearest node + extended = self.extend_fn(nearest.config, target, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + + # add last node in path to Tree1 + if len(path) == 0: + continue + + n1 = TreeNode(path[-1], parent=nearest) + self.nodes_tree_1.append(n1) + self.nodes_tree_1_torch = torch.vstack((self.nodes_tree_1_torch, n1.config)) + + if torch.allclose(path[-1], target): + self.remove_last_pre_sample() + + ############################################################### + # add nearest node in Tree2 to the one recently added to Tree1 + nearest = self.get_nearest_node(self.nodes_tree_2, self.nodes_tree_2_torch, n1.config) + + # create a safe path from the target node to the nearest node + extended = self.extend_fn(nearest.config, n1.config, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + + # add last node in path to TREE2 + if len(path) == 0: + continue + + n2 = TreeNode(path[-1], parent=nearest) + self.nodes_tree_2.append(n2) + self.nodes_tree_2_torch = torch.vstack((self.nodes_tree_2_torch, n2.config)) + + if swap: + self.nodes_tree_1, self.nodes_tree_2 = self.nodes_tree_2, self.nodes_tree_1 + self.nodes_tree_1_torch, self.nodes_tree_2_torch = self.nodes_tree_2_torch, self.nodes_tree_1_torch + + # if the last node in path is the same as the proposed node, the two trees are connected and terminate + if torch.allclose(n1.config, n2.config): + success = True + + if swap: + n1, n2 = n2, n1 + + path1, path2 = n1.retrace(), n2.retrace() + + # if swap: + # path1, path2 = path2, path1 + + path = configs(path1[:-1] + path2[::-1]) + break + + if path is not None: + if len(path) == 1: + return None + self.print_info(iteration, t.elapsed, success) + return purge_duplicates_from_traj(path, eps=1e-6) + return path + + def print_info(self, iteration, elapsed_time, success): + print(f'Iteration: {iteration:5}/{self.n_iters:5} ' + f'| Time: {elapsed_time:.3f} s' + f'| Nodes: {len(self.nodes_tree_1) + len(self.nodes_tree_2)} ' + f'| Success: {success}' + ) + + def render(self, ax, **kwargs): + for node in self.nodes_tree_1: + node.render(ax) + for node in self.nodes_tree_2: + node.render(ax) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py new file mode 100644 index 0000000000000000000000000000000000000000..a0eca1a0bcc4fd83f4f2d3d278aeb1a7dbec5662 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/rrt_star.py @@ -0,0 +1,276 @@ +import math +import sys +from copy import copy +from operator import itemgetter + +import numpy as np +import torch +import time +import matplotlib.pyplot as plt +from mp_baselines.planners.base import MPPlanner +from mp_baselines.planners.rrt_base import RRTBase +from mp_baselines.planners.utils import safe_path, purge_duplicates_from_traj, extend_path +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +class OptimalNode: + + def __init__(self, config, parent=None, d=0, path=[], iteration=None): + self.config = config + self.parent = parent + self.children = set() + self.d = d + self.path = path + if parent is not None: + self.cost = parent.cost + d + self.parent.children.add(self) + else: + self.cost = d + self.solution = False + self.creation = iteration + self.last_rewire = iteration + + def set_solution(self, solution): + if self.solution is solution: + return + self.solution = solution + if self.parent is not None: + self.parent.set_solution(solution) + + def retrace(self): + if self.parent is None: + return self.path + [self.config] + return self.parent.retrace() + self.path + [self.config] + + def rewire(self, parent, d, path, iteration=None): + if self.solution: + self.parent.set_solution(False) + self.parent.children.remove(self) + self.parent = parent + self.parent.children.add(self) + if self.solution: + self.parent.set_solution(True) + self.d = d + self.path = path + self.update() + self.last_rewire = iteration + + def update(self): + self.cost = self.parent.cost + self.d + for n in self.children: + n.update() + + def clear(self): + self.node_handle = None + self.edge_handle = None + + def render(self, ax): + assert ax is not None, "Axis cannot be None" + if self.parent is not None: + if ax.name == '3d': + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot3D([x[0], y[0]], [x[1], y[1]], [x[2], y[2]], color='k', linewidth=0.5) + else: + x, y = self.config.cpu().numpy(), self.parent.config.cpu().numpy() + ax.plot([x[0], y[0]], [x[1], y[1]], color='k', linewidth=0.5) + for child in self.children: + child.render(ax) + + def __str__(self): + return self.__class__.__name__ + '(' + str(self.config) + ')' + __repr__ = __str__ + + +class RRTStar(RRTBase): + + def __init__( + self, + task=None, + n_iters: int = None, + start_state_pos: torch.Tensor = None, + n_iters_after_success=None, + max_best_cost_iters: int = 1000, + cost_eps: float = 1e-2, + step_size: float = 0.1, + n_radius: float = 1., + n_knn: int = 0, + max_time: float = 60., + goal_prob: float = .1, + goal_state_pos: torch.Tensor = None, + tensor_args: dict = None, + n_pre_samples=10000, + pre_samples=None, + informed=False + ): + super(RRTStar, self).__init__( + 'RRTStar', + task, + n_iters, + start_state_pos, + goal_state_pos, + step_size, + n_radius, + max_time, + tensor_args, + n_pre_samples, + pre_samples + ) + + self.n_iters_after_success = n_iters_after_success + self.max_best_cost_iters = max_best_cost_iters if max_best_cost_iters is not None else n_iters + self.cost_eps = cost_eps + + # RRTStar params + assert n_knn >= 0, "knn parameter is < 0" + self.n_knn = n_knn + self.goal_prob = goal_prob + + self.informed = informed # Informed RRT + + self.nodes = None + self.nodes_torch = None + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + initial_nodes = observation.get('initial_nodes', None) + informed = observation.get('informed', self.informed) + eps = observation.get('eps', 1e-6) + print_freq = observation.get('print_freq', 150) + debug = observation.get('debug', False) + if self.collision_fn(self.start_state_pos).squeeze() or self.collision_fn(self.goal_state_pos).squeeze(): + return None + if initial_nodes is not None: + self.nodes = initial_nodes + self.nodes_torch = torch.vstack([node.config for node in self.nodes]) + else: + self.nodes = [OptimalNode(self.start_state_pos)] + self.nodes_torch = OptimalNode(self.start_state_pos).config + + goal_n = None + iteration = -1 + iters_after_first_success = 0 + best_cost_iters = 0 + best_cost_eps = torch.inf + success = False + + # best_possible_cost = distance_fn(goal, start) # if straight line is possible + + with TimerCUDA() as t: + while (t.elapsed < self.max_time) and (iteration < self.n_iters): + + iteration += 1 + + # Stop if the cost does not improve over iterations + if best_cost_iters >= self.max_best_cost_iters: + break + if goal_n is not None: + if goal_n.cost < best_cost_eps - self.cost_eps: + best_cost_eps = copy(goal_n.cost) + best_cost_iters = 0 + else: + best_cost_iters += 1 + + # Stop if the cost does not improve over iterations passed after the first success + success = goal_n is not None + if success: + iters_after_first_success += 1 + if self.n_iters_after_success is not None: + if iters_after_first_success > self.n_iters_after_success: + break + + # Sample new node + do_goal = goal_n is None and (iteration == 0 or torch.rand(1) < self.goal_prob) + if do_goal: + s = self.goal_state_pos + else: + s = self.sample_fn(**observation) + + if iteration % print_freq == 0 or iteration % (self.n_iters - 1) == 0 or iters_after_first_success == 1: + if debug: + self.print_info(iteration, t.elapsed, success, goal_n) + + # Informed RRT* + start_time_iter = time.time() + if informed and (goal_n is not None) and (self.distance_fn(self.start_state_pos, s) + self.distance_fn(s, self.goal_state_pos) >= goal_n.cost): + self.remove_last_pre_sample() + continue + + # nearest node to the sampled node + nearest = self.get_nearest_node(self.nodes, self.nodes_torch, s) + + # create a safe path from the sampled node to the nearest node + extended = self.extend_fn(nearest.config, s, max_step=self.step_size, max_dist=self.n_radius) + path = safe_path(extended, self.collision_fn) + if len(path) == 0: + continue + + if not do_goal and torch.allclose(path[-1], s): + self.remove_last_pre_sample() + + new = OptimalNode(path[-1], parent=nearest, d=self.distance_fn( + nearest.config, path[-1]), path=list(path[:-1]), iteration=iteration) + + if do_goal and (self.distance_fn(new.config, self.goal_state_pos) < eps): + goal_n = new + goal_n.set_solution(True) + + # Append the node to the tree + self.nodes.append(new) + self.nodes_torch = torch.vstack((self.nodes_torch, new.config)) + + # Get neighbors of new node + distances = self.distance_fn(self.nodes_torch, new.config) + if self.n_knn > 0: + # https://discuss.pytorch.org/t/k-nearest-neighbor-in-pytorch/59695 + knn = distances.topk(min(self.n_knn, len(distances)), largest=False) + neighbors_idxs = knn.indices + else: + neighbors_idxs = torch.argwhere(distances < self.n_radius) + + if neighbors_idxs.nelement() != 0: + neighbors_idxs = np.atleast_1d(neighbors_idxs.squeeze().cpu().numpy()) + try: + neighbors = list(itemgetter(*neighbors_idxs)(self.nodes)) + except TypeError: + neighbors = [itemgetter(*neighbors_idxs)(self.nodes)] + else: + neighbors = [] + + # Rewire - update parents + for n in neighbors: + d = self.distance_fn(n.config, new.config) + if (new.cost + d) < n.cost: + extended = self.extend_fn(new.config, n.config, max_step=self.step_size, max_dist=self.n_radius) + n_path = safe_path(extended, self.collision_fn) + if len(n_path) != 0: + n_dist = self.distance_fn(n.config, n_path[-1]) + if n_dist < eps: + n.rewire(new, d, list(n_path[:-1]), iteration=iteration) + + self.print_info(iteration, t.elapsed, success, goal_n) + + if goal_n is None: + return None + + # get path from start to goal + path = goal_n.retrace() + + return purge_duplicates_from_traj(path, eps=1e-6) + + def print_info(self, iteration, elapsed_time, success, goal_n): + print(f'Iteration: {iteration:5}/{self.n_iters:5} ' + f'| Time: {elapsed_time:.3f} s' + f'| Nodes: {self.nodes_torch.shape[0]} ' + f'| Success: {success} | Cost: {goal_n.cost if success else torch.inf:.12f}') + + def render(self, ax, **kwargs): + self.nodes[0].render(ax) + + +class InfRRTStar(RRTStar): + # Informed RRT Star + def __init__(self, *args, **kwargs): + super(InfRRTStar, self).__init__(*args, informed=True, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py new file mode 100644 index 0000000000000000000000000000000000000000..a950e52fdf5db16ab775ee8db8cfc49f3241ba15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stoch_gpmp.py @@ -0,0 +1,340 @@ +# Adapted from https://github.com/anindex/stoch_gpmp + +import time +from copy import copy + +import torch + +from mp_baselines.planners.base import OptimizationPlanner +from mp_baselines.planners.costs.factors.gp_factor import GPFactor +from mp_baselines.planners.costs.factors.mp_priors_multi import MultiMPPrior +from mp_baselines.planners.costs.factors.unary_factor import UnaryFactor +from mp_baselines.planners.gpmp2 import build_gpmp2_cost_composite + + +class StochGPMP(OptimizationPlanner): + + def __init__( + self, + robot=None, + n_dof: int = None, + n_support_points: int = None, + num_particles_per_goal: int = None, + opt_iters: int = None, + dt: float = None, + start_state: torch.Tensor = None, + step_size=1., + multi_goal_states=None, + initial_particle_means=None, + sigma_start_init=None, + sigma_start_sample=None, + sigma_goal_init=None, + sigma_goal_sample=None, + sigma_gp_init=None, + sigma_gp_sample=None, + num_samples=2, + temperature=1., + **kwargs + ): + super(StochGPMP, self).__init__( + name='StochGPMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=False, + **kwargs + ) + + self.d_state_opt = 2 * self.n_dof + + self.goal_directed = (multi_goal_states is not None) + if not self.goal_directed: # TODO NOTE(an): if xere is no goal, we assume xere is at least one solution + self.num_goals = 1 + else: + assert multi_goal_states.dim() == 2 + self.num_goals = multi_goal_states.shape[0] + + self.num_samples = num_samples + self.step_size = step_size + self.temperature = temperature + self.sigma_start_sample = sigma_start_sample + self.sigma_goal_sample = sigma_goal_sample + self.sigma_gp_sample = sigma_gp_sample + + self._mean = None + self._weights = None + self._sample_dist = None + + ############################################## + # Construct cost function + self.cost = build_gpmp2_cost_composite( + robot=robot, + n_support_points=n_support_points, + dt=dt, + start_state=start_state, + multi_goal_states=multi_goal_states, + num_particles_per_goal=num_particles_per_goal, + num_samples=num_samples, + **kwargs + ) + + ############################################## + # Initialize particles + self.reset(initial_particle_means=initial_particle_means) + + def reset( + self, + start_state=None, + multi_goal_states=None, + initial_particle_means=None, + ): + if start_state is not None: + self.start_state = start_state.detach().clone() + + if multi_goal_states is not None: + self.multi_goal_states = multi_goal_states.detach().clone() + + self.set_prior_factors() + + if initial_particle_means is not None: + if initial_particle_means == 'const_vel': + self._particle_means = self.const_vel_trajectories( + self.start_state, + self.multi_goal_states, + ) + else: + self._particle_means = initial_particle_means + else: + # Initialization particles from prior distribution + self._init_dist = self.get_prior_dist( + self.start_prior_init.K, + self.gp_prior_init.Q_inv[0], + self.multi_goal_prior_init[0].K if self.goal_directed else None, + self.start_state, + goal_states=self.multi_goal_states, + ) + self._particle_means = self._init_dist.sample(self.num_particles_per_goal).to(**self.tensor_args) + del self._init_dist # free memory + self._particle_means = self._particle_means.flatten(0, 1) + + # Sampling distributions + self._sample_dist = self.get_prior_dist( + self.start_prior_sample.K, + self.gp_prior_sample.Q_inv[0], + self.multi_goal_prior_sample[0].K if self.goal_directed else None, + self.start_state, + particle_means=self._particle_means, + goal_states=self.multi_goal_states + ) + self.Sigma_inv = self._sample_dist.Sigma_inv.to(**self.tensor_args) + self.state_samples = self._sample_dist.sample(self.num_samples).to(**self.tensor_args) + + def set_prior_factors(self): + #========= Initialization factors =============== + self.start_prior_init = UnaryFactor( + self.d_state_opt, + self.sigma_start_init, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_init = GPFactor( + self.n_dof, + self.sigma_gp_init, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + self.multi_goal_prior_init = [] + if self.goal_directed: + for i in range(self.num_goals): + self.multi_goal_prior_init.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_init, # NOTE(sasha) Assume same goal Cov. for now + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + #========= Sampling factors =============== + self.start_prior_sample = UnaryFactor( + self.d_state_opt, + self.sigma_start_sample, + self.start_state, + self.tensor_args, + ) + + self.gp_prior_sample = GPFactor( + self.n_dof, + self.sigma_gp_sample, + self.dt, + self.n_support_points - 1, + self.tensor_args, + ) + + self.multi_goal_prior_sample = [] + if self.goal_directed: + for i in range(self.num_goals): + self.multi_goal_prior_sample.append( + UnaryFactor( + self.d_state_opt, + self.sigma_goal_sample, # NOTE(sasha) Assume same goal Cov. for now + self.multi_goal_states[i], + self.tensor_args, + ) + ) + + def const_vel_trajectories( + self, + start_state, + multi_goal_states, + ): + traj_dim = (multi_goal_states.shape[0], self.num_particles_per_goal, self.n_support_points, self.d_state_opt) + state_traj = torch.zeros(traj_dim, **self.tensor_args) + mean_vel = (multi_goal_states[:, :self.n_dof] - start_state[:self.n_dof]) / (self.n_support_points * self.dt) + for i in range(self.n_support_points): + interp_state = start_state[:self.n_dof] * (self.n_support_points - i - 1) / (self.n_support_points - 1) \ + + multi_goal_states[:, :self.n_dof] * i / (self.n_support_points - 1) + state_traj[:, :, i, :self.n_dof] = interp_state.unsqueeze(1) + state_traj[:, :, :, self.n_dof:] = mean_vel.unsqueeze(1).unsqueeze(1) + return state_traj + + def get_prior_dist( + self, + start_K, + gp_K, + goal_K, + state_init, + particle_means=None, + goal_states=None, + ): + return MultiMPPrior( + self.n_support_points - 1, + self.dt, + 2 * self.n_dof, + self.n_dof, + start_K, + gp_K, + state_init, + K_g_inv=goal_K, # Assume same goal Cov. for now + means=particle_means, + goal_states=goal_states, + tensor_args=self.tensor_args + ) + + def _get_costs(self, **observation): + costs = self.cost.eval(self.state_samples, **observation).reshape(self.num_particles, self.num_samples) + + # Add cost from importance-sampling ratio + V = self.state_samples.view(-1, self.num_samples, self.n_support_points * self.d_state_opt) # flatten trajectories + U = self._particle_means.view(-1, 1, self.n_support_points * self.d_state_opt) + costs += self.temperature * (V @ self.Sigma_inv @ U.transpose(1, 2)).squeeze(2) + return costs + + def sample_and_eval(self, **observation): + # TODO: update prior covariance with new goal location + + # Sample state-trajectory particles + self.state_samples = self._sample_dist.sample(self.num_samples).to(**self.tensor_args) + + # Evaluate costs + costs = self._get_costs(**observation) + + position_seq = self.state_samples[..., :self.n_dof] + velocity_seq = self.state_samples[..., -self.n_dof:] + + position_seq_mean = self._particle_means[..., :self.n_dof].clone() + velocity_seq_mean = self._particle_means[..., -self.n_dof:].clone() + + return ( + velocity_seq, + position_seq, + velocity_seq_mean, + position_seq_mean, + costs, + ) + + def _update_distribution(self, costs, traj_samples): + self._weights = torch.softmax(-costs / self.temperature, dim=1) + self._weights = self._weights.reshape(-1, self.num_samples, 1, 1) + + # sum over particles + approx_grad = (self._weights * (traj_samples - self._particle_means.unsqueeze(1))).sum(1) + + self._particle_means.add_( + self.step_size * approx_grad + ) + self._sample_dist.set_mean(self._particle_means.view(self.num_particles, -1)) + + return approx_grad + + def optimize( + self, + opt_iters=None, + debug=False, + **observation + ): + + if opt_iters is None: + opt_iters = self.opt_iters + + for opt_step in range(opt_iters): + with torch.no_grad(): + (control_samples, + state_trajectories, + control_particles, + state_particles, + costs,) = self.sample_and_eval(**observation) + + approx_grad = self._update_distribution(costs, self.state_samples) + + self._recent_control_samples = control_samples + self._recent_control_particles = control_particles + self._recent_state_trajectories = state_trajectories + self._recent_state_particles = state_particles + self._recent_weights = self._weights + + # get mean trajectory + curr_traj = self._get_traj() + return curr_traj + + # def _get_traj(self, mode='best'): + # if mode == 'best': + # # TODO: Fix for multi-particles + # particle_ind = self._weights.argmax() + # traj = self.state_samples[particle_ind].clone() + # elif mode == 'mean': + # traj = self._mean.clone() + # else: + # raise ValueError('Unidentified sampling mode in get_next_action') + # return traj + + def get_recent_samples(self): + return ( + self._recent_state_trajectories.detach().clone(), + self._recent_state_particles.detach().clone(), + self._recent_control_samples.detach().clone(), + self._recent_control_particles.detach().clone(), + self._recent_weights.detach().clone(), + ) + + def sample_trajectories(self, num_samples_per_particle): + self._sample_dist.set_mean(self._particle_means.view(self.num_particles, -1)) + self.state_samples = self._sample_dist.sample(num_samples_per_particle).to( + **self.tensor_args) + position_seq = self.state_samples[..., :self.n_dof] + velocity_seq = self.state_samples[..., -self.n_dof:] + return ( + position_seq, + velocity_seq, + ) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stomp.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stomp.py new file mode 100644 index 0000000000000000000000000000000000000000..efda3b3782c09c25d8810e2369ae92ed57441f85 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/stomp.py @@ -0,0 +1,220 @@ +import einops +import torch +import torch.distributions as dist + +from mp_baselines.planners.base import OptimizationPlanner + + +class STOMP(OptimizationPlanner): + + def __init__( + self, + n_dof: int, + n_support_points: int, + num_particles_per_goal: int, + num_samples: int, + opt_iters: int, + dt: float, + start_state: torch.Tensor, + cost=None, + initial_particle_means=None, + multi_goal_states: torch.Tensor = None, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=10., + temperature: float = 1., + step_size: float = 1., + sigma_spectral: float = 0.1, + goal_state: torch.Tensor = None, + pos_only: bool = True, + tensor_args: dict = None, + **kwargs + ): + super(STOMP, self).__init__(name='STOMP', + n_dof=n_dof, + n_support_points=n_support_points, + num_particles_per_goal=num_particles_per_goal, + opt_iters=opt_iters, + dt=dt, + start_state=start_state, + cost=cost, + initial_particle_means=initial_particle_means, + multi_goal_states=multi_goal_states, + sigma_start_init=sigma_start_init, + sigma_goal_init=sigma_goal_init, + sigma_gp_init=sigma_gp_init, + pos_only=pos_only, + tensor_args=tensor_args) + + # STOMP params + self.lr = step_size + self.sigma_spectral = sigma_spectral + + self.start_state = start_state + self.goal_state = goal_state + self.num_samples = num_samples + self.temperature = temperature + + self._particle_means = None + self._weights = None + self._sample_dist = None + + # Precision matrix, shape: [ctrl_dim, n_support_points, n_support_points] + self.Sigma_inv = self._get_R_mat() + self.Sigma = torch.inverse(self.Sigma_inv) + self.reset(initial_particle_means=initial_particle_means) + self.best_cost = torch.inf + + def _get_R_mat(self): + """ + STOMP time-correlated Precision matrix. + """ + upper_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=1) + lower_diag = torch.diag(torch.ones(self.n_support_points - 1), diagonal=-1,) + diag = -2 * torch.eye(self.n_support_points) + A_mat = upper_diag + diag + lower_diag + A_mat = torch.cat( + (torch.zeros(1, self.n_support_points), + A_mat, + torch.zeros(1, self.n_support_points)), + dim=0, + ) + A_mat[0, 0] = 1. + A_mat[-1, -1] = 1. + A_mat = A_mat * 1./self.dt**2 * self.sigma_spectral + R_mat = A_mat.t() @ A_mat + return R_mat.to(**self.tensor_args) + + def set_noise_dist(self): + """ + Additive Gaussian noise distribution over 1-dimensional trajectory. + """ + self._noise_dist = dist.MultivariateNormal( + torch.zeros(self.num_particles, self.n_support_points, **self.tensor_args), + precision_matrix=self.Sigma_inv, + ) + + def sample(self): + """ + Generate trajectory samples from Gaussian control dist. + Return: position-trajectory samples, of shape: [num_particles, num_samples, n_support_points, n_dof] + """ + noise = self._noise_dist.sample((self.num_samples, self.d_state_opt)).transpose(0, 2).transpose(1, 3).transpose(1, 2) # [num_particles, num_samples, n_support_points, n_dof] + + # Force Bound to zero ## + noise[..., -1, :] = 0 + noise[..., 0, :] = 0 + samples = self._particle_means.unsqueeze(1) + noise + return samples + + def reset( + self, + initial_particle_means=None, + ): + # Straightline position-trajectory from start to goal + if initial_particle_means is not None: + self._particle_means = initial_particle_means.clone() + else: + self._particle_means = self.get_random_trajs() + self.set_noise_dist() + self.state_particles = self.sample() + + def const_vel_trajectory( + self, + start_state, + goal_state, + ): + num_steps = self.n_support_points - 1 + state_traj = torch.zeros(num_steps + 1, self.d_state_opt, **self.tensor_args) + for i in range(num_steps + 1): + state_traj[i, :self.n_dof] = start_state[:self.n_dof] * (num_steps - i) * 1. / num_steps \ + + goal_state[:self.n_dof] * i * 1./num_steps + if not self.pos_only: + mean_vel = (goal_state[:self.n_dof] - start_state[:self.n_dof]) / (num_steps * self.dt) + state_traj[:, self.n_dof:] = mean_vel.unsqueeze(0) + return state_traj + + def optimize( + self, + opt_iters=None, + **observation + ): + """ + Optimize for best trajectory at current state + """ + self._run_optimization(opt_iters, **observation) + # get best trajectory + curr_traj = self._get_traj() + return curr_traj + + def _run_optimization(self, opt_iters, **observation): + """ + Run optimization iterations. + """ + optim_vis = observation.get('optim_vis', False) + if opt_iters is None: + opt_iters = self.opt_iters + for opt_step in range(opt_iters): + self.costs = self._sample_and_eval(**observation) + self._update_distribution(self.costs, self.state_particles) + self._particle_means = self._particle_means.detach() + + def _sample_and_eval(self, **observation): + """ + Sample trajectories from distribution and evaluate costs. + """ + # Sample state-trajectory particles + self.state_particles = self.sample() + + ## Optional : Clamp for joint limits + # if isinstance(self.max_control, list): + # for d in range(self.control_dim): + # control_samples[..., d] = control_samples[..., d].clamp( + # min=-self.max_control[d], + # max=self.max_control[d], + # ) + # else: + # control_samples = control_samples.clamp( + # min=-self.max_control, + # max=self.max_control, + # ) + + # Evaluate quadratic costs + costs = self._get_costs(self.state_particles.flatten(0, 1), **observation) + costs = einops.rearrange(costs, "(p s) -> p s", p=self.num_particles, s=self.num_samples) + + ## Optional : Add cost term from importance sampling ratio + # for i in range(self.control_dim): + # Sigma_inv = self.ctrl_dist.Sigma[..., i].inverse() + # V = self.state_particles[..., i] + # U = self.U_mean[..., i] + # costs += self.lambda_ * (V @ Sigma_inv @ U).reshape(-1, 1) + + # Add smoothness term + # smooth_cost = self.state_particles.transpose(1, 2) @ self.Sigma_inv.unsqueeze(0) @ self.state_particles + # costs += smooth_cost.diagonal(dim1=1, dim2=2).sum(-1) + + return costs + + def _update_distribution(self, costs, traj_particles): + """ + Get sample weights and pdate trajectory mean. + """ + self._weights = self._calc_sample_weights(costs) + self._weights = self._weights.reshape(self.num_particles, self.num_samples, 1, 1) + + ## Optional: STOMP Covariance-weighted update + self._particle_means.add_( + self.lr * self.Sigma @ ( + self._weights * (traj_particles - self._particle_means.unsqueeze(1)) + ).sum(1) + ) + + # self._particle_means.add_( + # self.lr * ( + # self._weights * (traj_particles - self._particle_means.unsqueeze(1)) + # ).sum(0) + # ) + + def _calc_sample_weights(self, costs): + return torch.softmax(-costs / self.temperature, dim=1) # num_samples dim diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..39cedf13aa36d477b7e09c5f958a5d17cdf1ebfc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/mp_baselines/planners/utils.py @@ -0,0 +1,50 @@ +import torch + + +def extend_path(distance_fn, q1, q2, max_step=0.03, max_dist=0.1, tensor_args=None): + # max_dist must be <= radius of RRT star! + dist = distance_fn(q1, q2) + if dist > max_dist: + q2 = q1 + (q2 - q1) * (max_dist / dist) + + alpha = torch.linspace(0, 1, int(dist / max_step) + 2).to(**tensor_args) # skip first and last + q1 = q1.unsqueeze(0) + q2 = q2.unsqueeze(0) + extension = q1 + (q2 - q1) * alpha.unsqueeze(1) + return extension + + +def safe_path(sequence, collision_fn): + in_collision = collision_fn(sequence) + idxs_in_collision = torch.argwhere(in_collision) + if idxs_in_collision.nelement() == 0: + if sequence.ndim == 1: + return sequence.reshape((1, -1)) + return sequence[-1].reshape((1, -1)) + else: + first_idx_in_collision = idxs_in_collision[0] + if first_idx_in_collision == 0: + # the first point in the sequence is in collision + return [] + # return the point immediate before the one in collision + return sequence[first_idx_in_collision-1] + + +def purge_duplicates_from_traj(path, eps=1e-6): + # Remove duplicated points from a trajectory + if len(path) < 2: + return path + if isinstance(path, list): + path = torch.stack(path, dim=0) + if path.shape[0] == 2: + return path + + abs_diff = torch.abs(torch.diff(path, dim=-2)) + row_idxs = torch.argwhere(torch.all((abs_diff > eps) == False, dim=-1) == False).unique() + selection = path[row_idxs] + # Always add the first and last elements of the path + if torch.allclose(selection[0], path[0]) is False: + selection = torch.cat((path[0].view(1, -1), selection), dim=0) + if torch.allclose(selection[-1], path[-1]) is False: + selection = torch.cat((selection, path[-1].view(1, -1)), dim=0) + return selection diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b7e2c3ceecc3e5808724a5756d7d8d5da3e724ee --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/requirements.txt @@ -0,0 +1,3 @@ +numpy +matplotlib +cholespy diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..a16ec46c5bbb6e7b4807e45431eb7c7d7ac38bc6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/motion_planning_baselines/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup +from codecs import open +from os import path + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + +setup(name='mp_baselines', + description='Motion Planning Baselines', + author='An T. Le, Joao Carvalho', + author_email='an@robot-learning.de, joao@robot-learning.de', + packages=['mp_baselines'], + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/CITATION.cff b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..1eb6ec957cf945952a32aef09e7cd1ddcfd08ea9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: +- family-names: "Le" + given-names: "An" + affiliation: "TU Darmstadt" +- family-names: "Carvalho" + given-names: "Joao" + affiliation: "TU Darmstadt" +title: "torch_robotics" +version: 0.1.0 +date-released: 15-10-23 +url: "https://github.com/anindex/torch_robotics" diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9c052bb71ac9fb2c3cf5180c836d3f317ebfc4da --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 An Thai Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/MANIFEST.in b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..0752b91419620afd59f77fd95c8471a4d5fbb832 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +include CITATION.cff +recursive-include torch_robotics/data * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/README.md b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c198bf94e6c454405c2c200ec68e93a37ffab252 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/README.md @@ -0,0 +1,61 @@ +The original repository is this one: https://github.com/anindex/torch_robotics. +Their original README follows. + +# TorchRobotics + +This library implements differentiable robot tree from URDF or MCJF robot format, and the differentiable planning objects such as obstacle avoidance, self-collision avoidance and via point. + +**NOTE**: `torch_robotics` is under heavy development and highly experimental. + +## Installation + +Simply activate your conda/Python environment and run + +```azure +pip install -e . +``` + +## Examples + +To see FK, IK of all available robot kinematics + +```azure +python examples/forward_kinematics.py +``` + +and + +```azure +python examples/inverse_kinematics.py +``` + +## Acknowledgements + +A part of this implementation is inspired from the library [differentiable robot model](https://github.com/facebookresearch/differentiable-robot-model). + +## Contact + +If you have any questions or find any bugs, please let us know: + +- [An Le](https://www.ias.informatik.tu-darmstadt.de/Team/AnThaiLe), [an@robot-learning.de](an@robot-learning.de) +- [Joao Carvalho](https://www.ias.informatik.tu-darmstadt.de/Team/JoaoCarvalho), [joao@robot-learning.de](joao@robot-learning.de) + +## Citation + +If you found this repository useful, please consider citing these references: + +```azure +@inproceedings{le2023accelerating, + title={Accelerating Motion Planning via Optimal Transport}, + author={Le, An T. and Chalvatzaki, Georgia and Biess, Armin and Peters, Jan}, + booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, + year={2023} +} + +@article{carvalho2023motion, + title={Motion planning diffusion: Learning and planning of robot motions with diffusion models}, + author={Carvalho, Joao and Le, An T and Baierl, Mark and Koert, Dorothea and Peters, Jan}, + journal={arXiv preprint arXiv:2308.01557}, + year={2023} +} +``` diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/forward_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/forward_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..30b502a35bbe2b0669bd90184bbc688c922f1378 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/forward_kinematics.py @@ -0,0 +1,82 @@ +import torch +import time + +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_quat_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda, DifferentiableUR10, \ + DifferentiableTiagoDualHoloMove, DifferentiableShadowHand, DifferentiableAllegroHand, DifferentiableHabitatStretch +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA + + +if __name__ == "__main__": + seed = 1 + fix_random_seed(seed) + + batch_size = 10 + device = "cpu" + # device = "cuda:0" + + print("\n===========================Panda Model===============================") + diff_panda = DifferentiableFrankaPanda(device=device) + diff_panda.print_link_names() + print(diff_panda.get_joint_limits()) + print(diff_panda._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_panda._n_dofs).to(device).requires_grad_(True) + data = diff_panda.compute_forward_kinematics_all_links(q) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================UR10 Model===============================") + diff_ur10 = DifferentiableUR10(device=device) + diff_ur10.print_link_names() + print(diff_ur10.get_joint_limits()) + print(diff_ur10._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_ur10._n_dofs).to(device).requires_grad_(True) + data = diff_ur10.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Habitat Stretch Model===============================") + diff_str = DifferentiableHabitatStretch(device=device) + diff_str.print_link_names() + print(diff_str.get_joint_limits()) + print(diff_str._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_str._n_dofs).to(device).requires_grad_(True) + data = diff_str.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Tiago Model===============================") + diff_tiago = DifferentiableTiagoDualHoloMove(device=device) + diff_tiago.print_link_names() + print(diff_tiago.get_joint_limits()) + print(diff_tiago._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, diff_tiago._n_dofs).to(device).requires_grad_(True) + data = diff_tiago.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Shadow Hand Model===============================") + hand = DifferentiableShadowHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") + + print("\n===========================Allegro Hand Model===============================") + hand = DifferentiableAllegroHand(device=device) + hand.print_link_names() + print(hand.get_joint_limits()) + print(hand._n_dofs) + with TimerCUDA() as t: + q = torch.rand(batch_size, hand._n_dofs).to(device).requires_grad_(True) + data = hand.compute_forward_kinematics_all_links(q) + print(data.shape) + print(f"Computational Time {t.elapsed:.4f}") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/inverse_kinematics.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/inverse_kinematics.py new file mode 100644 index 0000000000000000000000000000000000000000..e412cdbd7e7aa503c2b7e434eca5742d03cde3db --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/examples/inverse_kinematics.py @@ -0,0 +1,82 @@ +from copy import copy + +import matplotlib.pyplot as plt +import torch + +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame +from torch_robotics.torch_kinematics_tree.geometrics.skeleton import get_skeleton_from_model +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import y_rot, z_rot +from torch_robotics.torch_kinematics_tree.geometrics.utils import link_pos_from_link_tensor, link_rot_from_link_tensor +from torch_robotics.torch_kinematics_tree.models.robots import DifferentiableFrankaPanda +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from torch_robotics.visualizers.plot_utils import plot_coordinate_frame + + +if __name__ == "__main__": + seed = 0 + fix_random_seed(seed) + + batch_size = 10 + device = 'cuda' + # device = 'cpu' + tensor_args = dict(device=device, dtype=torch.float32) + + pos_target = torch.tensor([0.2, 0.4, 0.1], **tensor_args) + rot_target = (z_rot(-torch.tensor(torch.pi/2)) @ y_rot(-torch.tensor(torch.pi))).to(**tensor_args) + frame_target = Frame(rot=rot_target, trans=pos_target, device=device) + H_target = frame_target.get_transform_matrix() # set translation and orientation of target here + + print("===========================Panda Model===============================") + # Panda Kinematics + diff_panda = DifferentiableFrankaPanda(gripper=False, device=device) + + with TimerCUDA() as t: + q_ik, idx_valid = diff_panda.inverse_kinematics( + H_target, link_name='ee_link', batch_size=batch_size, max_iters=500, lr=2e-1, se3_eps=5e-2, + eps_joint_lim=torch.pi/64, + # print_freq=-1, + debug=False + ) + print(f"\nIK time: {t.elapsed:.3f} sec") + + print(f'idx_valid: {idx_valid.nelement()}/{batch_size}') + + ############################################################################################################ + # plot the result + fig, ax = create_fig_and_axes(3) + ax.set_aspect('equal') + ax.set_xlim(-0.75, 0.75) + ax.set_ylim(-0.75, 0.75) + ax.set_zlim(-0.5, 1) + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + + # Origin frame + frame_origin = Frame(device=device) + ax.plot(0, 0, 0, color='b', marker='D', markersize=10, zorder=100) + plot_coordinate_frame(ax, frame_origin, arrow_length=0.1, arrow_linewidth=3., tensor_args=tensor_args) + + # Target frame + target_pos_np = to_numpy(pos_target) + ax.plot(target_pos_np[0], target_pos_np[1], target_pos_np[2], 'r*', markersize=20, zorder=100) + plot_coordinate_frame(ax, frame_target, arrow_length=0.15, arrow_linewidth=3., tensor_args=tensor_args) + + # Robot + for q in q_ik[idx_valid]: + skeleton = get_skeleton_from_model(diff_panda, q, diff_panda.get_link_names()) # visualize IK solution + skeleton.draw_skeleton(ax=ax, color='blue') + + # draw EE frame + H_EE = diff_panda.compute_forward_kinematics_all_links(q.unsqueeze(0), link_list=['ee_link']) + frame_EE = Frame( + rot=link_rot_from_link_tensor(H_EE).squeeze(), + trans=link_pos_from_link_tensor(H_EE).squeeze(), + device=device + ) + plot_coordinate_frame(ax, frame_EE, arrow_length=0.1, arrow_alpha=0.5, tensor_args=tensor_args) + + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c8cdb7b1095c010a77a5dbe88780e89b992b247 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/requirements.txt @@ -0,0 +1,5 @@ +pyyaml +urdf-parser-py +matplotlib +trimesh +einops diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/setup.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..911e23eac7991a75b5c6ea6c4d0744143aa152cd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/setup.py @@ -0,0 +1,23 @@ +import setuptools +from codecs import open +from os import path + + +ext_modules = [] + +here = path.abspath(path.dirname(__file__)) +requires_list = [] +with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: + for line in f: + requires_list.append(str(line)) + + +setuptools.setup( + name='torch_robotics', + description='Full Differentiable Kinematics Tree Implementation constructed from URDF', + author='An Thai Le, Joao Carvalho', + author_email='an@robots-learning.de, joao@robots-learning.de', + packages=setuptools.find_namespace_packages(), + include_package_data=True, + install_requires=requires_list, +) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/PKG-INFO b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..c96f5b9d34e6ccf2dfeaccae203553393cdd6bff --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/PKG-INFO @@ -0,0 +1,17 @@ +Metadata-Version: 2.4 +Name: torch_robotics +Version: 0.0.0 +Summary: Full Differentiable Kinematics Tree Implementation constructed from URDF +Author: An Thai Le, Joao Carvalho +Author-email: an@robots-learning.de, joao@robots-learning.de +License-File: LICENSE +Requires-Dist: pyyaml +Requires-Dist: urdf-parser-py +Requires-Dist: matplotlib +Requires-Dist: trimesh +Requires-Dist: einops +Dynamic: author +Dynamic: author-email +Dynamic: license-file +Dynamic: requires-dist +Dynamic: summary diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/SOURCES.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..400bf785a2a6de2306c7020fc84456a3af18f00f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/SOURCES.txt @@ -0,0 +1,81 @@ +CITATION.cff +LICENSE +MANIFEST.in +README.md +setup.py +examples/forward_kinematics.py +examples/inverse_kinematics.py +torch_robotics/__init__.py +torch_robotics.egg-info/PKG-INFO +torch_robotics.egg-info/SOURCES.txt +torch_robotics.egg-info/dependency_links.txt +torch_robotics.egg-info/requires.txt +torch_robotics.egg-info/top_level.txt +torch_robotics/environments/__init__.py +torch_robotics/environments/dense_2d_extra_objects.py +torch_robotics/environments/env_base.py +torch_robotics/environments/env_conveyor_2d.py +torch_robotics/environments/env_conveyor_2d_extra_objects.py +torch_robotics/environments/env_corridor_smd_2d.py +torch_robotics/environments/env_cross_room_2d.py +torch_robotics/environments/env_dense_2d.py +torch_robotics/environments/env_drop_region_2d.py +torch_robotics/environments/env_drop_region_2d_extra_objects.py +torch_robotics/environments/env_empty_2d.py +torch_robotics/environments/env_empty_2d_extra_objects.py +torch_robotics/environments/env_empty_nowait_2d.py +torch_robotics/environments/env_empty_nowait_2d_extra_objects.py +torch_robotics/environments/env_ensemble.py +torch_robotics/environments/env_highways_2d.py +torch_robotics/environments/env_highways_2d_extra_objects.py +torch_robotics/environments/env_narrow_corridors.py +torch_robotics/environments/grid_map_sdf.py +torch_robotics/environments/objects.py +torch_robotics/environments/occupancy_map.py +torch_robotics/environments/primitives.py +torch_robotics/environments/utils.py +torch_robotics/robots/__init__.py +torch_robotics/robots/robot_base.py +torch_robotics/robots/robot_composite_eighteen_planar_disk.py +torch_robotics/robots/robot_composite_fifteen_planar_disk.py +torch_robotics/robots/robot_composite_nine_planar_disk.py +torch_robotics/robots/robot_composite_six_planar_disk.py +torch_robotics/robots/robot_composite_three_planar_disk.py +torch_robotics/robots/robot_composite_twelve_planar_disk.py +torch_robotics/robots/robot_composite_twentyone_planar_disk.py +torch_robotics/robots/robot_composite_two_planar_disk.py +torch_robotics/robots/robot_planar_disk.py +torch_robotics/tasks/__init__.py +torch_robotics/tasks/tasks.py +torch_robotics/tasks/tasks_ensemble.py +torch_robotics/torch_kinematics_tree/__init__.py +torch_robotics/torch_kinematics_tree/geometrics/distributions.py +torch_robotics/torch_kinematics_tree/geometrics/euclidean.py +torch_robotics/torch_kinematics_tree/geometrics/frame.py +torch_robotics/torch_kinematics_tree/geometrics/manifold.py +torch_robotics/torch_kinematics_tree/geometrics/quaternion.py +torch_robotics/torch_kinematics_tree/geometrics/skeleton.py +torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py +torch_robotics/torch_kinematics_tree/geometrics/trajectory.py +torch_robotics/torch_kinematics_tree/geometrics/utils.py +torch_robotics/torch_kinematics_tree/models/__init__.py +torch_robotics/torch_kinematics_tree/models/rigid_body.py +torch_robotics/torch_kinematics_tree/models/robot_tree.py +torch_robotics/torch_kinematics_tree/models/robots.py +torch_robotics/torch_kinematics_tree/models/utils.py +torch_robotics/torch_kinematics_tree/utils/__init__.py +torch_robotics/torch_kinematics_tree/utils/files.py +torch_robotics/torch_planning_objectives/__init__.py +torch_robotics/torch_planning_objectives/fields/__init__.py +torch_robotics/torch_planning_objectives/fields/distance_fields.py +torch_robotics/torch_utils/__init__.py +torch_robotics/torch_utils/seed.py +torch_robotics/torch_utils/torch_timer.py +torch_robotics/torch_utils/torch_utils.py +torch_robotics/trajectory/__init__.py +torch_robotics/trajectory/metrics.py +torch_robotics/trajectory/utils.py +torch_robotics/visualizers/__init__.py +torch_robotics/visualizers/ensemble_planning_visualizer.py +torch_robotics/visualizers/planning_visualizer.py +torch_robotics/visualizers/plot_utils.py \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/dependency_links.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/requires.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c8cdb7b1095c010a77a5dbe88780e89b992b247 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/requires.txt @@ -0,0 +1,5 @@ +pyyaml +urdf-parser-py +matplotlib +trimesh +einops diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/top_level.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..205ebebc430e5b248d9d8fc19f45750653a68731 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics.egg-info/top_level.txt @@ -0,0 +1,2 @@ +examples +torch_robotics diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..74f327ed5364671d1d23d1e3931e0bf8d647237e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/__init__.py @@ -0,0 +1,19 @@ +from .env_empty_2d import * +from .env_empty_2d_extra_objects import * +from .env_conveyor_2d import * +from .env_conveyor_2d_extra_objects import * +from .env_drop_region_2d import * +from .env_drop_region_2d_extra_objects import * +from .env_highways_2d import * +from .env_highways_2d_extra_objects import * +from .env_ensemble import * +from .env_drop_region_2d import * +from .env_drop_region_2d_extra_objects import * +from .env_highways_2d import * +from .env_highways_2d_extra_objects import * +from .env_empty_nowait_2d import * +from .env_empty_nowait_2d_extra_objects import * + +# hard-map additions (scenario1_hard_maps) +from .env_cross_room_2d import * +from .env_corridor_smd_2d import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/dense_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/dense_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..a259ed847ceaabcadcfb0c3b049b9366fe8cdc4d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/dense_2d_extra_objects.py @@ -0,0 +1,100 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments.env_dense_2d import EnvDense2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDense2DExtraObjects(EnvDense2D): + + def __init__(self, tensor_args=None, **kwargs): + # obj_extra_list = [ + # MultiSphereField( + # np.array( + # [ + # [-0.4, 0.1], + # [-0.075, -0.85], + # [-0.1, -0.1], + # ]), + # np.array( + # [ + # 0.075, + # 0.1, + # 0.075, + # ] + # ) + # , + # tensor_args=tensor_args + # ), + # MultiBoxField( + # np.array( + # [ + # [0.45, -0.1], + # [0.35, 0.35], + # [-0.6, -0.85], + # [-0.65, -0.25], + # ] + # ), + # np.array( + # [ + # [0.2, 0.2], + # [0.1, 0.15], + # [0.1, 0.25], + # [0.15, 0.1], + # ] + # ) + # , + # tensor_args=tensor_args + # ) + # ] + + obj_extra_list = [ + MultiSphereField( + np.array( + [ + [-0.4, 0.1], + [-0.075, -0.85], + [-0.1, -0.1], + ]), + np.array( + [ + 0.075, + 0.1, + 0.075, + ] + ) + , + tensor_args=tensor_args + ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'dense2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvDense2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_base.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_base.py new file mode 100644 index 0000000000000000000000000000000000000000..22a10ac603d81b1182bc7b44acc73429e3768725 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_base.py @@ -0,0 +1,335 @@ +import itertools +from abc import ABC +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments.grid_map_sdf import GridMapSDF +from torch_robotics.environments.occupancy_map import OccupancyMap +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import to_numpy, DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvBase(ABC): + + def __init__(self, + name='NameEnvBase', + limits=None, + obj_fixed_list=None, + obj_extra_list=None, + precompute_sdf_obj_fixed=False, + sdf_cell_size=0.005, + tensor_args=None, + **kwargs + ): + self.tensor_args = tensor_args + import os as _os, torch as _torch + if _os.environ.get('RBT_ARENA') == '1': + limits = _torch.tensor([[-1.6, -1.0], [1.6, 1.0]], **(tensor_args or {})) + + self.name = name + + # Workspace + assert limits is not None + self.limits = limits + self.limits_np = to_numpy(limits) + self.dim = len(limits[0]) + + ################################################################################################ + # Objects + if obj_fixed_list is not None: + for obj in obj_fixed_list: + assert (isinstance(obj, ObjectField)), "Objects must be instances of ObjectField class" + if obj_extra_list is not None: + for obj in obj_extra_list: + assert (isinstance(obj, ObjectField)), "Objects must be instances of ObjectField class" + + self.obj_fixed_list = obj_fixed_list + self.obj_extra_list = obj_extra_list + self.obj_all_list = set(itertools.chain.from_iterable(( + self.obj_fixed_list if self.obj_fixed_list is not None else [], + self.obj_extra_list if self.obj_extra_list is not None else []) + )) + self.simplify_primitives() + + ################################################################################################ + # Precompute the SDF map of fixed objects + self.grid_map_sdf_obj_fixed = None + if precompute_sdf_obj_fixed: + with TimerCUDA() as t: + # Compute SDF grid + self.grid_map_sdf_obj_fixed = GridMapSDF( + self.limits, sdf_cell_size, self.obj_fixed_list, tensor_args=self.tensor_args + ) + print(f'Precomputing the SDF grid and gradients took: {t.elapsed:.3f} sec') + + ################################################################################################ + # Occupancy map + self.occupancy_map = None + self.cell_size = None + + def get_obj_list(self): + return self.obj_all_list + + def get_df_obj_list(self, return_extra_objects_only=False): + df_obj_l = [] + if not return_extra_objects_only: + # fixed objects + if self.grid_map_sdf_obj_fixed is not None: + df_obj_l.append(self.grid_map_sdf_obj_fixed) + else: + df_obj_l.extend(self.obj_fixed_list) + + # obj_extra_list objects + if self.obj_extra_list is not None: + df_obj_l.extend(self.obj_extra_list) + + return df_obj_l + + def add_obj(self, obj): + # Adds an object to the environments + raise NotImplementedError + self.simplify_primitives() + + def simplify_primitives(self): + return + # Groups primitives of the same type for faster batched computation + raise NotImplementedError + + def build_occupancy_map(self, cell_size=None): + self.cell_size = cell_size + # Make occupancy grid + map_dim = torch.abs(self.limits[1] - self.limits[0]) + occ_map = OccupancyMap(map_dim, self.cell_size, tensor_args=self.tensor_args) + for obj in self.obj_all_list: + obj.add_to_occupancy_map(occ_map) + occ_map.convert_map_to_torch() + self.occupancy_map = occ_map + + def add_obstacle_primitive(self, obst_primitive): + raise NotImplementedError + self.obst_primitives_l.append(obst_primitive) + self.simplify_primitives() + + def zero_grad(self): + for obj in self.obj_all_list: + try: + obj.zero_grad() + except: + raise NotImplementedError + + def render(self, ax=None): + if self.obj_fixed_list is not None: + for obj in self.obj_fixed_list: + obj.render(ax) + + if self.obj_extra_list is not None: + for obj in self.obj_extra_list: + obj.render(ax, color='red', cmap='Reds') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + # if self.use_occupancy_map: + # res = self.occupancy_map.map.shape[0] + # x = np.linspace(self.limits[0][0], self.limits[1][0], res) + # y = np.linspace(self.limits[0][1], self.limits[1][1], res) + # map = self.occupancy_map.map + # map[map > 1] = 1 + # ax.contourf(x, y, map, 2, cmap='Greys') + # ax.set_aspect('equal') + # ax.set_facecolor('white') + + def compute_sdf(self, x, reshape_shape=None): + # compute sdf of fixed objects + sdf = None + # if the sdf of fixed objects is precomputed, then use it + if self.grid_map_sdf_obj_fixed is not None: + sdf = self.grid_map_sdf_obj_fixed(x) + if reshape_shape: + sdf = sdf.reshape(reshape_shape) + else: + for obj in self.obj_fixed_list: + sdf_obj = obj.compute_signed_distance(x) + if reshape_shape is not None: + sdf_obj = sdf_obj.reshape(reshape_shape) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + + # compute sdf of extra objects + if self.obj_extra_list is not None: + for obj in self.obj_extra_list: + sdf_obj = obj.compute_signed_distance(x) + if reshape_shape is not None: + sdf_obj = sdf_obj.reshape(reshape_shape) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + + return sdf + + def render_sdf(self, ax=None, fig=None): + # draw sdf + xs = torch.linspace(self.limits_np[0][0], self.limits_np[1][0], steps=200, **self.tensor_args) + ys = torch.linspace(self.limits_np[0][1], self.limits_np[1][1], steps=200, **self.tensor_args) + if self.dim == 3: + zs = torch.linspace(self.limits_np[0][2], self.limits_np[1][2], steps=200, **self.tensor_args) + X, Y, Z = torch.meshgrid(xs, ys, zs, indexing='xy') + else: + X, Y = torch.meshgrid(xs, ys, indexing='xy') + + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + if self.dim == 3: + Z_flat = torch.flatten(Z) + stacked_tensors = torch.stack((X_flat, Y_flat, Z_flat), dim=-1).view(-1, 1, self.dim) + else: + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, self.dim) + sdf = self.compute_sdf(stacked_tensors, reshape_shape=X.shape) + + sdf_np = to_numpy(sdf) + if self.dim == 3: + idxs_sdf = torch.where(sdf < 0) + random_idxs = np.random.choice(np.arange(len(idxs_sdf[0])), size=5000, replace=False) # for downsampling + idxs = idxs_sdf[0][random_idxs], idxs_sdf[1][random_idxs], idxs_sdf[2][random_idxs] + ax.scatter(to_numpy(X[idxs]), to_numpy(Y[idxs]), to_numpy(Z[idxs])) + else: + ctf = ax.contourf(to_numpy(X), to_numpy(Y), sdf_np) + if fig is not None: + fig.colorbar(ctf, orientation='vertical') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_title('Binary occupancy map') + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + def render_grad_sdf(self, ax=None, fig=None): + # draw gradient of sdf + xs = torch.linspace(self.limits_np[0][0], self.limits_np[1][0], steps=40, **self.tensor_args) + ys = torch.linspace(self.limits_np[0][1], self.limits_np[1][1], steps=40, **self.tensor_args) + if self.dim == 3: + zs = torch.linspace(self.limits_np[0][2], self.limits_np[1][2], steps=20, **self.tensor_args) + X, Y, Z = torch.meshgrid(xs, ys, zs, indexing='xy') + else: + X, Y = torch.meshgrid(xs, ys, indexing='xy') + + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + if self.dim == 3: + Z_flat = torch.flatten(Z) + stacked_tensors = torch.stack((X_flat, Y_flat, Z_flat), dim=-1).view(-1, 1, self.dim) + else: + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, self.dim) + + f_grad_sdf = lambda x: self.compute_sdf(x, reshape_shape=X.shape).sum() + grad_sdf = jacobian(f_grad_sdf, stacked_tensors) + + grad_sdf_np = to_numpy(grad_sdf).squeeze() + if self.dim == 3: + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), to_numpy(Z_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], grad_sdf_np[:, 2], + length=0.1, normalize=True, + color='red') + else: + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], + color='red') + + ax.set_xlim(self.limits_np[0][0], self.limits_np[1][0]) + ax.set_ylim(self.limits_np[0][1], self.limits_np[1][1]) + if self.dim == 3: + ax.set_zlim(self.limits_np[0][2], self.limits_np[1][2]) + ax.set_zlabel('z') + ax.set_aspect('equal') + + ax.set_xlabel('x') + ax.set_ylabel('y') + + def get_rrt_connect_params(self, robot=None): + raise NotImplementedError + + def get_sgpmp_params(self, robot=None): + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + raise NotImplementedError + + def get_chomp_params(self, robot=None): + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Plot the trajectory. It is a tensor of shape (horizon, state_dim). + fig, ax = plt.subplots() + ax.plot(path[:, 0].detach().cpu().numpy(), path[:, 1].detach().cpu().numpy(), 'r') + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + plt.show() + + return -float('inf') + + def is_start_goal_valid_for_data_gen(self, robot, start_pos, goal_pos): + print("DEFAULTING TO TRUE IN IS_START_GOAL_VALID_FOR_DATA_GEN FUNCTION") + return True + +if __name__ == '__main__': + tensor_args = DEFAULT_TENSOR_ARGS + spheres = MultiSphereField(torch.zeros(2, **tensor_args).view(1, -1), + torch.ones(1, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + boxes = MultiBoxField(torch.zeros(2, **tensor_args).view(1, -1) + 0.5, + torch.ones(2, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + obj_field = ObjectField([spheres, boxes]) + + theta = np.deg2rad(45) + # obj_field.set_position_orientation(pos=[-0.5, 0., 0.]) + # obj_field.set_position_orientation(ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + obj_field.set_position_orientation(pos=[-0.5, 0., 0.], ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + + ############################################################################################################## + env = EnvBase( + name='DummyEnv', + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[obj_field], + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + tensor_args=tensor_args, + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..a4cd378d9089ac8540175b146043e8e907ecb7e8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d.py @@ -0,0 +1,211 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +# Project imports. +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvConveyor2D(EnvBase): + + def __init__(self, + name='EnvConveyor2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + MultiBoxField( + np.array([ + [0, 0], + [0, 0.35], + [0, -0.35] + ] if __import__('os').environ.get('RBT_WIDE') != '1' else [ + # RBT_WIDE: outer walls shifted +-0.05 m outward so each channel + # opens from 0.24 to 0.29 m -- a single r=0.13 robot (0.26 needed) + # fits single-file. Everything else (lengths, scale) unchanged. + [0, 0], + [0, 0.40], + [0, -0.40] + ]), + np.array([ + [0.8, 0.1], + [1.0, 0.1], + [1.0, 0.1] + ]), + tensor_args=tensor_args + ), + + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'conveyor2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=2, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + # Options for position sequences that all paths must go through (each path must choose one). This neeed not be uniformly discretized. The optimizer + # and interpolator will take care of that. + skill0_start = torch.tensor([-0.6, -0.2], **self.tensor_args) + skill0_end = torch.tensor([0.6, -0.2], **self.tensor_args) + skill0 = torch.stack( + [torch.lerp(skill0_start, skill0_end, alpha) for alpha in torch.linspace(0, 1, 30, **self.tensor_args)]) + skill1_start = torch.tensor([0.6, 0.2], **self.tensor_args) + skill1_end = torch.tensor([-0.6, 0.2], **self.tensor_args) + skill1 = torch.stack( + [torch.lerp(skill1_start, skill1_end, alpha) for alpha in torch.linspace(0, 1, 30, **self.tensor_args)]) + skill_pos_sequence_l = [skill0, skill1] + return skill_pos_sequence_l + else: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Compute the data adherence of the path. + # A trajectory is said to be "adhering" if it has either + # 1. Entered the top corridor from the right and left from the left. + # 2. Entered the bottom corridor from the left and left from the right. + # Iterate through the path and find the time, if any, agent passes + # through entrance, middle, and exit of a corridor. + t_top_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + t_bottom_corridor = torch.tensor([-3, -2, -1], **self.tensor_args) # t_enter, t_middle, t_exit + q_top_corridor = torch.tensor([[0.6, 0.2], [0.0, 0.2], [-0.6, 0.2]], **self.tensor_args) + q_bottom_corridor = torch.tensor([[-0.6, -0.2], [0.0, -0.2], [0.6, -0.2]], **self.tensor_args) + for t in range(path.shape[0]): + # Always only check the next available waypoint. + next_available_ix_top = torch.argmin(t_top_corridor) + q_top_next = q_top_corridor[next_available_ix_top, :] + if torch.norm(path[t, :] - q_top_next) < 0.2: + t_top_corridor[next_available_ix_top] = t + next_available_ix_bottom = torch.argmin(t_bottom_corridor) + q_bottom_next = q_bottom_corridor[next_available_ix_bottom, :] + if torch.norm(path[t, :] - q_bottom_next) < 0.2: + t_bottom_corridor[next_available_ix_bottom] = t + if torch.all(t_top_corridor != -1) or torch.all(t_bottom_corridor != -1): + return 1 + else: + return 0 + + +if __name__ == '__main__': + env = EnvConveyor2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..2b2e9aefba6c77f373c24f42658356a532938c5f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_conveyor_2d_extra_objects.py @@ -0,0 +1,88 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard includes. +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +# Project includes. +from torch_robotics.environments import EnvConveyor2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvConveyor2DExtraObjects(EnvConveyor2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.0], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.2], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'conveyor2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvConveyor2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..f2e981b577b5130e407e065d8376bc122f403c1a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_corridor_smd_2d.py @@ -0,0 +1,109 @@ +"""SMD Fig. 3a 'Corridor' -- parallel single-file channels joined by a passing cross-roads. + +The map's challenge is SEQUENCING: channels too narrow for two robots to pass, so they +must take turns. Two earlier designs failed to deliver that: + * the shipped env_narrow_corridors.py (nine spheres of r=0.8375 per wall) leaves a + channel 0.328 wide -- two robots pass abreast easily -- and its scalloping is only a + 1.2x ripple, so there is no real bay; + * a single straight channel with one alcove IS single-file, but a sub-0.20 channel caps + free space near 10% of the arena, about 13 robots standing still. It cannot host 21. + +So: THREE parallel channels, each 0.24 wide (one robot needs 0.21, two need 0.42), joined by a horizontal cross-roads at mid-height that IS wide enough for two, with +open staging areas top and bottom. Robots funnel single-file and must resolve who yields, +while the map still holds a large team. +""" +import numpy as np +import torch + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiBoxField, MultiSphereField + + +class EnvCorridorSMD2D(EnvBase): + + def __init__(self, name='EnvCorridorSMD2D', tensor_args=None, + precompute_sdf_obj_fixed=True, sdf_cell_size=0.005, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + # Four wall columns, each split into an upper and a lower block. What is left: + # channels at x = -0.60, 0.00, 0.60, width 0.16 (single file) + # cross-roads at |y| < 0.12, height 0.24 (two abreast) + # staging areas at |y| > 0.60 (open) + # Channel width 0.36. Sized against the BINDING margin, which is not the one I first + # used: PP's start/goal sampler hardcodes obstacle_margin = 0.16 + # (multi_agent_utils.get_start_goal_pos_random_in_env), stricter than the + # validator's 0.105. At 0.24 only 0.34 m^2 of the map cleared 0.16 while 21 robots + # at 0.15 separation need ~0.44 m^2, so every one of 501 PP attempts failed. At + # 0.36 that area is 0.91 m^2. Still under 0.42, so two robots cannot pass abreast + # and the single-file property -- the point of the map -- survives. + cols = [(-0.89, 0.22), (-0.3, 0.24), (0.3, 0.24), (0.89, 0.22)] + centers, sizes = [], [] + for cx, cw in cols: + for sy in (+0.31, -0.31): + centers.append([cx, sy]) + sizes.append([cw, 0.38]) + obj_list = [ + MultiBoxField(np.array(centers), np.array(sizes), tensor_args=tensor_args), + ] + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[ObjectField(obj_list, 'corridorsmd2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None): + """Required override: EnvBase raises NotImplementedError, and generate_trajectories + calls this unconditionally BEFORE any planning -- so data generation dies on + context 0 without it. None means "no skill prior", the same as env_empty_2d.py:122. + """ + return None + + def compute_traj_data_adherence(self, path: torch.Tensor): + """Required override: the EnvBase default calls plt.show() -- a BLOCKING GUI call + inside the per-agent, per-trial inference loop -- and then returns -inf, which + silently poisons the data-adherence metric for every path. This map has no + preferred direction of travel, so adherence is defined as straight-line + efficiency: chord length over path length, in (0, 1]. + """ + if path.shape[0] < 2: + return 1.0 + seg = torch.linalg.norm(path[1:, :2] - path[:-1, :2], dim=-1).sum() + chord = torch.linalg.norm(path[-1, :2] - path[0, :2]) + if seg <= 0: + return 1.0 + return float(torch.clamp(chord / seg, 0.0, 1.0)) + + def get_rrt_connect_params(self, robot=None): + return dict(n_iters=500, step_size=0.01, n_radius=0.05, + n_pre_samples=50000, max_time=50) + + def get_gpmp2_params(self, robot=None): + # The *_init / *_sample sigmas and solver_params are NOT optional: the trajectory + # generator builds UnaryFactor(sigma=...) from them, and a missing key surfaces as + # `TypeError: unsupported operand type(s) for **: 'NoneType' and 'int'` deep inside + # the factor graph, not as a missing-key error. Copied verbatim from + # env_highways_2d.py:104-124 so the new maps are planned with identical settings. + return dict( + n_support_points=64, dt=0.04, opt_iters=20, num_samples=64, + sigma_start=1e-5, sigma_gp=1e-2, sigma_goal_prior=1e-5, + sigma_coll=1e-5, step_size=1e-1, + sigma_start_init=1e-4, sigma_goal_init=1e-4, sigma_gp_init=1e-5, + sigma_start_sample=1e-4, sigma_goal_sample=1e-4, + solver_params={'delta': 1e-2, 'trust_region': True, 'method': 'cholesky'}, + ) + + +class EnvCorridorSMD2DExtraObjects(EnvCorridorSMD2D): + + def __init__(self, tensor_args=None, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + obj_extra_list = [MultiSphereField(np.array([]), np.array([]), tensor_args=tensor_args)] + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'corridorsmd2d-extraobjects')], + tensor_args=tensor_args, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..347d574b69ac7f68de866626416e411ab8c4d0dc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_cross_room_2d.py @@ -0,0 +1,96 @@ +"""SMD Fig. 3c 'Room' -- a cross-shaped divider splitting the workspace into four rooms. + +SMD's Figure 3 shows three practical maps: a narrow corridor (3a), a shelf (3b) and a +room (3c). Only the corridor ships as code (env_narrow_corridors.py, itself unexported). +This reconstructs 3c: a plus-shaped obstacle at the centre whose arms stop short of the +boundary, so the four quadrants connect only through the four gaps at the edges. That is +the property that makes it interesting for MRMP -- every cross-quadrant trip funnels +through one of four openings, so teams contend for them. + +Built from boxes rather than spheres so the walls are straight, matching the figure. +Arm half-length 0.70 leaves a 0.30 gap at each edge; the OBS_SCALE hook on MultiBoxField +narrows those gaps directly. +""" +import numpy as np +import torch + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiBoxField + + +class EnvCrossRoom2D(EnvBase): + + def __init__(self, name='EnvCrossRoom2D', tensor_args=None, + precompute_sdf_obj_fixed=True, sdf_cell_size=0.005, **kwargs): + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + obj_list = [ + MultiBoxField( + np.array([[0.0, 0.0], [0.0, 0.0]]), # centres: both at the origin + np.array([[1.40, 0.18], [0.18, 1.40]]), # horizontal arm, vertical arm + tensor_args=tensor_args + ), + ] + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), + obj_fixed_list=[ObjectField(obj_list, 'crossroom2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None): + """Required override: EnvBase raises NotImplementedError, and generate_trajectories + calls this unconditionally BEFORE any planning -- so data generation dies on + context 0 without it. None means "no skill prior", the same as env_empty_2d.py:122. + """ + return None + + def compute_traj_data_adherence(self, path: torch.Tensor): + """Required override: the EnvBase default calls plt.show() -- a BLOCKING GUI call + inside the per-agent, per-trial inference loop -- and then returns -inf, which + silently poisons the data-adherence metric for every path. This map has no + preferred direction of travel, so adherence is defined as straight-line + efficiency: chord length over path length, in (0, 1]. + """ + if path.shape[0] < 2: + return 1.0 + seg = torch.linalg.norm(path[1:, :2] - path[:-1, :2], dim=-1).sum() + chord = torch.linalg.norm(path[-1, :2] - path[0, :2]) + if seg <= 0: + return 1.0 + return float(torch.clamp(chord / seg, 0.0, 1.0)) + + def get_rrt_connect_params(self, robot=None): + return dict(n_iters=500, step_size=0.01, n_radius=0.05, + n_pre_samples=50000, max_time=50) + + def get_gpmp2_params(self, robot=None): + # The *_init / *_sample sigmas and solver_params are NOT optional: the trajectory + # generator builds UnaryFactor(sigma=...) from them, and a missing key surfaces as + # `TypeError: unsupported operand type(s) for **: 'NoneType' and 'int'` deep inside + # the factor graph, not as a missing-key error. Copied verbatim from + # env_highways_2d.py:104-124 so the new maps are planned with identical settings. + return dict( + n_support_points=64, dt=0.04, opt_iters=20, num_samples=64, + sigma_start=1e-5, sigma_gp=1e-2, sigma_goal_prior=1e-5, + sigma_coll=1e-5, step_size=1e-1, + sigma_start_init=1e-4, sigma_goal_init=1e-4, sigma_gp_init=1e-5, + sigma_start_sample=1e-4, sigma_goal_sample=1e-4, + solver_params={'delta': 1e-2, 'trust_region': True, 'method': 'cholesky'}, + ) + + +class EnvCrossRoom2DExtraObjects(EnvCrossRoom2D): + + def __init__(self, tensor_args=None, **kwargs): + from torch_robotics.environments.primitives import MultiSphereField + kwargs.pop('instance_idx', None) + kwargs.pop('map_name', None) + obj_extra_list = [MultiSphereField(np.array([]), np.array([]), tensor_args=tensor_args)] + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'crossroom2d-extraobjects')], + tensor_args=tensor_args, **kwargs) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_dense_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_dense_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..32a385465039ebe94f1f97bdbba21262b580be22 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_dense_2d.py @@ -0,0 +1,158 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.robots import RobotPointMass +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDense2D(EnvBase): + + def __init__(self, + name='EnvDense2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array( + [[-0.43378472328186035, 0.3334643840789795], [0.3313474655151367, 0.6288051009178162], + [-0.5656964778900146, -0.484994500875473], [0.42124247550964355, -0.6656165719032288], + [0.05636655166745186, -0.5149664282798767], [-0.36961784958839417, -0.12315540760755539], + [-0.8740217089653015, -0.4034936726093292], [-0.6359214186668396, 0.6683124899864197], + [0.808782160282135, 0.5287870168685913], [-0.023786112666130066, 0.4590069353580475], + [0.11544948071241379, -0.12676022946834564], [0.1455741971731186, 0.16420497000217438], + [0.628413736820221, -0.43461447954177856], [0.17965620756149292, -0.8926276564598083], + [0.6775968670845032, 0.8817358016967773], [-0.3608766794204712, 0.8313458561897278], + ]), + np.array( + [0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, + 0.125, 0.125, + ] + ) + , + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( + # [[0.607781708240509, 0.19512386620044708], [0.5575312972068787, 0.5508843064308167], + # [-0.3352295458316803, -0.6887519359588623], [-0.6572632193565369, 0.31827881932258606], + # [-0.664594292640686, -0.016457155346870422], [0.8165988922119141, -0.19856023788452148], + # [-0.8222246170043945, -0.6448580026626587], [-0.2855989933013916, -0.36841487884521484], + # [-0.8946458101272583, 0.8962447643280029], [-0.23994405567646027, 0.6021060943603516], + # [-0.006193588487803936, 0.8456171751022339], [0.305103600025177, -0.3661990463733673], + # [-0.10704007744789124, 0.1318950206041336], [0.7156378626823425, -0.6923345923423767] + # ] + # ), + # np.array( + # [[0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224], + # [0.20000000298023224, 0.20000000298023224], [0.20000000298023224, 0.20000000298023224] + # ] + # ) + # , + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # environments limits + obj_fixed_list=[ObjectField(obj_list, 'dense2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.3, + n_pre_samples=50000, + max_time=50 + ) + + if isinstance(robot, RobotPointMass): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + n_interpolated_points=None, + dt=0.04, + opt_iters=300, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + if isinstance(robot, RobotPointMass): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + if isinstance(robot, RobotPointMass): + return params + else: + raise NotImplementedError + + +if __name__ == '__main__': + env = EnvDense2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..832f20f9064544fc485b8ab8cfea14511aad20ee --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d.py @@ -0,0 +1,215 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +# Project imports. +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDropRegion2D(EnvBase): + + def __init__(self, + name='EnvDropRegion2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + # MultiSphereField( + # np.array([ + # [0.4, 0.4], + # [-0.4, 0.4], + # [0.4, -0.4], + # [-0.4, -0.4], + # ]), # (n, 2) array of sphere centers. + # np.array([ + # 0.1, + # 0.1, + # 0.1, + # 0.1, + # ]), # (n, ) array of sphere radii. + # tensor_args=tensor_args + # ), + MultiBoxField( + np.array([ + [0.4, 0.4], + [-0.4, 0.4], + [0.4, -0.4], + [-0.4, -0.4], + ]), + np.array([ + [0.4, 0.4], + [0.4, 0.4], + [0.4, 0.4], + [0.4, 0.4], + ]), + tensor_args=tensor_args + ), + ] + + self.drop_region_centers = [ + [0.4, 0.75], + [0.4, 0.05], + [0.4, -0.05], + [0.4, -0.75], + [-0.4, 0.75], + [-0.4, 0.05], + [-0.4, -0.05], + [-0.4, -0.75], + [0.75, 0.4], + [0.05, 0.4], + [-0.05, 0.4], + [-0.75, 0.4], + [0.75, -0.4], + [0.05, -0.4], + [-0.05, -0.4], + [-0.75, -0.4] + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'dense2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=2, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return [ + # Top and bottom of drop regions. + torch.tensor([c] * 35, **self.tensor_args) for c in self.drop_region_centers + ] + else: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor, drop_region_radius=0.15, ratio_traj_steps_in_region=0.25): + # Compute the data adherence of the path. + # We mark 1 if the trajectory visits any of the drop regions for at least num_steps consecutive time steps. + num_steps_in_region = int(path.shape[0] * ratio_traj_steps_in_region) + for c in self.drop_region_centers: + region_center = torch.tensor(c, **self.tensor_args) + dist = torch.norm(path - region_center, dim=-1) + in_region_mask = dist < drop_region_radius + print("AT REGION:", torch.sum(in_region_mask.float())) + # Compute the data adherence. + for i in range(num_steps_in_region, len(path)): + if in_region_mask[i - num_steps_in_region:i].all(): + return 1.0 + return 0.0 + + +if __name__ == '__main__': + env = EnvDropRegion2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..d8bd24923911c2c1e423aa47e4d0afc8ddba1f2d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_drop_region_2d_extra_objects.py @@ -0,0 +1,63 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvDropRegion2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvDropRegion2DExtraObjects(EnvDropRegion2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'dropregion2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvDropRegion2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..53a818f46d1760f7cf38e7e9ed0ce42e3788e796 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d.py @@ -0,0 +1,165 @@ +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from smd.config.smd_params import SMDParams as params + + +class EnvEmpty2D(EnvBase): + + def __init__(self, + name='EnvEmpty2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, 0.0], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.2, 0.2], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'empty2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=10000, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=300, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=0.2, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + + else: + raise NotImplementedError + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + return None + # from torch_robotics.robots import * + # if isinstance(robot, RobotPlanarDisk): + # return [ + # torch.tensor([[0.0, 0.0]]*35, **self.tensor_args), # Pause at (0, 0) for a bit. + # ] + # else: + # raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor, + fraction_of_length=params.data_adherence_linear_deviation_fraction) -> torch.Tensor: + # The score is deviation of the path from a straight line. Cost in {0, 1}. + # The score is 1 for each point on the path within a distance less than fraction_of_length * length from + # the straight line. The computation is the average of the scores for all points in the path. + start_state_pos = path[0][:2] + goal_state_pos = path[-1][:2] + length = torch.norm(goal_state_pos - start_state_pos) + path = path[:, :2] + path = torch.stack([path[:, 0], path[:, 1], torch.zeros_like(path[:, 0])], dim=1) + start_state_pos = torch.stack([start_state_pos[0], start_state_pos[1], torch.zeros_like(start_state_pos[0])]).unsqueeze(0) + goal_state_pos = torch.stack([goal_state_pos[0], goal_state_pos[1], torch.zeros_like(goal_state_pos[0])]).unsqueeze(0) + deviation_from_line = torch.norm(torch.cross(goal_state_pos - start_state_pos, path - start_state_pos), + dim=1) / length + return (deviation_from_line < fraction_of_length).float().mean().item() + + +if __name__ == '__main__': + env = EnvEmpty2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..d7e4877338e3ac8a7767021fabc407847df3dad8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_2d_extra_objects.py @@ -0,0 +1,65 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvEmpty2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEmpty2DExtraObjects(EnvEmpty2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + np.array( + [[1, -1], [1, -0.5], [1, 0], [1, 0.5], [1, 1], [-1, -1], [-1, -0.5], [-1, 0.5], [-1, 1]]), + np.array( + [0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375] + ) + , + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'empty2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvEmpty2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..86fee15c213bed5eb2f8c6c4e558c55645176b85 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d.py @@ -0,0 +1,49 @@ +from typing import List + +import numpy as np +import torch +from matplotlib import pyplot as plt + +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.environments import * +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEmptyNoWait2D(EnvEmpty2D): + + def __init__(self, + name='EnvEmptyNoWait2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + super().__init__( + name=name, + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + +if __name__ == '__main__': + env = EnvEmptyNoWait2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..ce58b306a73e2820dff63c3692003ccbfcc46f40 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_empty_nowait_2d_extra_objects.py @@ -0,0 +1,88 @@ +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +from torch_robotics.environments import EnvEmptyNoWait2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + +import pickle +class EnvEmptyNoWait2DExtraObjects(EnvEmptyNoWait2D): + + def __init__(self, tensor_args=None, **kwargs): + instance_idx = kwargs['instance_idx'] + map_name = kwargs['map_name'] + + file_name = '../../instances_data/'+map_name+'.pkl' + with open(file_name, 'rb') as f: + loaded_set = pickle.load(f) + + obs_info = loaded_set[instance_idx][0][0] + + # get the positions of the obstacles + obs_pos = [] + for obs in obs_info: + obs_pos.append(obs[0].tolist()) + + obs_r = [] + for obs in obs_info: + obs_r.append(obs[1]) + + obj_extra_list = [ + MultiSphereField( + np.array(obs_pos), + np.array(obs_r), + tensor_args=tensor_args + ), + # MultiSphereField( + # np.array( + # [[1, -1], [1, -0.5], [1, 0], [1, 0.5], [1, 1], [-1, -1], [-1, -0.5], [-1, 0.5], [-1, 1]]), + # np.array( + # [0.85, 0.85, 0.85, 0.85, 0.85, 0.85, 0.85, 0.85, 0.85] + # ) + # , + # tensor_args=tensor_args + # ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.2], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.39], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'emptynowait2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvEmptyNoWait2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..d3d666ca51d7bf9c48e3a267777e5bcf29a2ea91 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_ensemble.py @@ -0,0 +1,94 @@ +from typing import Dict +from matplotlib import pyplot as plt + +import torch + +from torch_robotics.environments import * +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvEnsemble(EnvBase): + def __init__(self, + envs: Dict[int, EnvBase], + transforms: Dict[int, torch.Tensor], + tensor_args=None, + **kwargs): + self.envs = envs + self.transforms = transforms # @Note: transforms are the relative positions of the envs, where the first env + # is the absolut reference + + self.env_centers = {k: self.transforms[k] for k in self.envs.keys()} + self.env_limits = {k: self.envs[k].limits for k in self.envs.keys()} + + # loop on envs and transforms, and get the big map limits + if tensor_args is None: + tensor_args = DEFAULT_TENSOR_ARGS + absolute_limits = torch.zeros(self.env_limits[0].shape, **tensor_args) + for k in self.envs.keys(): + limits = self.env_limits[k] + center = self.env_centers[k] + limits = limits + center + absolute_limits[0] = torch.min(absolute_limits[0], limits[0]) + absolute_limits[1] = torch.max(absolute_limits[1], limits[1]) + + obj_list = [] + for k, env in self.envs.items(): + obj_k_list = env.get_obj_list() + for obj in obj_k_list: + # translate the object to the absolute position obj.reference_frame = self.env_centers[k] #TODO: It + # would be nice to have a reference frame for the object and thats enough, but currently there is no + # actual use for it in EnvBase + if obj.pos.shape[0] == 3 and self.env_centers[k].shape[0] == 2: + obj.pos[:2] += self.env_centers[k] + else: + obj.pos += self.env_centers[k] + obj_list.append(obj) + + super().__init__(limits=absolute_limits, + obj_fixed_list=obj_list, + tensor_args=tensor_args, + **kwargs) + + +if __name__ == '__main__': + env0 = EnvEmpty2D(tensor_args=DEFAULT_TENSOR_ARGS) + + env1 = EnvConveyor2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + env2 = EnvSquare2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + env3 = EnvDense2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + + envs = {0: env0, 1: env1, 2: env2, 3: env3} + + transforms = {0: torch.tensor([0., 0.], **DEFAULT_TENSOR_ARGS), + 1: torch.tensor([2., 0.], **DEFAULT_TENSOR_ARGS), + 2: torch.tensor([2., -2.], **DEFAULT_TENSOR_ARGS), + 3: torch.tensor([0., -2.], **DEFAULT_TENSOR_ARGS)} + + env = EnvEnsemble(envs, transforms, tensor_args=DEFAULT_TENSOR_ARGS) + + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..e6fd63c3ad8d12a83e2320b3159c67816e92b790 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d.py @@ -0,0 +1,291 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +# Project imports. +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from smd.common.trajectory_utils import densify_trajs + + +class EnvHighways2D(EnvBase): + + def __init__(self, + name='EnvHighways2D', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array([]), # (n, 2) array of sphere centers. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + MultiBoxField( + np.array([ + [0, 0.0], + [0., 0.875], + [0., -0.875], + [0.875, 0.0], + [-0.875, 0.0], + [0.875, 0.875], + [0.875, -0.875], + [-0.875, 0.875], + [-0.875, -0.875], + ]), + np.array([ + [0.5, 0.5], + [0.5, 0.25], + [0.5, 0.25], + [0.25, 0.5], + [0.25, 0.5], + [0.25, 0.25], + [0.25, 0.25], + [0.25, 0.25], + [0.25, 0.25] + ]), + tensor_args=tensor_args + ), + ] + # np.array([ + # [0, 0.0], + # [0., 0.875], + # [0., -0.875], + # [0.875, 0.0], + # [-0.875, 0.0] + # ]), + # np.array([ + # [0.7, 0.7], + # [0.7, 0.35], + # [0.7, 0.35], + # [0.35, 0.7], + # [0.35, 0.7] + # ]), + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'highways2d')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=500, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=20, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=1e-5, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def is_start_goal_valid_for_data_gen(self, robot, start_pos, goal_pos): + """ + :param robot: Robot object. + :param start_pos: Start position. (q_dim,), often (2, ). + :param goal_pos: Goal position. (q_dim,), often (2, ). + """ + if torch.linalg.norm(start_pos - goal_pos) > 0.6: + return False + # We set squares for starts and goals. Starts can only be at start squares and goals follow similarly. + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + start_region_centers = torch.tensor([ + [0.8, 0.5], + [-0.5, 0.8], + [-0.8, -0.5], + [0.5, -0.8] + ], **self.tensor_args) + goal_region_centers = torch.tensor([ + [0.8, -0.5], + [0.5, 0.8], + [-0.8, 0.5], + [-0.5, -0.8] + ], **self.tensor_args) + start_region_radius = 0.15 + goal_region_radius = 0.15 + if torch.any(torch.norm(start_region_centers - start_pos, dim=-1) < start_region_radius).item() and \ + torch.any(torch.norm(goal_region_centers - goal_pos, dim=-1) < goal_region_radius).item(): + return True + else: + return False + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + + # These are quadrant-midpoints. + ordered_waypoints = torch.tensor([ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5] + ], **self.tensor_args) + + # Find the closest waypoint to the start. + start_pos = start_pos.unsqueeze(0) + ordered_waypoints = ordered_waypoints.unsqueeze(1) + distances = torch.norm(ordered_waypoints - start_pos, dim=-1) + closest_waypoint_start_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_start = ordered_waypoints[closest_waypoint_start_idx].squeeze(0) + + # Find the closest waypoint to the goal. + goal_pos = goal_pos.unsqueeze(0) + distances = torch.norm(ordered_waypoints - goal_pos, dim=-1) + closest_waypoint_goal_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_goal = ordered_waypoints[closest_waypoint_goal_idx].squeeze(0) + + # Create a skill that goes from closest entrance to closest exit via the other waypoints order. + skill_pos_sequence = [closest_waypoint_start] + if closest_waypoint_start_idx == closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + while closest_waypoint_start_idx != closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + skill_pos_sequence.append(ordered_waypoints[closest_waypoint_start_idx].squeeze(0)) + # skill_pos_sequence.append(closest_waypoint_goal) + skill_pos_sequence_l = [torch.stack(skill_pos_sequence)] + + skill_pos_sequence_l = densify_trajs(skill_pos_sequence_l, n_points_interp=10) + # Get rid of the first few points and the last few points. We only care about the homotopy. + skill_pos_sequence_l = [skill_pos_sequence_l[0][4:-4]] + # Add noise. + skill_pos_sequence_l += [skill_pos_sequence_l[i] + torch.randn_like(skill_pos_sequence_l[i]) * 0.01 for i in range(len(skill_pos_sequence_l))] + + # Show the skill. + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots() + # print(skill_pos_sequence_l[0]) + # ax.plot(skill_pos_sequence_l[0][:, 0].detach().cpu().detach().cpu().numpy(), skill_pos_sequence_l[0][:, 1].detach().cpu().numpy(), 'r') + # ax.scatter(start_pos[0, 0].item(), start_pos[0, 1].item(), c='g') + # ax.scatter(goal_pos[0, 0].item(), goal_pos[0, 1].item(), c='b') + # ax.set_xlim(-1, 1) + # ax.set_ylim(-1, 1) + # plt.show() + + return skill_pos_sequence_l + else: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Compute vectors between consecutive points + vectors = path[:,:] + + # Normalize the vectors + norms = torch.norm(vectors, dim=1, keepdim=True) + vectors_normalized = vectors / norms + + # Get pairs of consecutive normalized vectors + vec1 = vectors_normalized[:-1] + vec2 = vectors_normalized[1:] + + # Compute the 2D cross products of consecutive vectors + cross_products = vec1[:, 0] * vec2[:, 1] - vec1[:, 1] * vec2[:, 0] + + # Sum the cross products and determine if counterclockwise or clockwise + aggregate_cross_product = torch.sum(cross_products) + + return 1 if aggregate_cross_product > 0 else 0 + +if __name__ == '__main__': + env = EnvHighways2D( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py new file mode 100644 index 0000000000000000000000000000000000000000..d1cd547f02bdd7b274e327eb0c5321f846e08f78 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_highways_2d_extra_objects.py @@ -0,0 +1,88 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard includes. +import numpy as np +import torch +from matplotlib import pyplot as plt +from torch.autograd.functional import jacobian + +# Project includes. +from torch_robotics.environments import EnvHighways2D +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + +class EnvHighways2DExtraObjects(EnvHighways2D): + + def __init__(self, tensor_args=None, **kwargs): + obj_extra_list = [ + MultiSphereField( + # np.array([[0.5, 0.5]]), # (n, 2) array of sphere centers. + np.array([]), # (n, 2) array of sphere centers. + # np.array([.04]), # (n, ) array of sphere radii. + np.array([]), # (n, ) array of sphere radii. + tensor_args=tensor_args + ), + # MultiBoxField( + # np.array( # (n, 2) array of box centers. + # [ + # [0.0, -0.0], + # ] + # ), + # np.array( # (n, 2) array of box sizes. + # [ + # [0.4, 0.2], + # ] + # ), + # tensor_args=tensor_args + # ) + ] + + super().__init__( + name=self.__class__.__name__, + obj_extra_list=[ObjectField(obj_extra_list, 'conveyor2d-extraobjects')], + tensor_args=tensor_args, + **kwargs + ) + + +if __name__ == '__main__': + env = EnvHighways2DExtraObjects( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_narrow_corridors.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_narrow_corridors.py new file mode 100644 index 0000000000000000000000000000000000000000..185beb55a799072ba277313884c8f005ae676425 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/env_narrow_corridors.py @@ -0,0 +1,260 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# General imports. +import numpy as np +import torch +from matplotlib import pyplot as plt +from typing import List +# Project imports. +from torch_robotics.environments.env_base import EnvBase +from torch_robotics.environments.primitives import ObjectField, MultiSphereField, MultiBoxField +from torch_robotics.environments.utils import create_grid_spheres +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +from smd.common.trajectory_utils import densify_trajs + + +class EnvNarrowCorridors(EnvBase): + + def __init__(self, + name='EnvNarrowCorridors', + tensor_args=None, + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.005, + **kwargs + ): + + obj_list = [ + MultiSphereField( + np.array( + [[1, -1], [1, -0.5], [1, 0], [1, 0.5], [1, 1], [-1, -1], [-1, -0.5], [-1, 0.5], [-1, 1]]), + np.array( + [0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375, 0.8375] + ) + , + tensor_args=tensor_args + ), + ] + + super().__init__( + name=name, + limits=torch.tensor([[-1, -1], [1, 1]], **tensor_args), # Environments limits. + obj_fixed_list=[ObjectField(obj_list, 'narrowcorridors')], + precompute_sdf_obj_fixed=precompute_sdf_obj_fixed, + sdf_cell_size=sdf_cell_size, + tensor_args=tensor_args, + **kwargs + ) + + def get_rrt_connect_params(self, robot=None): + params = dict( + n_iters=500, + step_size=0.01, + n_radius=0.05, + n_pre_samples=50000, + max_time=50 + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_gpmp2_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=20, + num_samples=64, + sigma_start=1e-5, + sigma_gp=1e-2, + sigma_goal_prior=1e-5, + sigma_coll=1e-5, + step_size=1e-1, + sigma_start_init=1e-4, + sigma_goal_init=1e-4, + sigma_gp_init=1e-5, + sigma_start_sample=1e-4, + sigma_goal_sample=1e-4, + solver_params={ + 'delta': 1e-2, + 'trust_region': True, + 'method': 'cholesky', + }, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + def get_chomp_params(self, robot=None): + params = dict( + n_support_points=64, + dt=0.04, + opt_iters=1, # Keep this 1 for visualization + weight_prior_cost=1e-4, + step_size=0.05, + grad_clip=0.05, + sigma_start_init=0.001, + sigma_goal_init=0.001, + sigma_gp_init=0.3, + pos_only=False, + ) + + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + return params + else: + raise NotImplementedError + + # def is_start_goal_valid_for_data_gen(self, robot, start_pos, goal_pos): + # """ + # :param robot: Robot object. + # :param start_pos: Start position. (q_dim,), often (2, ). + # :param goal_pos: Goal position. (q_dim,), often (2, ). + # """ + # if torch.linalg.norm(start_pos - goal_pos) > 0.6: + # return False + # # We set squares for starts and goals. Starts can only be at start squares and goals follow similarly. + # from torch_robotics.robots import RobotPlanarDisk + # if isinstance(robot, RobotPlanarDisk): + # start_region_centers = torch.tensor([ + # [0.8, 0.5], + # [-0.5, 0.8], + # [-0.8, -0.5], + # [0.5, -0.8] + # ], **self.tensor_args) + # goal_region_centers = torch.tensor([ + # [0.8, -0.5], + # [0.5, 0.8], + # [-0.8, 0.5], + # [-0.5, -0.8] + # ], **self.tensor_args) + # start_region_radius = 0.15 + # goal_region_radius = 0.15 + # if torch.any(torch.norm(start_region_centers - start_pos, dim=-1) < start_region_radius).item() and \ + # torch.any(torch.norm(goal_region_centers - goal_pos, dim=-1) < goal_region_radius).item(): + # return True + # else: + # return False + + def get_skill_pos_seq_l(self, robot=None, start_pos=None, goal_pos=None) -> List[torch.Tensor]: + from torch_robotics.robots import RobotPlanarDisk + if isinstance(robot, RobotPlanarDisk): + + # These are quadrant-midpoints. + ordered_waypoints = torch.tensor([ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5] + ], **self.tensor_args) + + # Find the closest waypoint to the start. + start_pos = start_pos.unsqueeze(0) + ordered_waypoints = ordered_waypoints.unsqueeze(1) + distances = torch.norm(ordered_waypoints - start_pos, dim=-1) + closest_waypoint_start_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_start = ordered_waypoints[closest_waypoint_start_idx].squeeze(0) + + # Find the closest waypoint to the goal. + goal_pos = goal_pos.unsqueeze(0) + distances = torch.norm(ordered_waypoints - goal_pos, dim=-1) + closest_waypoint_goal_idx = torch.argmin(distances, dim=0).item() + closest_waypoint_goal = ordered_waypoints[closest_waypoint_goal_idx].squeeze(0) + + # Create a skill that goes from closest entrance to closest exit via the other waypoints order. + skill_pos_sequence = [closest_waypoint_start] + if closest_waypoint_start_idx == closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + while closest_waypoint_start_idx != closest_waypoint_goal_idx: + closest_waypoint_start_idx = (closest_waypoint_start_idx + 1) % len(ordered_waypoints) + skill_pos_sequence.append(ordered_waypoints[closest_waypoint_start_idx].squeeze(0)) + # skill_pos_sequence.append(closest_waypoint_goal) + skill_pos_sequence_l = [torch.stack(skill_pos_sequence)] + + skill_pos_sequence_l = densify_trajs(skill_pos_sequence_l, n_points_interp=10) + # Get rid of the first few points and the last few points. We only care about the homotopy. + skill_pos_sequence_l = [skill_pos_sequence_l[0][4:-4]] + # Add noise. + skill_pos_sequence_l += [skill_pos_sequence_l[i] + torch.randn_like(skill_pos_sequence_l[i]) * 0.01 for i in range(len(skill_pos_sequence_l))] + + # Show the skill. + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots() + # print(skill_pos_sequence_l[0]) + # ax.plot(skill_pos_sequence_l[0][:, 0].detach().cpu().detach().cpu().numpy(), skill_pos_sequence_l[0][:, 1].detach().cpu().numpy(), 'r') + # ax.scatter(start_pos[0, 0].item(), start_pos[0, 1].item(), c='g') + # ax.scatter(goal_pos[0, 0].item(), goal_pos[0, 1].item(), c='b') + # ax.set_xlim(-1, 1) + # ax.set_ylim(-1, 1) + # plt.show() + + return skill_pos_sequence_l + else: + raise NotImplementedError + + def compute_traj_data_adherence(self, path: torch.Tensor): + # Compute vectors between consecutive points + vectors = path[:,:] + + # Normalize the vectors + norms = torch.norm(vectors, dim=1, keepdim=True) + vectors_normalized = vectors / norms + + # Get pairs of consecutive normalized vectors + vec1 = vectors_normalized[:-1] + vec2 = vectors_normalized[1:] + + # Compute the 2D cross products of consecutive vectors + cross_products = vec1[:, 0] * vec2[:, 1] - vec1[:, 1] * vec2[:, 0] + + # Sum the cross products and determine if counterclockwise or clockwise + aggregate_cross_product = torch.sum(cross_products) + + return 1 if aggregate_cross_product > 0 else 0 + +if __name__ == '__main__': + env = EnvNarrowCorridors( + precompute_sdf_obj_fixed=True, + sdf_cell_size=0.01, + tensor_args=DEFAULT_TENSOR_ARGS + ) + fig, ax = create_fig_and_axes(env.dim) + env.render(ax) + plt.show() + + # Render sdf + fig, ax = create_fig_and_axes(env.dim) + env.render_sdf(ax, fig) + + # Render gradient of sdf + env.render_grad_sdf(ax, fig) + plt.show() + + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py new file mode 100644 index 0000000000000000000000000000000000000000..3fbd711c75e310c07b6290105e7ca5e2e9b7f146 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/grid_map_sdf.py @@ -0,0 +1,117 @@ +from math import ceil + +import matplotlib.pyplot as plt +import numpy as np +import torch +from torch.autograd.functional import jacobian + + +class GridMapSDF: + """ + Generates an SDF grid. + """ + def __init__(self, limits, cell_size, obj_list, tensor_args=None): + + self.limits = limits + self.dim = limits.shape[-1] + + self.tensor_args = tensor_args + + # objects + self.obj_list = obj_list + + # SDF grids + map_dim = torch.abs(limits[1] - limits[0]) + self.map_dim = map_dim + self.cell_size = cell_size + self.cmap_dim = torch.ceil(map_dim/cell_size).type(torch.LongTensor).to(self.tensor_args['device']) + + self.points_for_sdf = None + self.sdf_tensor = None + self.grad_sdf_tensor = None + self.precompute_sdf() + + def precompute_sdf(self): + # create voxel grid of points + basis_ranges = [ + torch.linspace(self.limits[0][0], self.limits[1][0], self.cmap_dim[0], **self.tensor_args), + torch.linspace(self.limits[0][1], self.limits[1][1], self.cmap_dim[1], **self.tensor_args), + ] + if self.dim == 3: + basis_ranges.append( + torch.linspace(self.limits[0][2], self.limits[1][2], self.cmap_dim[2], **self.tensor_args) + ) + points_for_sdf_meshgrid = torch.meshgrid(*basis_ranges, indexing='ij') + self.points_for_sdf = torch.stack(points_for_sdf_meshgrid, dim=-1) + + f_grad_sdf = lambda x: self.compute_signed_distance_raw(x).sum() + sdf_tensor_l = [] + grad_sdf_tensor_l = [] + # TODO - compute sdf and gradient in batches to prevent memory overflow + for i in range(self.points_for_sdf.shape[0]): + torch.cuda.empty_cache() + # sdf + points_sdf = self.points_for_sdf[i] + sdf_tensor = self.compute_signed_distance_raw(points_sdf) + sdf_tensor_l.append(sdf_tensor) + # gradient of sdf + grad_sdf_tensor = jacobian(f_grad_sdf, points_sdf) + grad_sdf_tensor_l.append(grad_sdf_tensor) + torch.cuda.empty_cache() + + self.sdf_tensor = torch.stack(sdf_tensor_l, dim=0) + self.grad_sdf_tensor = torch.stack(grad_sdf_tensor_l, dim=0) + + def compute_signed_distance_raw(self, x): + sdf = None + for obj in self.obj_list: + sdf_obj = obj.compute_signed_distance(x) + if sdf is None: + sdf = sdf_obj + else: + sdf = torch.minimum(sdf, sdf_obj) + return sdf + + def __call__(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def compute_cost(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def compute_signed_distance(self, X, **kwargs): + return self.get_sdf(X, **kwargs) + + def get_sdf(self, X, **kwargs): + """ + Checks for collision in a batch of trajectories using the generated occupancy grid (i.e. obstacle map), and + returns sum of collision costs for the entire batch. + + :param weight: weight on obstacle cost, float tensor. + :param X: Tensor of trajectories, of shape (batch_size, horizon, task_spaces, position_dim) + :return: collision cost on the trajectories + """ + X_in_map = ((X-self.limits[0])/self.map_dim * self.cmap_dim).floor().to(torch.int) + + # Project out-of-bounds locations to axis + max_idx = torch.tensor(self.points_for_sdf.shape[:-1]).type_as(X_in_map) - 1 + X_in_map = X_in_map.clamp(torch.zeros_like(max_idx), max_idx) + + # SDFs and gradients + # To compute the gradients, because we already have computed the gradients, we use a surrogate sdf function + # surrogate_sdf(x) = sdf(x_detachted) + x @ grad_sdf(x_detachted) - x_detached @ grad_sdf(x_detachted) + # This surrogate has the property + # surrogate_sdf(x) = sdf(x_detachted) + # grad_x_surrogate_sdf(x) = grad_sdf(x_detachted) + X_in_map_detached = X_in_map.detach() + X_query = torch.unbind(X_in_map_detached, dim=-1) + + sdf_vals = self.sdf_tensor[X_query] + grad_sdf = self.grad_sdf_tensor[X_query] + + X_detached = X.detach() + sdf_vals += (X * grad_sdf).sum(-1) - (X_detached * grad_sdf).sum(-1) + + return sdf_vals + + def zero_grad(self): + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/objects.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/objects.py new file mode 100644 index 0000000000000000000000000000000000000000..ac1a351dbf62757ebd10e32460680890fdd9a29d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/objects.py @@ -0,0 +1,89 @@ +import abc + +import torch +from urdf_parser_py.urdf import Box + +from torch_robotics.environments.primitives import ObjectField, MultiBoxField +from torch_robotics.torch_utils.torch_utils import to_numpy + + +class GraspedObject(ObjectField): + + def __init__(self, primitive_fields, **kwargs): + # pos, ori - position and orientation are specified wrt to the end-effector link + + # Only one primitive type + assert len(primitive_fields) == 1 + + super().__init__(primitive_fields, **kwargs) + + # Geometry URDF + self.geometry_urdf = self.get_geometry_urdf() + + def get_geometry_urdf(self): + primitive_field = self.fields[0] + + if isinstance(primitive_field, MultiBoxField): + size = primitive_field.sizes[0] + return Box(to_numpy(size)) + else: + raise NotImplementedError + + @abc.abstractmethod + def get_base_points_for_collision(self): + raise NotImplementedError + + +class GraspedObjectPandaBox(GraspedObject): + + def __init__(self, tensor_args=None, **kwargs): + # One box + primitive_fields = [ + MultiBoxField(torch.zeros(3, **tensor_args).view(1, -1), + torch.tensor([0.05, 0.05, 0.15], **tensor_args).view(1, -1), + tensor_args=tensor_args) + ] + + # position and orientation wrt to the robots's end-effector link -> for panda reference_frame='panda_hand' + pos = torch.tensor([0., 0., 0.11], **tensor_args) + ori = torch.tensor([0, 0.7071081, 0, 0.7071055], **tensor_args) + + super().__init__( + primitive_fields, + name='GraspedObjectPandaBox', + pos=pos, ori=ori, reference_frame='panda_hand', + **kwargs) + + self.base_points_for_collision = self.get_base_points_for_collision() + self.n_base_points_for_collision = len(self.base_points_for_collision) + + def get_base_points_for_collision(self): + # points on vertices and centers of faces + size = self.fields[0].sizes[0] + x, y, z = size + vertices = torch.tensor( + [ + [x/2, y/2, -z/2], + [x/2, -y/2, -z/2], + [-x/2, -y/2, -z/2], + [-x/2, y/2, -z/2], + [x/2, y/2, z/2], + [x/2, -y/2, z/2], + [-x/2, -y/2, z/2], + [-x/2, y/2, z/2], + ], + **self.tensor_args) + + faces = torch.tensor( + [ + [x/2, 0, 0], + [0, -y/2, 0], + [-x/2, 0, 0], + [0, y/2, 0], + [0, 0, z/2], + [0, 0, -z/2], + ], + **self.tensor_args) + + points = torch.cat((vertices, faces), dim=0) + return points diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/occupancy_map.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/occupancy_map.py new file mode 100644 index 0000000000000000000000000000000000000000..06a6a95182da52cd2c1359b69f8961515fdab6e7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/occupancy_map.py @@ -0,0 +1,172 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le, João Carvalho + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le, João Carvalho since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) 2022 Sasha Lambert + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from math import ceil + +import matplotlib.pyplot as plt +import numpy as np +import torch + + +class OccupancyMap: + """ + Generates an occupancy grid. + """ + def __init__(self, map_dim, cell_size, tensor_args=None): + + for d in map_dim: + assert d % 2 == 0, f"Dimension {d} is not an even number" + + self.map_dim = map_dim + + self.n_dim = len(map_dim) + + if tensor_args is None: + tensor_args = {'device': torch.device('cpu'), 'dtype': torch.float32} + self.tensor_args = tensor_args + + self.cmap_dim = [0 for _ in map_dim] + for i, d in enumerate(self.cmap_dim): + self.cmap_dim[i] = ceil(map_dim[i]/cell_size) + + self.map = np.zeros(self.cmap_dim) + self.map_torch = None + + self.cell_size = cell_size + + # Map center (in cells) + self.origin = np.array([d//2 for d in self.cmap_dim]) + self.c_offset = torch.Tensor(self.origin).to(**tensor_args) + + # limits + self.dims = self.map.shape + self.lims = [(-self.cell_size * d/2, self.cell_size * d/2) for d in self.dims] + + def convert_map_to_torch(self): + self.map_torch = torch.Tensor(self.map).to(**self.tensor_args) + return self.map_torch + + def get_collisions(self, X, **kwargs): + """ + Checks for collision in a batch of trajectories using the generated occupancy grid (i.e. obstacle map), and + returns sum of collision costs for the entire batch. + + :param weight: weight on obstacle cost, float tensor. + :param X: Tensor of trajectories, of shape (batch_size, horizon, task_spaces, position_dim) + :return: collision cost on the trajectories + """ + + X_occ = X * (1/self.cell_size) + self.c_offset + X_occ = X_occ.floor() + + # X_occ = X_occ.cpu().numpy().astype(np.int) + X_occ = X_occ.type(torch.LongTensor) + X_occ = X_occ.to(device=self.tensor_args['device']) + + # Project out-of-bounds locations to axis + for i in range(X_occ.shape[-1]): + X_occ[..., i] = X_occ[..., i].clamp(0, self.map.shape[i]-1) + + # Collisions + try: + if X_occ.shape[-1] == 2: + collision_vals = self.map_torch[X_occ[..., 1], X_occ[..., 0]] + elif X_occ.shape[-1] == 3: + collision_vals = self.map_torch[X_occ[..., 1], X_occ[..., 0], X_occ[..., 2]] + else: + raise NotImplementedError + except Exception as e: + print(e) + print(X_occ) + print(X_occ.clamp(0, self.map.shape[0]-1)) + return collision_vals + + def compute_distances(self, X, **kwargs): + """ + Computes euclidean distances of X to all points in the occupied grid + """ + X_grid_points_idxs = torch.nonzero(self.map_torch > 0) + X_grid_points_task_space = (X_grid_points_idxs - self.c_offset) * self.cell_size + distances = torch.cdist(X, X_grid_points_task_space, p=2.0) + return distances + + def compute_cost(self, X, **kwargs): + return self.get_collisions(X, **kwargs) + + def zero_grad(self): + pass + + def plot(self, ax=None, save_dir=None, filename="obst_map.png"): + if ax is None: + if self.n_dim == 2: + fig, ax = plt.subplots() + else: + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + if self.n_dim == 2: + res = self.map.shape[0] + x = np.linspace(self.lims[0][0], self.lims[0][1], res) + y = np.linspace(self.lims[1][0], self.lims[1][1], res) + ax.contourf(x, y, np.clip(self.map, 0, 1), 2, cmap='Greys') + else: + x, y, z = np.indices(np.array(self.map.shape) + 1, dtype=float) + x -= self.origin[0] + x = x * self.cell_size + y -= self.origin[1] + y = y * self.cell_size + z -= self.origin[2] + z = z * self.cell_size + ax.voxels(y, x, z, self.map, facecolors='gold', edgecolor='black', shade=False, alpha=0.05) + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/primitives.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/primitives.py new file mode 100644 index 0000000000000000000000000000000000000000..e11bbaaa02bba1b25b98476212279d8f4f0f010b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/primitives.py @@ -0,0 +1,653 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from math import ceil +from typing import List + +import einops +import numpy as np +import torch +from matplotlib import pyplot as plt, transforms +from matplotlib.patches import FancyBboxPatch, BoxStyle +from torch.autograd.functional import jacobian + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_rotation_matrix +from torch_robotics.torch_kinematics_tree.geometrics.utils import transform_point, rotate_point +from torch_robotics.torch_utils.torch_utils import DEFAULT_TENSOR_ARGS, to_torch, to_numpy, tensor_linspace_v1 +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes + + + +class PrimitiveShapeField(ABC): + """ + Represents a primitive object in N-D. + """ + + def __init__(self, dim=3, tensor_args=None): + self.dim = dim + if tensor_args is None: + tensor_args = DEFAULT_TENSOR_ARGS + self.tensor_args = tensor_args + + def compute_signed_distance(self, x): + """ + Returns the signed distance at x. + Parameters + ---------- + x : torch array (batch, dim) or (batch, horizon, dim) + """ + return self.compute_signed_distance_impl(x) + + @abstractmethod + def compute_signed_distance_impl(self, x): + # The SDF computed here assumes the primitive shape main center is located at the origin, and there is no + # rotation. + # Note that this center is different from e.g. the centers for spheres in MultiSphereField, since a primitive + # shape can be made of multiple shapes. + raise NotImplementedError() + + def zero_grad(self): + pass + + def obstacle_collision_check(self, obst_map): + valid = True + obst_map_test = self.add_to_occupancy_map(deepcopy(obst_map)) + if np.any(obst_map_test.map > 1): + valid = False + return valid + + def point_collision_check(self, obst_map, pts): + valid = True + if pts is not None: + obst_map_test = self.add_to_occupancy_map(np.copy(obst_map)) + for pt in pts: + if obst_map_test[ceil(pt[0]), ceil(pt[1])] >= 1: + valid = False + break + return valid + + @abstractmethod + def add_to_occupancy_map(self, obst_map): + # Adds obstacle to an occupancy grid + raise NotImplementedError + + @abstractmethod + def render(self, ax, pos=None, ori=None, color=None, **kwargs): + raise NotImplementedError + + +def plot_sphere(ax, center, pos, radius, cmap): + u, v = np.mgrid[0:2 * np.pi:30j, 0:np.pi:20j] + x = radius * (np.cos(u) * np.sin(v)) + y = radius * (np.sin(u) * np.sin(v)) + z = radius * np.cos(v) + ax.plot_surface( + x + center[0] + pos[0], y + center[1] + pos[1], z + center[2] + pos[2], + cmap=cmap, + alpha=1 + ) + + +class MultiSphereField(PrimitiveShapeField): + + def __init__(self, centers, radii, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Centers of the spheres. + radii : numpy array + Radii of the spheres. + """ + super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) + self.centers = to_torch(centers, **self.tensor_args) + # OBS_SCALE difficulty axis (sphere obstacles). Default 1.0 reproduces the + # shipped geometry exactly. '' -> default, not a crash. + _obs_scale = float(__import__('os').environ.get('OBS_SCALE') or '1.0') + self.radii = to_torch(radii, **self.tensor_args) * _obs_scale + + def __repr__(self): + return f"MultiSphereField(centers={self.centers}, radii={self.radii})" + + def compute_signed_distance_impl(self, x): + if len(self.centers) == 0: + return torch.ones_like(x[..., 0]) + + distance_to_centers = torch.norm(x.unsqueeze(-2) - self.centers.unsqueeze(0), dim=-1) + # sdfs = distance_to_centers - self.radii.unsqueeze(0) + sdfs = distance_to_centers - self.radii + return torch.min(sdfs, dim=-1)[0] + + def zero_grad(self): + self.centers.grad = None + self.radii.grad = None + + def add_to_occupancy_map(self, obst_map): + # Adds obstacle to occupancy map + for center, radius in zip(self.centers, self.radii): + n_dim = len(center) + # Convert dims to cell indices + c_r = ceil(radius / obst_map.cell_size) + c_x = ceil(center[0] / obst_map.cell_size) + c_y = ceil(center[1] / obst_map.cell_size) + + # centers in cell indices + obst_map_origin_xi = obst_map.origin[0] + obst_map_origin_yi = obst_map.origin[1] + + c_x_cell = c_x + obst_map_origin_xi + c_y_cell = c_y + obst_map_origin_yi + + obst_map_x_dim = obst_map.dims[0] + obst_map_y_dim = obst_map.dims[1] + + if n_dim == 3: + c_z = ceil(center[2] / obst_map.cell_size) + obst_map_origin_zi = obst_map.origin[2] + c_z_cell = c_z + obst_map_origin_zi + obst_map_z_dim = obst_map.dims[2] + + for i in range(c_x_cell - 2 * c_r, c_x_cell + 2 * c_r): + if i < 0 or i >= obst_map_x_dim: + continue + for j in range(c_y_cell - 2 * c_r, c_y_cell + 2 * c_r): + if j < 0 or j >= obst_map_y_dim: + continue + + if n_dim == 3: + for k in range(c_z_cell - 2 * c_r, c_z_cell + 2 * c_r): + if k < 0 or k >= obst_map_z_dim: + continue + + p = torch.tensor([(i - obst_map_origin_xi) * obst_map.cell_size, + (j - obst_map_origin_yi) * obst_map.cell_size, + (k - obst_map_origin_zi) * obst_map.cell_size + ], + **self.tensor_args) + + if self.is_inside(p, center, radius): + obst_map.map[j, i, k] += 1 + + else: + p = torch.tensor([(i - obst_map_origin_xi) * obst_map.cell_size, + (j - obst_map_origin_yi) * obst_map.cell_size], + **self.tensor_args) + + if self.is_inside(p, center, radius): + obst_map.map[j, i] += 1 + return obst_map + + @staticmethod + def is_inside(p, center, radius): + # Check if point p is inside the discretized sphere + return torch.linalg.norm(p - center) <= radius + + def render(self, ax, pos=None, ori=None, color='wheat', cmap='wheat', **kwargs): + for center, radius in zip(self.centers, self.radii): + center = to_numpy(center) + radius = to_numpy(radius) + if pos is None: + pos = np.zeros(self.dim) + else: + pos = to_numpy(pos) + # orientation is not needed, because the shape is symmetric + if ax.name == '3d': + plot_sphere(ax, center, pos, radius, cmap) + else: + circle = plt.Circle((center[0] + pos[0], center[1] + pos[1]), radius, color=color, linewidth=0, alpha=1) + ax.add_patch(circle) + + +def patch_rotate_translate(ax, patch, rot, trans): + rot_angle_deg = np.rad2deg(np.arctan2(rot[1, 0], rot[0, 0])) + affine_transf = transforms.Affine2D().rotate_deg(rot_angle_deg).translate(trans[0], trans[1]) + ax.transData + patch.set_transform(affine_transf) + ax.add_patch(patch) + + +class MultiBoxField(PrimitiveShapeField): + + def __init__(self, centers, sizes, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Center of the boxes. + sizes: numpy array + Sizes of the boxes. + """ + super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) + self.centers = to_torch(centers, **self.tensor_args) + # OBS_SCALE difficulty axis: inflates every box about its own centre. + _obs_scale = float(__import__('os').environ.get('OBS_SCALE') or '1.0') + self.sizes = to_torch(sizes, **self.tensor_args) * _obs_scale + self.half_sizes = self.sizes / 2 + + def __repr__(self): + return f"MultiBoxField(centers={self.centers}, sizes={self.sizes})" + + def compute_signed_distance_impl(self, x): + distance_to_centers = torch.abs(x.unsqueeze(-2) - self.centers.unsqueeze(0)) + sdfs = torch.max(distance_to_centers - self.half_sizes.unsqueeze(0), dim=-1)[0] + return torch.min(sdfs, dim=-1)[0] + + def zero_grad(self): + self.centers.grad = None + self.sizes.grad = None + self.half_sizes.grad = None + + def add_to_occupancy_map(self, obst_map): + for center, size in zip(self.centers, self.sizes): + n_dim = len(center) + width = size[0] + height = size[1] + # Convert dims to cell indices + w = ceil(width / obst_map.cell_size) + h = ceil(height / obst_map.cell_size) + c_x = ceil(center[0] / obst_map.cell_size) + c_y = ceil(center[1] / obst_map.cell_size) + + obst_map_origin_xi = obst_map.origin[0] + obst_map_origin_yi = obst_map.origin[1] + + if n_dim == 2: + obst_map.map[ + c_y - ceil(h / 2.) + obst_map_origin_yi:c_y + ceil(h / 2.) + obst_map_origin_yi, + c_x - ceil(w / 2.) + obst_map_origin_xi:c_x + ceil(w / 2.) + obst_map_origin_xi + ] += 1 + else: + depth = size[2] + c_z = ceil(center[2] / obst_map.cell_size) + obst_map_origin_zi = obst_map.origin[2] + d = ceil(depth / obst_map.cell_size) + obst_map.map[ + c_y - ceil(h / 2.) + obst_map_origin_yi:c_y + ceil(h / 2.) + obst_map_origin_yi, + c_x - ceil(w / 2.) + obst_map_origin_xi:c_x + ceil(w / 2.) + obst_map_origin_xi, + c_z - ceil(d / 2.) + obst_map_origin_zi:c_z + ceil(d / 2.) + obst_map_origin_zi + ] += 1 + return obst_map + + def get_cube(self): + phi = torch.arange(1, 10, 2, **self.tensor_args) * torch.pi / 4 + Phi, Theta = torch.meshgrid(phi, phi, indexing="ij") + x = torch.cos(Phi) * torch.sin(Theta) + y = torch.sin(Phi) * torch.sin(Theta) + z = torch.cos(Theta) / np.sqrt(2) + return x, y, z + + def render(self, ax, pos=None, ori=None, color='wheat', cmap='gray', **kwargs): + + rot = q_to_rotation_matrix(ori).squeeze() + if ax.name == '3d': + x, y, z = self.get_cube() + for center, size in zip(self.centers, self.sizes): + cx, cy, cz = center + a, b, c = size + + points_x = cx + x * a + points_y = cy + y * b + points_z = cz + z * c + + points = torch.stack((points_x.ravel(), points_y.ravel(), points_z.ravel()), dim=-1) + points = transform_point(points, rot, pos) + + d = x.shape[0] + points_x = points[:, 0].view(d, d) + points_y = points[:, 1].view(d, d) + points_z = points[:, 2].view(d, d) + + points_x_np, points_y_np, points_z_np = to_numpy(points_x), to_numpy(points_y), to_numpy(points_z) + # TODO - implemented drawing of rounded boxes in 3D + ax.plot_surface(points_x_np, points_y_np, points_z_np, cmap=cmap, alpha=0.25) + else: + for i, (center, size) in enumerate(zip(self.centers, self.sizes)): + cx, cy = to_numpy(center) + a, b = to_numpy(size) + pos_np = to_numpy(pos) + # by definition a rotation in the xy-plane is around the z-axis + rot_np = to_numpy(rot[:2, :2]) + point = np.array([cx - a / 2, cy - b / 2]) + self.draw_box(ax, i, point, a, b, rot_np, pos_np[:2], color) + + def draw_box(self, ax, i, point, a, b, rot, trans, color='wheat'): + rectangle = plt.Rectangle((point[0], point[1]), a, b, + color=color, linewidth=0, alpha=1) + patch_rotate_translate(ax, rectangle, rot, trans) + + +class MultiRoundedBoxField(MultiBoxField): + + def __init__(self, centers, sizes, tensor_args=None): + """ + Parameters + ---------- + centers : numpy array + Center of the boxes. + sizes: numpy array + Sizes of the boxes. + """ + super().__init__(centers, sizes, tensor_args=tensor_args) + self.radius = torch.min(self.sizes, dim=-1)[0] * 0.15 # empirical value + + def compute_signed_distance_impl(self, x): + # Implementation of rounded box + # https://raphlinus.github.io/graphics/2020/04/21/blurred-rounded-rects.html + distance_to_centers = torch.abs(x.unsqueeze(-2) - self.centers.unsqueeze(0)) + q = distance_to_centers - self.half_sizes.unsqueeze(0) + self.radius.unsqueeze(0).unsqueeze(-1) + max_q = torch.amax(q, dim=-1) + sdfs = torch.minimum(max_q, torch.zeros_like(max_q)) + torch.linalg.norm(torch.relu(q), dim=-1) - self.radius.unsqueeze(0) + return torch.min(sdfs, dim=-1)[0] + + def draw_box(self, ax, i, point, a, b, rot, trans, color='wheat'): + rounded_box = FancyBboxPatch((point[0], point[1]), a, b, color=color, + boxstyle=BoxStyle.Round(pad=0., rounding_size=to_numpy(self.radius[i]).item()) + ) + patch_rotate_translate(ax, rounded_box, rot, trans) + + +# Alias for rounded box. +# Use a rounded box instead of a box by default. +# This creates smoother cost functions, which are important to gradient-based optimization methods. +MultiBoxField = MultiRoundedBoxField + + +# TODO - NEEDS CHECKING +# class MultiInfiniteCylinderField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Centers of the cylinders. +# radii : numpy array +# Radii of the cylinders. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.centers = torch.tensor(centers, **self.tensor_args) +# self.radii = torch.tensor(radii, **self.tensor_args) +# +# def __repr__(self): +# return f"InfiniteCylinder(centers={self.centers}, radii={self.radii})" +# +# def compute_signed_distance_impl(self, x): +# # treat it like a circle in 2d +# distance_to_centers_2d = torch.norm(x[..., :2].unsqueeze(-2) - self.centers.unsqueeze(0)[..., :2], dim=-1) +# sdfs = distance_to_centers_2d - self.radii.unsqueeze(0) +# return torch.min(sdfs, dim=-1)[0] +# +# def zero_grad(self): +# self.centers.grad = None +# self.radii.grad = None +# +# def add_to_occupancy_map(self, obst_map): +# raise NotImplementedError +# +# def render(self, ax, pos=None, ori=None): +# raise NotImplementedError +# # https://stackoverflow.com/a/49311446 +# def data_for_cylinder_along_z(center_x, center_y, radius, height_z): +# z = np.linspace(0, height_z, 50) +# theta = np.linspace(0, 2 * np.pi, 50) +# theta_grid, z_grid = np.meshgrid(theta, z) +# x_grid = radius * np.cos(theta_grid) + center_x +# y_grid = radius * np.sin(theta_grid) + center_y +# return x_grid, y_grid, z_grid +# +# for center, radius in zip(self.centers, self.radii): +# cx, cy, cz = to_numpy(center) +# radius = to_numpy(radius) +# xc, yc, zc = data_for_cylinder_along_z(cx, cy, radius, 1.5) # add height just for visualization +# ax.plot_surface(xc, yc, zc, cmap='wheat', alpha=0.75) +# +# +# class MultiCylinderField(MultiInfiniteCylinderField): +# +# def __init__(self, centers, radii, heights, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Centers of the cylinders. +# radii : numpy array +# Radii of the cylinders. +# heights : numpy array +# Heights of the cylinders. +# """ +# super().__init__(centers, radii, tensor_args=tensor_args) +# self.heights = torch.tensor(heights, **self.tensor_args) +# self.half_heights = self.heights / 2 +# +# def __repr__(self): +# return f"Cylinder(centers={self.centers}, radii={self.radii}, heights={self.heights})" +# +# def compute_signed_distance_impl(self, x): +# raise NotImplementedError +# x = x - self.center +# x_proj = x[:, :2] +# x_proj_norm = torch.norm(x_proj, dim=-1) +# x_proj_norm = torch.where(x_proj_norm > 0, x_proj_norm, torch.ones_like(x_proj_norm)) +# x_proj = x_proj / x_proj_norm[:, None] +# x_proj = x_proj * self.radius +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# return torch.norm(x - x_proj, dim=-1) - self.radius +# +# def zero_grad(self): +# super().zero_grad() +# self.heights.grad = None +# +# def add_to_occupancy_map(self, obst_map): +# raise NotImplementedError +# +# +# class MultiEllipsoidField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, tensor_args=None): +# """ +# Axis aligned ellipsoid. +# Parameters +# ---------- +# center : numpy array +# Center of the ellipsoid. +# radii : numpy array +# Radii of the ellipsoid. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.center = torch.tensor(centers, **self.tensor_args) +# self.radii = torch.tensor(radii, **self.tensor_args) +# +# def compute_signed_distance_impl(self, x): +# return torch.norm((x - self.center) / self.radii, dim=-1) - 1 +# +# def zero_grad(self): +# self.center.grad = None +# self.radii.grad = None +# +# def __repr__(self): +# return f"Ellipsoid(center={self.center}, radii={self.radii})" +# +# +# class MultiCapsuleField(PrimitiveShapeField): +# +# def __init__(self, centers, radii, heights, tensor_args=None): +# """ +# Parameters +# ---------- +# centers : numpy array +# Center of the capsule. +# radiii : float +# Radius of the capsule. +# heights : float +# Height of the capsule. +# """ +# super().__init__(dim=centers.shape[-1], tensor_args=tensor_args) +# self.center = torch.tensor(centers, **self.tensor_args) +# self.radius = torch.tensor(radii, **self.tensor_args) +# self.height = torch.tensor(heights, **self.tensor_args) +# +# def compute_signed_distance_impl(self, x): +# x = x - self.center +# x_proj = x[:, :2] +# x_proj_norm = torch.norm(x_proj, dim=-1) +# x_proj_norm = torch.where(x_proj_norm > 0, x_proj_norm, torch.ones_like(x_proj_norm)) +# x_proj = x_proj / x_proj_norm[:, None] +# x_proj = x_proj * self.radius +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# x_proj = torch.norm(x - x_proj, dim=-1) - self.radius +# x_proj = torch.where(x_proj > 0, x_proj, torch.zeros_like(x_proj)) +# x_proj = torch.where(x_proj < self.height, x_proj, torch.ones_like(x_proj) * self.height) +# x_proj = torch.cat([x_proj, x[:, 2:]], dim=-1) +# return torch.norm(x - x_proj, dim=-1) - self.radius +# +# def zero_grad(self): +# self.center.grad = None +# self.radius.grad = None +# self.height.grad = None +# +# def __repr__(self): +# return f"Capsule(center={self.center}, radius={self.radius}, height={self.height})" +# +# +# class MeshField(PrimitiveShapeField): +# """ +# Represents a mesh as a primitive shape. +# """ +# +# def __init__(self, **kwargs): +# super().__init__(**kwargs) + + +######################################################################################################################## +class ObjectField(PrimitiveShapeField): + + def __init__(self, primitive_fields, name='object', pos=None, ori=None, reference_frame='base'): + """ + Holds an object made of primitives and manages its position and orientation in the environments. + """ + self.name = name + + assert primitive_fields is not None + assert isinstance(primitive_fields, List) + super().__init__(dim=primitive_fields[0].dim, tensor_args=primitive_fields[0].tensor_args) + self.fields = primitive_fields + + # position and orientation + assert (pos is None and ori is None) or (pos.nelement() == 3 and ori.nelement() == 4) + self.pos = torch.zeros(3, **self.tensor_args) if pos is None else pos + self.ori = torch.tensor([1, 0, 0, 0], **self.tensor_args) if ori is None else ori # quat - wxyz + + # Reference frame for the position and orientation + self.reference_frame = reference_frame + + # precomputed sdf and gradients + # holds the sdf (and its gradient) of this object for all points in the environments workspace + self.grid_map_sdf = None + + def __repr__(self): + return f"ObjectField(fields={self.fields})" + + def set_position_orientation(self, pos=None, ori=None): + if pos is not None: + assert len(pos) == 3 + self.pos = to_torch(pos, **self.tensor_args) + if ori is not None: + assert len(ori) == 4, "quaternion wxyz" + self.ori = to_torch(ori, **self.tensor_args) + + def join_primitives(self): + raise NotImplementedError + + def compute_signed_distance_impl(self, x): + # Transform the point before computing the SDF. + # The implemented SDFs assume the objects are centered around 0 and not rotated. + x_shape = x.shape + if x_shape[-1] == 2: + x_new = torch.cat((x, torch.zeros((x.shape[:-1]), **self.tensor_args).unsqueeze(-1)), dim=-1) + else: + x_new = x + + # transform back to the origin + rot = q_to_rotation_matrix(self.ori).squeeze().transpose(-2, -1) + x_new = rotate_point(x_new-self.pos, rot) + if x_shape[-1] == 2: + x_new = x_new[..., :2] + + sdf_fields = [] + for field in self.fields: + sdf_fields.append(field.compute_signed_distance_impl(x_new)) + return torch.min(torch.stack(sdf_fields, dim=-1), dim=-1)[0] + + def render(self, ax, pos=None, ori=None, color='wheat', **kwargs): + for field in self.fields: + if pos is None: + pos = self.pos + if ori is None: + ori = self.ori + field.render(ax, pos=pos, ori=ori, color=color, **kwargs) + + def add_to_occupancy_map(self, occ_map): + for field in self.fields: + field.add_to_occupancy_map(occ_map) + + def zero_grad(self): + for field in self.fields: + field.zero_grad() + + +if __name__ == '__main__': + tensor_args = DEFAULT_TENSOR_ARGS + spheres = MultiSphereField(torch.zeros(2, **tensor_args).view(1, -1), + torch.ones(1, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + boxes = MultiBoxField(torch.zeros(2, **tensor_args).view(1, -1) + 0.5, + torch.ones(2, **tensor_args).view(1, -1) * 0.3, + tensor_args=tensor_args) + + obj_field = ObjectField([spheres, boxes]) + + theta = np.deg2rad(45) + # obj_field.set_position_orientation(pos=[-0.5, 0., 0.]) + # obj_field.set_position_orientation(ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + obj_field.set_position_orientation(pos=[-0.5, 0., 0.], ori=[np.cos(theta/2), 0, 0, np.sin(theta/2)]) + + # # Render objects + fig, ax = plt.subplots() + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + obj_field.render(ax) + plt.show() + + # Render sdf + fig, ax = plt.subplots() + xs = torch.linspace(-1, 1, steps=400, **tensor_args) + ys = torch.linspace(-1, 1, steps=400, **tensor_args) + X, Y = torch.meshgrid(xs, ys, indexing='xy') + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + sdf = obj_field.compute_signed_distance(torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, 2)) + sdf = sdf.reshape(X.shape) + sdf_np = to_numpy(sdf) + ctf = ax.contourf(to_numpy(X), to_numpy(Y), sdf_np) + fig.colorbar(ctf, orientation='vertical') + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + + # Render gradient sdf + xs = torch.linspace(-1, 1, steps=20, **tensor_args) + ys = torch.linspace(-1, 1, steps=20, **tensor_args) + X, Y = torch.meshgrid(xs, ys, indexing='xy') + X_flat = torch.flatten(X) + Y_flat = torch.flatten(Y) + stacked_tensors = torch.stack((X_flat, Y_flat), dim=-1).view(-1, 1, 2) + + f_grad_sdf = lambda x: obj_field.compute_signed_distance(x).sum() + grad_sdf = jacobian(f_grad_sdf, stacked_tensors) + + grad_sdf_np = to_numpy(grad_sdf).squeeze() + ax.quiver(to_numpy(X_flat), to_numpy(Y_flat), + grad_sdf_np[:, 0], grad_sdf_np[:, 1], + color='red') + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..42d6c406adac166f5ea33534c19c118bcacd0c01 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/environments/utils.py @@ -0,0 +1,29 @@ +import numpy as np + +from torch_robotics.environments.primitives import MultiSphereField, ObjectField, MultiBoxField + + +def create_grid_spheres(rows=5, cols=5, heights=0, radius=0.1, distance_from_border=0.1, tensor_args=None): + # Generates a grid (rows, cols, heights) of circles + # if heights = 0, creates a 2d grid, else a 3d grid + dim = 2 if heights == 0 else 3 + centers_x = np.linspace(-1 + distance_from_border, 1 - distance_from_border, cols) + centers_y = np.linspace(-1 + distance_from_border, 1 - distance_from_border, rows) + z_flat = None + if dim == 3: + centers_z = np.linspace(-1 + distance_from_border, 1 - distance_from_border, heights) + X, Y, Z = np.meshgrid(centers_x, centers_y, centers_z) + z_flat = Z.flatten() + else: + X, Y = np.meshgrid(centers_x, centers_y) + + flats = [X.flatten(), Y.flatten()] + if z_flat: + flats.append(z_flat) + centers = np.array(flats).T + radii = np.ones(flats[0].shape[0]) * radius + + spheres = MultiSphereField(centers, radii, tensor_args=tensor_args) + obj_field = ObjectField([spheres], 'grid-of-spheres') + obj_list = [obj_field] + return obj_list diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1ab0346213d6ff3e2e0a0780879045fff3893152 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/__init__.py @@ -0,0 +1,10 @@ +from .robot_planar_disk import * +from .robot_composite_three_planar_disk import * +from .robot_composite_six_planar_disk import * +from .robot_composite_nine_planar_disk import * +from .robot_composite_two_planar_disk import * + +from .robot_composite_twelve_planar_disk import * +from .robot_composite_fifteen_planar_disk import * +from .robot_composite_eighteen_planar_disk import * +from .robot_composite_twentyone_planar_disk import * diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_base.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_base.py new file mode 100644 index 0000000000000000000000000000000000000000..e1980cec1a0723125d3a9e4ab4b3bc20b913b738 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_base.py @@ -0,0 +1,205 @@ +import abc +import itertools +from abc import ABC +from math import ceil +from typing import Tuple + +import torch + +from torch_robotics.torch_planning_objectives.fields.distance_fields import CollisionSelfField +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch +from torch_robotics.trajectory.utils import finite_difference_vector + + +class RobotBase(ABC): + + def __init__( + self, + name='RobotBase', + q_limits=None, + grasped_object=None, + margin_for_grasped_object_collision_checking=0.001, + link_names_for_object_collision_checking=None, + link_margins_for_object_collision_checking=None, + link_idxs_for_object_collision_checking=None, + link_names_for_self_collision_checking=None, + link_names_pairs_for_self_collision_checking=None, + link_idxs_for_self_collision_checking=None, + self_collision_margin_robot=0.001, + link_names_for_self_collision_checking_with_grasped_object=None, + self_collision_margin_grasped_object=0.05, + num_interpolated_points_for_self_collision_checking=1, + num_interpolated_points_for_object_collision_checking=1, + dt=1.0, # time interval to compute velocities and accelerations from positions via finite difference + tensor_args=None, + **kwargs + ): + self.name = name + self.tensor_args = tensor_args + + self.dt = dt + + ################################################################################################ + # Configuration space + assert q_limits is not None, "q_limits cannot be None" + self.q_limits = q_limits + self.q_min = q_limits[0] + self.q_max = q_limits[1] + self.q_min_np = to_numpy(self.q_min) + self.q_max_np = to_numpy(self.q_max) + self.q_distribution = torch.distributions.uniform.Uniform(self.q_min, self.q_max) + self.q_dim = len(self.q_min) + + ################################################################################################ + # Grasped object + self.grasped_object = grasped_object + self.margin_for_grasped_object_collision_checking = margin_for_grasped_object_collision_checking + + ################################################################################################ + # Objects collision field + assert num_interpolated_points_for_object_collision_checking >= len(link_names_for_object_collision_checking) + if num_interpolated_points_for_object_collision_checking % len(link_names_for_object_collision_checking) != 0: + self.points_per_link_object_collision_checking = ceil(num_interpolated_points_for_object_collision_checking / len(link_names_for_object_collision_checking)) + num_interpolated_points_for_object_collision_checking = self.points_per_link_object_collision_checking * len(link_names_for_object_collision_checking) + else: + self.points_per_link_object_collision_checking = int(num_interpolated_points_for_object_collision_checking / len(link_names_for_object_collision_checking)) + self.self_collision_margin_robot = self_collision_margin_robot + self.num_interpolated_points_for_object_collision_checking = num_interpolated_points_for_object_collision_checking + self.link_names_for_object_collision_checking = link_names_for_object_collision_checking + self.n_links_for_object_collision_checking = len(link_names_for_object_collision_checking) + self.link_margins_for_object_collision_checking = link_margins_for_object_collision_checking + self.link_margins_for_object_collision_checking_robot_tensor = torch.tensor( + link_margins_for_object_collision_checking, **self.tensor_args).repeat_interleave( + int(num_interpolated_points_for_object_collision_checking / len(link_margins_for_object_collision_checking)) + ) + self.link_margins_for_object_collision_checking_tensor = self.link_margins_for_object_collision_checking_robot_tensor + # append grasped object margins + if self.grasped_object is not None: + self.link_margins_for_object_collision_checking_tensor = torch.cat( + (self.link_margins_for_object_collision_checking_tensor, + torch.ones(self.grasped_object.n_base_points_for_collision, **self.tensor_args) * self.margin_for_grasped_object_collision_checking) + ) + + self.link_idxs_for_object_collision_checking = link_idxs_for_object_collision_checking + + ################################################################################################ + # Self collision field + if link_names_for_self_collision_checking is None: + self.df_collision_self = None + else: + assert num_interpolated_points_for_self_collision_checking >= len(link_names_for_self_collision_checking) + if num_interpolated_points_for_self_collision_checking % len(link_names_for_self_collision_checking) != 0: + self.points_per_link_self_collision_checking = ceil(num_interpolated_points_for_self_collision_checking / len(link_names_for_self_collision_checking)) + num_interpolated_points_for_self_collision_checking = self.points_per_link_self_collision_checking * len(link_names_for_self_collision_checking) + else: + self.points_per_link_self_collision_checking = int(num_interpolated_points_for_self_collision_checking / len(link_names_for_self_collision_checking)) + + self.link_names_for_self_collision_checking = link_names_for_self_collision_checking + self.link_names_pairs_for_self_collision_checking = link_names_pairs_for_self_collision_checking + + self.link_idxs_for_self_collision_checking = link_idxs_for_self_collision_checking + + self.link_names_for_self_collision_checking_with_grasped_object = link_names_for_self_collision_checking_with_grasped_object + + self.self_collision_margin_grasped_object = self_collision_margin_grasped_object + + # build indices to retrieve distances from self collision distance matrix + # including the grasped object + idxs_links_distance_matrix = [] + p = self.points_per_link_self_collision_checking + total_self_distances_robot = 0 + for i, link_1 in enumerate(self.link_names_for_self_collision_checking): + if link_1 in self.link_names_pairs_for_self_collision_checking: + for link_2 in self.link_names_pairs_for_self_collision_checking[link_1]: + j = self.link_names_for_self_collision_checking.index(link_2) + idxs = [(i*p + m, j*p + n) for m, n in list(itertools.product(range(p), range(p)))] + idxs_links_distance_matrix.extend(idxs) + total_self_distances_robot += len(idxs) + + self_collision_margin_vector = [self.self_collision_margin_robot] * total_self_distances_robot + + if self.grasped_object is not None: + total_self_distances_grasped_object = 0 + self_collision_robot_last_row_idx = len(self.link_names_for_self_collision_checking) * p + n_grasped_points = self.grasped_object.n_base_points_for_collision + for link_1 in self.link_names_for_self_collision_checking_with_grasped_object: + j = self.link_names_for_self_collision_checking.index(link_1) + idxs = [(self_collision_robot_last_row_idx + m, j * p + n) for m, n in list(itertools.product(range(n_grasped_points), range(p)))] + idxs_links_distance_matrix.extend(idxs) + total_self_distances_grasped_object += len(idxs) + + self_collision_margin_vector.extend([self.self_collision_margin_grasped_object] * total_self_distances_grasped_object) + + self_collision_margin_vector = to_torch(self_collision_margin_vector, **self.tensor_args) + + self.df_collision_self = CollisionSelfField( + self, + link_idxs_for_collision_checking=self.link_idxs_for_self_collision_checking, + idxs_links_distance_matrix=idxs_links_distance_matrix, + num_interpolated_points=num_interpolated_points_for_self_collision_checking, + cutoff_margin=self_collision_margin_vector, + tensor_args=self.tensor_args + ) + + def random_q(self, n_samples=10): + # Random position in configuration space + q_pos = self.q_distribution.sample((n_samples,)) + return q_pos + + def get_position(self, x): + return x[..., :self.q_dim] + + def get_direction(self, x): + raise NotImplementedError + + def get_velocity(self, x): + vel = x[..., self.q_dim:2 * self.q_dim] + # If there is no velocity in the state, then compute it via finite difference + if x.nelement() != 0 and vel.nelement() == 0: + vel = finite_difference_vector(x, dt=self.dt, method='central') + return vel + return vel + + def get_acceleration(self, x): + acc = x[..., 2 * self.q_dim:3 * self.q_dim] + # If there is no acceleration in the state, then compute it via finite difference + if x.nelement() != 0 and acc.nelement() == 0: + vel = self.get_velocity(x) + acc = finite_difference_vector(vel, dt=self.dt, method='central') + return acc + return acc + + def distance_q(self, q1, q2): + return torch.linalg.norm(q1 - q2, dim=-1) + + def fk_map_collision(self, q, **kwargs): + if q.ndim == 1: + q = q.unsqueeze(0) # add batch dimension + return self.fk_map_collision_impl(q, **kwargs) + + @abc.abstractmethod + def fk_map_collision_impl(self, q, **kwargs): + # q: (..., q_dim) + # return: (..., links_collision_positions, 3) + raise NotImplementedError + + @abc.abstractmethod + def render(self, ax, **kwargs): + raise NotImplementedError + + @abc.abstractmethod + def render_trajectories(self, ax, trajs=None, **kwargs): + raise NotImplementedError + + def check_rr_collisions(self, robot_q: torch.tensor) -> Tuple[torch.tensor, torch.tensor]: + """ + Check collisions between robots. (Robot-robot collisions). + Args: + robot_q: (..., n_robots, q_dim) + Returns: + collisions: (..., n_robots, n_robots), True if there is a collision between the robots. + collision_points: (..., n_robots, n_robots, 2 or 3), collision points. + """ + raise NotImplementedError + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_eighteen_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_eighteen_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..85c8fa2722dd83aee7c0a9b152fce4920945d536 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_eighteen_planar_disk.py @@ -0,0 +1,131 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeEighteenPlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeEighteenPlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1] * 36, + [ 1] * 36 + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + link_margins_for_object_collision_checking=[radius * 1.1] * 18, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], + num_interpolated_points_for_object_collision_checking=18, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + num_interpolated_points_for_self_collision_checking=18, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_6': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_7': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_8': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_9': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_10': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_11': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_12': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_13': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_14', 'link_15', 'link_16', 'link_17'], + 'link_14': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_15', 'link_16', 'link_17'], + 'link_15': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_16', 'link_17'], + 'link_16': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_17'], + 'link_17': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16'] + }), + **kwargs + ) + self.radius = radius + self.n_agents = 18 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 18: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_fifteen_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_fifteen_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..6a3122e6ee1490fbb2266f8758eb65d09539fb4f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_fifteen_planar_disk.py @@ -0,0 +1,128 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeFifteenPlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeFifteenPlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1] * 30, + [ 1] * 30 + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + link_margins_for_object_collision_checking=[radius * 1.1] * 15, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + num_interpolated_points_for_object_collision_checking=15, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + num_interpolated_points_for_self_collision_checking=15, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_6': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_7': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_8': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_9': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_10': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_11', 'link_12', 'link_13', 'link_14'], + 'link_11': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_12', 'link_13', 'link_14'], + 'link_12': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_13', 'link_14'], + 'link_13': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_14'], + 'link_14': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13'] + }), + **kwargs + ) + self.radius = radius + self.n_agents = 15 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 18: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_nine_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_nine_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..06555ae5f5cb59e11fe78fa5e67507e3b3217893 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_nine_planar_disk.py @@ -0,0 +1,124 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeNinePlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeNinePlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], + [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', + 'link_6', 'link_7', 'link_8'], + link_margins_for_object_collision_checking=[radius * 1.1] * 9, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8], + num_interpolated_points_for_object_collision_checking=9, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', + 'link_6', 'link_7', 'link_8'], + num_interpolated_points_for_self_collision_checking=9, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5', 'link_6', 'link_7', 'link_8'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_6', 'link_7', 'link_8'], + 'link_6': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_7', 'link_8'], + 'link_7': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_8'], + 'link_8': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7'], + }), + **kwargs + ) + self.radius = radius + self.n_agents = 9 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 18: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_six_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_six_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..71a092c2bedcc56733aaf86fcab6fe9ac543acd9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_six_planar_disk.py @@ -0,0 +1,166 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeSixPlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeSixPlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], + [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5'], + link_margins_for_object_collision_checking=[radius * 1.1] * 6, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5], + num_interpolated_points_for_object_collision_checking=6, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5'], + num_interpolated_points_for_self_collision_checking=6, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4'], + }), + **kwargs + ) + self.radius = radius + self.n_agents = 6 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + for agent_id2 in range(self.n_agents): + if agent_id2 != agent_id: + q2_single = q2[agent_id2 * 2: (agent_id2 + 1) * 2] + circle2 = plt.Circle(q2_single, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 12: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + if start_state is not None: + for i in range(self.n_agents): + start_state_np = to_numpy(start_state[i*2:(i+1)*2]) + ax.plot(start_state_np[0], start_state_np[1], 'go', markersize=self.radius*100) + if goal_state is not None: + for i in range(self.n_agents): + goal_state_np = to_numpy(goal_state[i*2:(i+1)*2]) + ax.plot(goal_state_np[0], goal_state_np[1], marker='o', color='purple', markersize=self.radius*100) + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + +if __name__ == '__main__': + # Test the robot. + robot_args = dict( + tensor_args=dict(device='cpu', dtype=torch.float64), + name='RobotCompositeSixPlanarDisk', + radius=0.05, + ) + robot = RobotCompositeSixPlanarDisk(**robot_args) + q = torch.tensor([0.1, 0.5, + 0.2, 0.6, + 0.3, 0.7, + 0.4, 0.8, + 0.5, 0.9, + -0.6, -0.7]) + link_pos = robot.fk_map_collision_impl(q) + print(link_pos) + print(link_pos.shape) + # Test rendering. + fig, ax = plt.subplots() + robot.render(ax, q) + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + plt.show() + # Test rendering trajectories. + fig, ax = plt.subplots() + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + trajs = torch.tensor([[[0.1, 0.5, 0.2, 0.5, 0.3, 0.5, 0.4, 0.5, 0.5, 0.5, -0.6, -0.7], + [0.1, 0.6, 0.2, 0.6, 0.3, 0.6, 0.4, 0.6, 0.5, 0.6, -0.5, -0.7], + [0.1, 0.7, 0.2, 0.7, 0.3, 0.7, 0.4, 0.7, 0.5, 0.7, -0.4, -0.7], + [0.1, 0.8, 0.2, 0.8, 0.3, 0.8, 0.4, 0.8, 0.5, 0.8, -0.3, -0.7], + [0.1, 0.9, 0.2, 0.9, 0.3, 0.9, 0.4, 0.9, 0.5, 0.9, -0.2, -0.7], + [0.2, -0.6, 0.3, -0.6, 0.4, -0.6, 0.5, -0.6, 0.6, -0.6, -0.5, 0.7]]]) + + start_state = trajs[0, 0] + goal_state = trajs[0, -1] + robot.render_trajectories(ax, trajs, start_state, goal_state) + plt.show() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_three_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_three_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..d493a86181de718cf2d42943b6464da7cba57d68 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_three_planar_disk.py @@ -0,0 +1,124 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeThreePlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeThreePlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1, -1, -1, -1, -1, -1], + [ 1, 1, 1, 1, 1, 1] + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2'], + link_margins_for_object_collision_checking=[radius * 1.1] * 3, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2], + num_interpolated_points_for_object_collision_checking=3, + link_idxs_for_self_collision_checking=[0, 1, 2], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2'], + num_interpolated_points_for_self_collision_checking=3, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2'], + 'link_1': ['link_0', 'link_2'], + 'link_2': ['link_0', 'link_1'], + }), + **kwargs + ) + self.radius = radius + self.n_agents = 3 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + for agent_id2 in range(self.n_agents): + if agent_id2 != agent_id: + q2_single = q2[agent_id2 * 2: (agent_id2 + 1) * 2] + circle2 = plt.Circle(q2_single, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 6: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + # ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + if start_state is not None: + for i in range(self.n_agents): + start_state_np = to_numpy(start_state[i*2:(i+1)*2]) + ax.plot(start_state_np[0], start_state_np[1], 'go', markersize=self.radius*100) + if goal_state is not None: + for i in range(self.n_agents): + goal_state_np = to_numpy(goal_state[i*2:(i+1)*2]) + ax.plot(goal_state_np[0], goal_state_np[1], marker='o', color='purple', markersize=self.radius*100) + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twelve_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twelve_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..2d43c3ffa419a0d411d0a4af7d81b229f50726da --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twelve_planar_disk.py @@ -0,0 +1,125 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeTwelvePlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeTwelvePlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1] * 24, + [ 1] * 24 + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + link_margins_for_object_collision_checking=[radius * 1.1] * 12, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + num_interpolated_points_for_object_collision_checking=12, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + num_interpolated_points_for_self_collision_checking=12, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_6': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_7': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_8', 'link_9', 'link_10', 'link_11'], + 'link_8': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_9', 'link_10', 'link_11'], + 'link_9': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_10', 'link_11'], + 'link_10': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_11'], + 'link_11': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10'] + }), + **kwargs + ) + self.radius = radius + self.n_agents = 12 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 18: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twentyone_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twentyone_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..3181ae9675e082a4bd7f535d56342ef2bdc3d2e5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_twentyone_planar_disk.py @@ -0,0 +1,134 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeTwentyOnePlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeTwentyOnePlanarDisk', + radius=0.05, + q_limits=torch.tensor([ + [-1] * 42, + [ 1] * 42 + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + link_margins_for_object_collision_checking=[radius * 1.1] * 21, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + num_interpolated_points_for_object_collision_checking=21, + link_idxs_for_self_collision_checking=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + num_interpolated_points_for_self_collision_checking=21, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_1': ['link_0', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_2': ['link_0', 'link_1', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_3': ['link_0', 'link_1', 'link_2', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_4': ['link_0', 'link_1', 'link_2', 'link_3', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_5': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_6': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_7': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_8': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_9': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_10': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_11': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_12': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_13': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_14': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_15': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_16', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_16': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_17', 'link_18', 'link_19', 'link_20'], + 'link_17': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_18', 'link_19', 'link_20'], + 'link_18': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_19', 'link_20'], + 'link_19': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_20'], + 'link_20': ['link_0', 'link_1', 'link_2', 'link_3', 'link_4', 'link_5', 'link_6', 'link_7', 'link_8', 'link_9', 'link_10', 'link_11', 'link_12', 'link_13', 'link_14', 'link_15', 'link_16', 'link_17', 'link_18', 'link_19'] + }), + **kwargs + ) + self.radius = radius + self.n_agents = 21 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 18: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_two_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_two_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..4b0027ad0346932d9b4cb4042aba138969794582 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_composite_two_planar_disk.py @@ -0,0 +1,113 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from collections import OrderedDict + +# Standard imports. +import matplotlib.pyplot as plt +import numpy as np +import torch +import matplotlib.collections as mcoll + +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, to_torch + + +class RobotCompositeTwoPlanarDisk(RobotBase): + def __init__(self, + name='RobotCompositeTwoPlanarDisk', + radius=0.1, + q_limits=torch.tensor([ + [-1, -1, -1, -1], + [ 1, 1, 1, 1] + ]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch(q_limits, **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0', 'link_1'], + link_margins_for_object_collision_checking=[radius * 1.1] * 2, # Inflate so that self-collision affects robots slightly further away. + link_idxs_for_object_collision_checking=[0, 1], + num_interpolated_points_for_object_collision_checking=2, + link_idxs_for_self_collision_checking=[0, 1], + self_collision_margin_robot=radius * 4, # Inflated. + link_names_for_self_collision_checking=['link_0', 'link_1'], + num_interpolated_points_for_self_collision_checking=2, + link_names_pairs_for_self_collision_checking=OrderedDict({ + 'link_0': ['link_1'], + 'link_1': ['link_0'], + }), + **kwargs + ) + self.radius = radius + self.n_agents = 2 + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + q_multi = q + if q_multi is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q_multi = to_numpy(q_multi) + if q_multi.ndim == 1: + if self.q_dim == self.n_agents * 2: + for agent_id in range(self.n_agents): + q = q_multi[agent_id * 2: (agent_id + 1) * 2] + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', **kwargs): + if trajs is not None: + trajs_pos_multi = self.get_position(trajs) + trajs_np_multi = to_numpy(trajs_pos_multi) + if self.q_dim == 4: + for agent_id in range(self.n_agents): + trajs_np = trajs_np_multi[:, :, agent_id * 2: (agent_id + 1) * 2] + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + + + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Return the centers of the disks in a (B, H, n_robots, 2) tensor. + B, H, q_dim = q.shape + q = q.view(B, H, self.n_agents, 2) + return q diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..ba594874cd81d18102e836784ebd878df2237ec2 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/robots/robot_planar_disk.py @@ -0,0 +1,206 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul. Heavily based on the code robot_point_mass.py from SMD. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from typing import Tuple + +# Standard imports. +import numpy as np +import torch +import matplotlib.collections as mcoll +import matplotlib.pyplot as plt + +from smd.common.constraints import * +from smd.common.trajectory_utils import * +# Project imports. +from torch_robotics.robots.robot_base import RobotBase +from torch_robotics.torch_utils.torch_utils import to_numpy, tensor_linspace_v1, to_torch +from smd.config import SMDParams as params + + +class RobotPlanarDisk(RobotBase): + + # def __init__(self, + # name='RobotPlanarDisk', + # radius=0.07, + # q_limits=torch.tensor([[-1, -1], [1, 1]]), # Confspace limits. + # **kwargs): + # super().__init__( + # name=name, + # q_limits=to_torch(q_limits, **kwargs['tensor_args']), + # **kwargs + # ) + # + # ################################################################################################ + # # Robot + # self.radius = radius + # # Set the link margins for object collision checking to the radius of the disk here and in the parent. + # self.link_margins_for_object_collision_checking = [radius] + + def __init__(self, + name='RobotPlanarDisk', + radius=params.robot_planar_disk_radius, + q_limits=torch.tensor([[-1, -1], [1, 1]]), # configuration space limits + **kwargs): + super().__init__( + name=name, + q_limits=to_torch( + __import__('torch').tensor([[-1.6, -1.0], [1.6, 1.0]]) + if __import__('os').environ.get('RBT_ARENA') == '1' else q_limits, + **kwargs['tensor_args']), + link_names_for_object_collision_checking=['link_0'], + link_margins_for_object_collision_checking=[radius * 1.1], + link_idxs_for_object_collision_checking=[0], + num_interpolated_points_for_object_collision_checking=1, + **kwargs + ) + self.radius = radius + + def render(self, ax, q=None, color='blue', cmap='Blues', margin_multiplier=1., **kwargs): + if q is not None: + margin = self.radius * margin_multiplier * 0.9 # The 0.9 margin multiplier is to make the disks display correctly. + q = to_numpy(q) + if q.ndim == 1: + if self.q_dim == 2: + circle1 = plt.Circle(q, margin, color=color, zorder=10) + ax.add_patch(circle1) + if 'q_tail' in kwargs: + n_tail = len(kwargs['q_tail']) + q_tail = kwargs['q_tail'] + for i, q2 in enumerate(kwargs['q_tail']): + margin_tail = (n_tail - i) / n_tail * margin + alpha = (1 - i / n_tail) / 2 + circle2 = plt.Circle(q2, margin_tail, color=color, zorder=10, alpha=alpha, edgecolor=None) + ax.add_patch(circle2) + elif self.q_dim == 3: + plot_sphere(ax, q, np.zeros_like(q), margin, cmap) + else: + raise NotImplementedError + elif q.ndim == 2: + if q.shape[-1] == 2: + # ax.scatter(q[:, 0], q[:, 1], color=color, s=10 ** 2, zorder=10) + circ = [] + for q_ in q: + circ.append(plt.Circle(q_, margin, color=color)) + coll = mcoll.PatchCollection(circ, zorder=10) + ax.add_collection(coll) + elif q.shape[-1] == 3: + # ax.scatter(q[:, 0], q[:, 1], q[:, 2], color=color, s=10 ** 2, zorder=10) + for q_ in q: + plot_sphere(ax, q_, np.zeros_like(q_), margin, cmap) + else: + raise NotImplementedError + else: + raise NotImplementedError + + def render_trajectories( + self, ax, trajs=None, start_state=None, goal_state=None, colors=['blue'], + linestyle='solid', constraints_l=None, **kwargs): + if trajs is not None: + trajs_pos = self.get_position(trajs) + trajs_np = to_numpy(trajs_pos) + if self.q_dim == 3: + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1], trajs_np[..., 2]))).swapaxes(1, 2) + line_segments = Line3DCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 3)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + ax.scatter(points[:, 0], points[:, 1], points[:, 2], color=colors_scatter, s=2**2) + else: + segments = np.array(list(zip(trajs_np[..., 0], trajs_np[..., 1]))).swapaxes(1, 2) + line_segments = mcoll.LineCollection(segments, colors=colors, linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(trajs_np, (-1, 2)) + colors_scatter = [] + for segment, color in zip(segments, colors): + colors_scatter.extend([color]*segment.shape[0]) + # ax.scatter(points[:, 0], points[:, 1], color=colors_scatter, s=2**2) + if start_state is not None: + start_state_np = to_numpy(start_state) + if len(start_state_np) == 3: + ax.plot(start_state_np[0], start_state_np[1], start_state_np[2], 'go', markersize=self.radius*100) + else: + ax.plot(start_state_np[0], start_state_np[1], 'go', markersize=self.radius*100) + if goal_state is not None: + goal_state_np = to_numpy(goal_state) + if len(goal_state_np) == 3: + ax.plot(goal_state_np[0], goal_state_np[1], goal_state_np[2], marker='o', + color='purple', markersize=self.radius*100) + else: + ax.plot(goal_state_np[0], goal_state_np[1], marker='o', color='purple', markersize=self.radius*100) + if constraints_l is not None: + for c in constraints_l: + # if isinstance(c, MultiPointConstraint): + q_l = c.get_q_l() + q_l_np = [to_numpy(q) for q in q_l] + for q in q_l_np: + if self.q_dim == 3: + plot_sphere(ax, q, np.zeros_like(q), self.radius, 'Blues') + else: + # circle = plt.Circle(q, self.radius, color='red', zorder=10) + # ax.add_patch(circle) + # Show gaussian centered at this point. + for i in range(1, 50): + circle = plt.Circle(q, self.radius * (i / 50)**4, color='red', zorder=10, alpha=0.2 * (50 - i)/50) + ax.add_patch(circle) + + # elif isinstance(c, VertexConstraint): + # q = c.get_q() + # ... + def fk_map_collision_impl(self, q, **kwargs): + # There is no forward kinematics. Assume it's the identity. + # Add tasks space dimension + return q.unsqueeze(-2) + + def check_rr_collisions(self, robot_q: torch.tensor) -> Tuple[torch.tensor, torch.tensor]: + """ + Check collisions between robots. (Robot-robot collisions). + Args: + robot_q: (..., n_robots, q_dim) + Returns: + collisions: (..., n_robots, n_robots), True if there is a collision between the robots. + collision_points: (..., n_robots, n_robots, 2 or 3), the collision points. + """ + # # Check collisions between robots. + # return are_points_closer_than_margin(robot_q, 2.1 * self.radius) + + points_batch = robot_q + margin = 2.1 * self.radius # /////////////// + assert points_batch.dim() >= 2 + points_batch1 = points_batch.unsqueeze(-2) + points_batch2 = points_batch.unsqueeze(-3) + # Pairwise distances between robots. + robot_dq = points_batch1 - points_batch2 + # (..., n_robots, n_robots) + robot_dq_norm = torch.norm(robot_dq, dim=-1) + # (..., n_robots, n_robots) + collisions = robot_dq_norm < margin + # Set the trace to be False. + collisions = collisions & ~torch.eye(collisions.shape[-1], device=collisions.device, dtype=collisions.dtype) + # Collision points. For this robot those are the midpoint between the robots. + collision_points = (points_batch1 + points_batch2) / 2 + # Mask the collision points. Set the collision points to nan if there is no collision. + collision_points = collision_points * collisions.unsqueeze(-1) + collision_points[~collisions.unsqueeze(-1).expand_as(collision_points)] = float('nan') + return collisions, collision_points diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..b4fcc22df567d429e3a843c679ba41df864afa2d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks.py @@ -0,0 +1,336 @@ +import sys +from abc import ABC +from functools import partial + +import einops +import numpy as np +import torch + +from torch_robotics.torch_planning_objectives.fields.distance_fields import CollisionWorkspaceBoundariesDistanceField, \ + CollisionSelfField, CollisionObjectDistanceField +from torch_robotics.trajectory.utils import interpolate_traj_via_points + + +class Task(ABC): + + def __init__(self, env=None, robot=None, tensor_args=None, **kwargs): + self.env = env + self.robot = robot + self.tensor_args = tensor_args + + +class PlanningTask(Task): + + def __init__( + self, + ws_limits=None, + use_occupancy_map=False, + cell_size=0.01, + obstacle_cutoff_margin=0.01, + **kwargs + ): + super().__init__(**kwargs) + self.ws_limits = self.env.limits if ws_limits is None else ws_limits + self.ws_min = self.ws_limits[0] + self.ws_max = self.ws_limits[1] + + # Optional: use an occupancy map for collision checking -- useful for sampling-based algorithms + # A precomputed collision map is faster when checking for collisions, in comparison to computing the distances + # from tasks spaces to objects + self.use_occupancy_map = use_occupancy_map + if use_occupancy_map: + self.env.build_occupancy_map(cell_size=cell_size) + + ################################################################################################ + # Collision fields + # collision field for self-collision + self.df_collision_self = self.robot.df_collision_self + + # collision field for objects + self.df_collision_objects = CollisionObjectDistanceField( + self.robot, + df_obj_list_fn=self.env.get_df_obj_list, + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + tensor_args=self.tensor_args + ) + + if self.env.obj_extra_list is not None: + self.df_collision_extra_objects = CollisionObjectDistanceField( + self.robot, + df_obj_list_fn=partial(self.env.get_df_obj_list, return_extra_objects_only=True), + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + tensor_args=self.tensor_args + ) + self._collision_fields_extra_objects = [self.df_collision_extra_objects] + else: + self._collision_fields_extra_objects = [] + + # collision field for workspace boundaries + self.df_collision_ws_boundaries = CollisionWorkspaceBoundariesDistanceField( + self.robot, + link_idxs_for_collision_checking=self.robot.link_idxs_for_object_collision_checking, + num_interpolated_points=self.robot.num_interpolated_points_for_object_collision_checking, + link_margins_for_object_collision_checking_tensor=self.robot.link_margins_for_object_collision_checking_tensor, + cutoff_margin=obstacle_cutoff_margin, + # Hack for Diffusion Ensemble to allow larger footprint robots to be near boundary. + ws_bounds_min=self.ws_min * 1.08, + # Hack for Diffusion Ensemble to allow larger footprint robots to be near boundary. + ws_bounds_max=self.ws_max * 1.08, + tensor_args=self.tensor_args + ) + + self._collision_fields = [self.df_collision_self, self.df_collision_objects, self.df_collision_ws_boundaries] + + def get_collision_fields(self): + return self._collision_fields + + def get_collision_fields_extra_objects(self): + return self._collision_fields_extra_objects + + def distance_q(self, q1, q2): + return self.robot.distance_q(q1, q2) + + def sample_q(self, without_collision=True, **kwargs): + if without_collision: + return self.random_coll_free_q(**kwargs) + else: + return self.robot.random_q(**kwargs) + + def random_coll_free_q(self, n_samples=1, max_samples=1000, max_tries=1000): + # Random position in configuration space not in collision + reject = True + samples = torch.zeros((n_samples, self.robot.q_dim), **self.tensor_args) + idx_begin = 0 + for i in range(max_tries): + qs = self.robot.random_q(max_samples) + in_collision = self.compute_collision(qs).squeeze() + idxs_not_in_collision = torch.argwhere(in_collision == False).squeeze() + if idxs_not_in_collision.nelement() == 0: + # all points are in collision + continue + if idxs_not_in_collision.dim() == 0: + # Wrapping a 0-dim tensor in a Python list (the original approach here) + # silently breaks fancy indexing below: idxs_not_in_collision[idx_random] + # ends up returning the raw 0-dim scalar rather than a (1,)-shaped index + # tensor, which drops the batch dimension from free_qs and corrupts the + # subsequent assignment. Keep it a proper 1-D tensor instead. + idxs_not_in_collision = idxs_not_in_collision.unsqueeze(0) + idx_random = torch.randperm(len(idxs_not_in_collision))[:n_samples] + free_qs = qs[idxs_not_in_collision[idx_random]] + idx_end = min(idx_begin + free_qs.shape[0], samples.shape[0]) + samples[idx_begin:idx_end] = free_qs[:idx_end - idx_begin] + idx_begin = idx_end + if idx_end >= n_samples: + reject = False + break + + if reject: + sys.exit("Could not find a collision free configuration") + + return samples.squeeze() + + def compute_collision(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='occupancy', **kwargs) + + def compute_collision_cost(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='sdf', **kwargs) + + def _compute_collision_or_cost(self, q, field_type='occupancy', **kwargs): + # q.shape needs to be reshaped to (batch, horizon, q_dim) + q_original_shape = q.shape + b = 1 + h = 1 + collisions = None + if q.ndim == 1: + q = q.unsqueeze(0).unsqueeze(0) # add batch and horizon dimensions for interface + collisions = torch.ones((1, ), **self.tensor_args) + elif q.ndim == 2: + b = q.shape[0] + q = q.unsqueeze(1) # add horizon dimension for interface + collisions = torch.ones((b, 1), **self.tensor_args) # (batch, 1) + elif q.ndim == 3: + b = q.shape[0] + h = q.shape[1] + collisions = torch.ones((b, h), **self.tensor_args) # (batch, horizon) + elif q.ndim > 3: + raise NotImplementedError + + if self.use_occupancy_map: + raise NotImplementedError + # ---------------------------------- For occupancy maps ---------------------------------- + ######################################## + # Configuration space boundaries + idxs_coll_free = torch.argwhere(torch.all( + torch.logical_and(torch.greater_equal(q, self.robot.q_min), torch.less_equal(q, self.robot.q_max)), + dim=-1)) # I, 2 + + # check if all points are out of bounds (in collision) + if idxs_coll_free.nelement() == 0: + return collisions + + ######################################## + # Task space collisions + # forward kinematics + q_try = q[idxs_coll_free[:, 0], idxs_coll_free[:, 1]] # I, q_dim + x_pos = self.robot.fk_map_collision(q_try, pos_only=True) # I, taskspaces, x_dim + + # workspace boundaries + # configuration is not valid if any points in the tasks spaces is out of workspace boundaries + idxs_ws_in_boundaries = torch.argwhere(torch.all(torch.all(torch.logical_and( + torch.greater_equal(x_pos, self.ws_min), torch.less_equal(x_pos, self.ws_max)), dim=-1), + dim=-1)).squeeze() # I_ws + + idxs_coll_free = idxs_coll_free[idxs_ws_in_boundaries].view(-1, 2) + + # collision in tasks space + x_pos_in_ws = x_pos[idxs_ws_in_boundaries] # I_ws, x_dim + collisions_pos_x = self.env.occupancy_map.get_collisions(x_pos_in_ws, **kwargs) + if len(collisions_pos_x.shape) == 1: + collisions_pos_x = collisions_pos_x.view(1, -1) + idxs_taskspace = torch.argwhere(torch.all(collisions_pos_x == 0, dim=-1)).squeeze() + + idxs_coll_free = idxs_coll_free[idxs_taskspace].view(-1, 2) + + # filter collisions + if len(collisions) == 1: + collisions[idxs_coll_free[:, 0]] = 0 + else: + collisions[idxs_coll_free[:, 0], idxs_coll_free[:, 1]] = 0 + else: + # ---------------------------------- For distance fields ---------------------------------- + ######################################## + # For distance fields + + # forward kinematics + fk_collision_pos = self.robot.fk_map_collision(q) # batch, horizon, taskspaces, x_dim + + ######################## + # Self collision + if self.df_collision_self is not None: + cost_collision_self = self.df_collision_self.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_self = 0 + + # Object collision + if self.df_collision_objects is not None: + cost_collision_objects = self.df_collision_objects.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_objects = 0 + + # Workspace boundaries + if self.df_collision_ws_boundaries is not None: + cost_collision_border = self.df_collision_ws_boundaries.compute_cost(q, fk_collision_pos, field_type=field_type, **kwargs) + else: + cost_collision_border = 0 + + if field_type == 'occupancy': + collisions = cost_collision_self | cost_collision_objects | cost_collision_border + else: + collisions = cost_collision_self + cost_collision_objects + cost_collision_border + + return collisions + + def get_trajs_collision_and_free(self, trajs, return_indices=False, num_interpolation=5): + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: # n_goals (or steps), batch of trajectories, length, dim + N, B, H, D = trajs.shape + trajs_new = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + trajs_new = trajs + + ############################################################################################################### + # compute collisions on a finer interpolated trajectory + trajs_interpolated = interpolate_traj_via_points(trajs_new, num_interpolation=0) + margin = 0.0 + # TODO(yoraish): this is a hack to accomodate the planar disk robot which only has one point of collision but a volume nontheless. + # If robot has a radius, then use it as margin. + if getattr(self.robot, 'radius', None) is not None: + margin = self.robot.radius-1e-3 + trajs_waypoints_collisions = self.compute_collision(trajs_interpolated, margin=margin) + + if trajs.ndim == 4: + trajs_waypoints_collisions = einops.rearrange(trajs_waypoints_collisions, '(N B) H -> N B H', N=N, B=B) + + trajs_free_idxs = torch.argwhere(torch.logical_not(trajs_waypoints_collisions).all(dim=-1)) + trajs_coll_idxs = torch.argwhere(trajs_waypoints_collisions.any(dim=-1)) + + ############################################################################################################### + # Check that trajectories that are not in collision are inside the joint limits + if trajs_free_idxs.nelement() == 0: + pass + else: + if trajs.ndim == 4: + trajs_free_tmp = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + else: + trajs_free_tmp = trajs[trajs_free_idxs.squeeze(), ...] + + trajs_free_tmp_position = self.robot.get_position(trajs_free_tmp) + trajs_free_inside_joint_limits_idxs = torch.logical_and( + trajs_free_tmp_position >= self.robot.q_min, + trajs_free_tmp_position <= self.robot.q_max).all(dim=-1).all(dim=-1) + trajs_free_inside_joint_limits_idxs = torch.atleast_1d(trajs_free_inside_joint_limits_idxs) + trajs_free_idxs_try = trajs_free_idxs[torch.argwhere(trajs_free_inside_joint_limits_idxs).squeeze()] + if trajs_free_idxs_try.nelement() == 0: + trajs_coll_idxs = trajs_free_idxs.clone() + else: + trajs_coll_idxs_joint_limits = trajs_free_idxs[torch.argwhere(torch.logical_not(trajs_free_inside_joint_limits_idxs)).squeeze()] + if trajs_coll_idxs_joint_limits.ndim == 1: + trajs_coll_idxs_joint_limits = trajs_coll_idxs_joint_limits[..., None] + trajs_coll_idxs = torch.cat((trajs_coll_idxs, trajs_coll_idxs_joint_limits)) + trajs_free_idxs = trajs_free_idxs_try + + ############################################################################################################### + # Return trajectories free and in collision + if trajs.ndim == 4: + trajs_free = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0).unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs[:, 0], trajs_coll_idxs[:, 1], ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0).unsqueeze(0) + else: + trajs_free = trajs[trajs_free_idxs.squeeze(), ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs.squeeze(), ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0) + + if trajs_coll.nelement() == 0: + trajs_coll = None + if trajs_free.nelement() == 0: + trajs_free = None + + if return_indices: + return trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions + return trajs_coll, trajs_free + + def compute_fraction_free_trajs(self, trajs, **kwargs): + # Compute the fractions of trajs that are collision free + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.get_trajs_collision_and_free(trajs, return_indices=True) + n_trajs_free = trajs_free_idxs.nelement() + n_trajs_coll = trajs_coll_idxs.nelement() + return n_trajs_free/(n_trajs_free + n_trajs_coll) + + def compute_collision_intensity_trajs(self, trajs, **kwargs): + # Compute the fraction of waypoints that are in collision + _, _, _, _, trajs_waypoints_collisions = self.get_trajs_collision_and_free(trajs, return_indices=True) + return torch.count_nonzero(trajs_waypoints_collisions)/trajs_waypoints_collisions.nelement() + + def compute_success_free_trajs(self, trajs, **kwargs): + # If at least one trajectory is collision free, then we consider success + _, trajs_free = self.get_trajs_collision_and_free(trajs) + if trajs_free is not None: + if trajs_free.nelement() >= 1: + return 1 + return 0 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py new file mode 100644 index 0000000000000000000000000000000000000000..37265d073f61989e9473e14e607e00270186440f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/tasks/tasks_ensemble.py @@ -0,0 +1,370 @@ +import torch + +from .tasks import * +from torch_robotics.environments import * +from typing import Dict +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints +from smd.common.pretty_print import * + +class TaskEnsemble(Task): + def __init__(self, + tasks: Dict[int, Task], + transforms: Dict[int, torch.Tensor], + **kwargs): + self.tasks = tasks # Dict[int, PlanningTask] + self.transforms = transforms # Dict[int, torch.Tensor [dx dy] in 2D.] + envs_dict = {k: task.env for k, task in tasks.items()} + env = EnvEnsemble(envs_dict, transforms) + super().__init__(env, tasks[0].robot, **kwargs) + + def transform_q(self, task_id: int, q): + if q.shape[-1] > self.transforms[task_id].shape[0]: + transform = torch.cat( + [self.transforms[task_id], torch.zeros(q.shape[-1] - self.transforms[task_id].shape[0], + device=self.transforms[task_id].device)]) + else: + transform = self.transforms[task_id] + return q + transform + + def inverse_transform_q(self, task_id: int, q): + if q.shape[-1] > self.transforms[task_id].shape[0]: + transform = torch.cat( + [self.transforms[task_id], torch.zeros(q.shape[-1] - self.transforms[task_id].shape[0], + device=self.transforms[task_id].device)]) + else: + transform = self.transforms[task_id] + return q - transform + + +class PlanningTaskEnsemble(TaskEnsemble): + def __init__(self, + tasks: Dict[int, PlanningTask], + transforms: Dict[int, torch.Tensor], + ws_limits=None, + **kwargs): + super().__init__(tasks, transforms, **kwargs) + self.ws_limits = self.env.limits if ws_limits is None else ws_limits + self.ws_min = self.ws_limits[0] + self.ws_max = self.ws_limits[1] + + def get_collision_fields(self, task_id=None): + if task_id is None: + tot_fields = [] + for task in self.tasks.values(): + col_fields = task.get_collision_fields() + tot_fields.extend(col_fields) + return tot_fields + return self.tasks[task_id].get_collision_fields() + + def get_collision_fields_extra_objects(self, task_id=None): + if task_id is None: + tot_fields = [] + for task in self.tasks.values(): + col_fields = task.get_collision_fields_extra_objects() + tot_fields.extend(col_fields) + return tot_fields + return self.tasks[task_id].get_collision_fields_extra_objects() + + def distance_q(self, task_id: int, q1, q2): + return self.tasks[task_id].distance_q(q1, q2) + + def sample_q(self, task_id: int, without_collision=True, **kwargs): + return self.tasks[task_id].sample_q(without_collision=without_collision, **kwargs) + + def random_coll_free_q(self, task_id: int, n_samples=1, max_samples=1000, max_tries=1000): + return self.tasks[task_id].random_coll_free_q(n_samples, max_samples, max_tries) + + def get_traj_unnormalized(self, task_id: int, + datasets, + traj_normalized): + trajs_iters = datasets[task_id].unnormalize_trajectories(traj_normalized) + trajs_final = trajs_iters[-1] + trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs, _ = \ + (self.tasks[task_id].get_trajs_collision_and_free(trajs_final, return_indices=True)) + return trajs_iters, trajs_final, trajs_final_coll, trajs_final_coll_idxs, trajs_final_free, trajs_final_free_idxs + + def get_stats(self, + task_id: int, + trajs_iters, + trajs_final, + trajs_final_coll, + trajs_final_coll_idxs, + trajs_final_free, + trajs_final_free_idxs, + t_total, + save_data=True, + results_dir=None + ): + success_free_trajs = self.tasks[task_id].compute_success_free_trajs(trajs_final) + fraction_free_trajs = self.tasks[task_id].compute_fraction_free_trajs(trajs_final) + collision_intensity_trajs = self.tasks[task_id].compute_collision_intensity_trajs(trajs_final) + + print(f'success: {success_free_trajs}') + print(f'percentage free trajs: {fraction_free_trajs * 100:.2f}') + print(f'percentage collision intensity: {collision_intensity_trajs * 100:.2f}') + + # compute costs only on collision-free trajectories + traj_final_free_best = None + idx_best_traj = None + cost_best_free_traj = None + cost_smoothness = None + cost_path_length = None + cost_all = None + variance_waypoint_trajs_final_free = None + if trajs_final_free is not None: + cost_smoothness = compute_smoothness(trajs_final_free, self.tasks[task_id].robot) + print(f'cost smoothness: {cost_smoothness.mean():.4f}, {cost_smoothness.std():.4f}') + + cost_path_length = compute_path_length(trajs_final_free, self.tasks[task_id].robot) + print(f'cost path length: {cost_path_length.mean():.4f}, {cost_path_length.std():.4f}') + + # compute best trajectory + cost_all = cost_path_length + cost_smoothness + idx_best_traj_free = torch.argmin(cost_all).item() + traj_final_free_best = trajs_final_free[idx_best_traj_free] + cost_best_free_traj = torch.min(cost_all).item() + print(f'cost best: {cost_best_free_traj:.3f}') + + # variance of waypoints + variance_waypoint_trajs_final_free = compute_variance_waypoints(trajs_final_free, self.tasks[task_id].robot) + print(f'variance waypoint: {variance_waypoint_trajs_final_free:.4f}') + + print(f'\n--------------------------------------\n') + + ######################################################################################################################## + # Save data + results_data_dict = { + 'trajs_iters': trajs_iters, + 'trajs_final_coll': trajs_final_coll, + 'trajs_final_coll_idxs': trajs_final_coll_idxs, + 'trajs_final_free': trajs_final_free, + 'trajs_final_free_idxs': trajs_final_free_idxs, + 'success_free_trajs': success_free_trajs, + 'fraction_free_trajs': fraction_free_trajs, + 'collision_intensity_trajs': collision_intensity_trajs, + # 'idx_best_traj_free': idx_best_traj_free, + 'traj_final_free_best': traj_final_free_best, + 'cost_best_free_traj': cost_best_free_traj, + 'cost_path_length_trajs_final_free': cost_path_length, + 'cost_smoothness_trajs_final_free': cost_smoothness, + 'cost_all_trajs_final_free': cost_all, + 'variance_waypoint_trajs_final_free': variance_waypoint_trajs_final_free, + 't_total': t_total + } + + if save_data: + import os + import pickle + with open(os.path.join(results_dir, f'results_data_dict_model{task_id}.pickle'), 'wb') as handle: + pickle.dump(results_data_dict, handle, protocol=pickle.HIGHEST_PROTOCOL) + return results_data_dict + + def combine_trajs(self, + results_ensemble: Dict[int, Dict]): + keys = list(results_ensemble.values())[0].keys() + for task_id, results in results_ensemble.items(): + for k in keys: + if "idxs" not in k and k.startswith("traj"): + if results[k] is not None: + results_ensemble[task_id][k] = self.transform_q(task_id, results[k]) + else: + results_ensemble[task_id][k] = None + + results = {'trajs_iters': torch.cat([results_ensemble[task_id]['trajs_iters'] for task_id in + results_ensemble.keys()], dim=-2)} + trajs_final = results['trajs_iters'][-1] + coll_ids = torch.tensor([], **self.tensor_args) + for batch_id in range(results_ensemble[0]['trajs_iters'].shape[1]): + for task_id in results_ensemble.keys(): + if batch_id in results_ensemble[task_id]['trajs_final_coll_idxs']: + coll_ids = torch.cat((coll_ids, torch.tensor([batch_id], **self.tensor_args))) + break + free_ids = torch.tensor([i for i in range(results_ensemble[0]['trajs_iters'].shape[1]) if i not in coll_ids], + **self.tensor_args) + + results['trajs_final_coll_idxs'] = coll_ids.long() + results['trajs_final_free_idxs'] = free_ids.long() + results['trajs_final_coll'] = trajs_final[:, results['trajs_final_coll_idxs'], ...] \ + if len(coll_ids) > 0 else torch.tensor([], **self.tensor_args) + results['trajs_final_free'] = trajs_final[results['trajs_final_free_idxs'], :, ...] \ + if len(free_ids) > 0 else torch.tensor([], **self.tensor_args) + results['success_free_trajs'] = 1 if len(free_ids) > 0 else 0 + results['fraction_free_trajs'] = len(free_ids) / results['trajs_iters'].shape[1] + results['collision_intensity_trajs'] = 1 - results['fraction_free_trajs'] + if len(free_ids) > 0: + # results['cost_smoothness_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_smoothness_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + # results['cost_path_length_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_path_length_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + results['cost_smoothness_trajs_final_free'] = compute_smoothness(results['trajs_final_free'], self.robot) + results['cost_path_length_trajs_final_free'] = compute_path_length(results['trajs_final_free'], self.robot) + + # results['cost_all_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['cost_all_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + + results['cost_all_trajs_final_free'] = results['cost_smoothness_trajs_final_free'] + results['cost_path_length_trajs_final_free'] + + # results['variance_waypoint_trajs_final_free'] = torch.sum( + # torch.cat([results_ensemble[task_id]['variance_waypoint_trajs_final_free'].unsqueeze(0) + # for task_id in results_ensemble.keys()], + # dim=-1), dim=-1) + results['variance_waypoint_trajs_final_free'] = compute_variance_waypoints(results['trajs_final_free'], self.robot) + + idx_best_traj_among_free_trajs = torch.argmin(results['cost_all_trajs_final_free'], dim=-1) + # Index of best free trajectory among all trajectories. + results['idx_best_traj'] = results['trajs_final_free_idxs'][idx_best_traj_among_free_trajs] + results['traj_final_free_best'] = results['trajs_final_free'][idx_best_traj_among_free_trajs, ...] + results['cost_best_free_traj'] = torch.min(results['cost_all_trajs_final_free'], dim=-1)[0] + results['t_total'] = results_ensemble[0]['t_total'] + return results + + # return results_ensemble + + def compute_collision(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='occupancy', **kwargs) + + def compute_collision_cost(self, x, **kwargs): + q_pos = self.robot.get_position(x) + return self._compute_collision_or_cost(q_pos, field_type='sdf', **kwargs) + + def _compute_collision_or_cost(self, q, field_type='occupancy', **kwargs): + # q.shape needs to be reshaped to (batch, horizon, q_dim) + q_original_shape = q.shape + b = 1 + h = 1 + collisions = None + if q.ndim == 1: + q = q.unsqueeze(0).unsqueeze(0) # add batch and horizon dimensions for interface + collisions = torch.ones((1,), **self.tensor_args) + elif q.ndim == 2: + b = q.shape[0] + q = q.unsqueeze(1) # add horizon dimension for interface + collisions = torch.ones((b, 1), **self.tensor_args) # (batch, 1) + elif q.ndim == 3: + b = q.shape[0] + h = q.shape[1] + collisions = torch.ones((b, h), **self.tensor_args) # (batch, horizon) + elif q.ndim > 3: + raise NotImplementedError + collisions = collisions.long() + # get the right task based on q values and each task limits + task_ids = self.infer_task_id_from_q(q) # Shape (num_qs, ). + for task_id in self.tasks.keys(): + mask = task_ids == task_id + if torch.any(mask): + q_task = q[mask] + q_task = self.inverse_transform_q(task_id, q_task) + mask = mask.view(-1) + collisions[mask] = self.tasks[task_id]._compute_collision_or_cost(q_task, field_type=field_type, **kwargs) + + return collisions.view(q_original_shape[:-1]) + + # return self.tasks[task_id]._compute_collision_or_cost(self.inverse_transform_q(task_id, q), field_type=field_type, **kwargs) + + def get_trajs_collision_and_free(self, trajs, return_indices=False, num_interpolation=5): + # TEST TEST TEST. Return all valid. + # TODO(yorai): get_trajs_collision_and_free returns all free. This is for visualization, but still needs to be + # fixed. + if return_indices: + return None, torch.tensor([]), trajs, torch.tensor([i for i in range(trajs.shape[0])]), torch.tensor([False for i in range(trajs.shape[1])]) + return None, trajs + + assert trajs.ndim == 3 or trajs.ndim == 4 + N = 1 + if trajs.ndim == 4: # n_goals (or steps), batch of trajectories, length, dim + N, B, H, D = trajs.shape + trajs_new = einops.rearrange(trajs, 'N B H D -> (N B) H D') + else: + B, H, D = trajs.shape + trajs_new = trajs + + # ############################################################################################################## + # # compute collisions on a finer interpolated trajectory + # trajs_interpolated = interpolate_traj_via_points(trajs_new, num_interpolation=num_interpolation) + trajs_interpolated_all = [] + trajs_waypoints_collisions_all = {} + for task_id, task in self.tasks.items(): + ############################################################################################################### + # compute collisions on a finer interpolated trajectory + trajs_interpolated = interpolate_traj_via_points( + trajs_new[:, task_id * params.horizon:(task_id + 1) * params.horizon, ...], + num_interpolation=num_interpolation) + trajs_interpolated_all.append(trajs_interpolated) + # TODO(yoraish): this is a hack to accomodate the planar disk robot which only has one point of collision + # but a volume nonetheless. If robot has a radius, then use it as margin. + if getattr(self.robot, 'radius', None) is not None: + margin = self.robot.radius + trajs_waypoints_collisions = task.compute_collision(trajs_interpolated, margin=margin) + trajs_waypoints_collisions_all[task_id] = trajs_waypoints_collisions + + trajs = torch.cat(trajs_interpolated_all, dim=1) + trajs_waypoints_collisions = torch.stack(list(trajs_waypoints_collisions_all.values()), dim=0).all(dim=0) + if trajs.ndim == 4: + trajs_waypoints_collisions = einops.rearrange(trajs_waypoints_collisions, '(N B) H -> N B H', N=N, B=B) + + trajs_free_idxs = torch.argwhere(torch.logical_not(trajs_waypoints_collisions).all(dim=-1)) + trajs_coll_idxs = torch.argwhere(trajs_waypoints_collisions.any(dim=-1)) + + # Return trajectories free and in collision + if trajs.ndim == 4: + trajs_free = trajs[trajs_free_idxs[:, 0], trajs_free_idxs[:, 1], ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0).unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs[:, 0], trajs_coll_idxs[:, 1], ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0).unsqueeze(0) + else: + trajs_free = trajs[trajs_free_idxs.squeeze(), ...] + if trajs_free.ndim == 2: + trajs_free = trajs_free.unsqueeze(0) + trajs_coll = trajs[trajs_coll_idxs.squeeze(), ...] + if trajs_coll.ndim == 2: + trajs_coll = trajs_coll.unsqueeze(0) + + if trajs_coll.nelement() == 0: + trajs_coll = None + if trajs_free.nelement() == 0: + trajs_free = None + + if return_indices: + return trajs_coll, trajs_coll_idxs, trajs_free, trajs_free_idxs, trajs_waypoints_collisions + return trajs_coll, trajs_free + + def infer_task_id_from_q_idx(self, q_idx): + task_id = int(q_idx // params.horizon) + task = self.tasks[task_id] + return task_id, task + + def infer_task_id_from_q(self, q): + """ + Infer the task id from the joint values. + :param q: Shape (B, H, q_dim). + :return: task_ids: Shape (B, ). With each entry is the task id of the corresponding joint values traj. + """ + q_pos = self.robot.get_position(q) + # check the limits of each task + env_limits = [] + for task_id, task in self.tasks.items(): + limits = task.env.limits + # transform the limits + lower = self.transform_q(task_id, limits[0]) + upper = self.transform_q(task_id, limits[1]) + env_limits.append((lower, upper)) + task_ids = torch.zeros(q_pos.shape[0], device=q_pos.device, dtype=torch.long) - 1 + + for i, limits in enumerate(env_limits): + # if torch.all(q_pos >= limits[0]) and torch.all(q_pos <= limits[1]): + # task_id = i + # break + mask = torch.logical_and(q_pos >= limits[0], q_pos <= limits[1]) + mask = mask.all(dim=-1).squeeze() + task_ids[mask] = i + + return task_ids # Shape (num_qs, ). diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py new file mode 100644 index 0000000000000000000000000000000000000000..f01e409cd977d6f508ade1bfa35f9cb9aec36754 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/distributions.py @@ -0,0 +1,86 @@ +import torch +import numpy as np + + +class Gaussian(object): + """ + Batch implementation of Gaussian distribution defined on a manifold. + """ + + def __init__(self, manifold, mean, cov): + self._manifold = manifold + if mean.shape[0] != manifold.dim_M: + raise TypeError(f'[Gaussian]: mean dim_M {mean.shape[0]} and the manifold dim_M {manifold.dim_M} mismatch!') + if cov.shape[0] != manifold.dim_T or cov.shape[1] != manifold.dim_T: + raise TypeError(f'[Gaussian]: cov shape {cov.shape} and the manifold dim_T {manifold.dim_T, manifold.dim_T} mismatch!') + self.mean = mean + self.cov = cov + + def pdf(self, x): + """ + Evaluates pdf of the distribution at the point x. + Parameters + ---------- + :param x: torch of shape (..., self.manifold.dim_M) + Returns + ------- + :return pdf_p: torch of shape (...,) + """ + v = self.manifold.log_map(x, base=self.mean) + return self._nf * torch.exp(-(v.transpose(-2, -1) @ self.cov_inv @ v).sum(-1) / 2) + + def transform(self, A, b): + """ + Gaussian transformation. + :param A: torch of shape (..., dim_T, dim_T), rotation in tangent space + :param b: np.array of shape (..., dim_M), translation in manifold space + Returns + ------- + :return np array of the transformed Gaussians with shape (..., ) + """ + if A.shape[-2] != self.manifold.dim_T or A.shape[-1] != self.manifold.dim_T: + raise RuntimeError('[Gaussian]: Expected A to be of the dimension of the tangent' + ' space (%s, %s) and not %s' % (self.manifold.dim_T, self.manifold.dim_T, A.shape)) + if b.shape[-1] != self.manifold.dim_M: + raise RuntimeError('[Gaussian]: Expected b to be of the dimension of the manifold' + ' space (%s,) and not %s' % (self.manifold.dim_M, b.shape)) + batch_dim = b.shape[:-1] + mu_trafo = self.manifold.exp_map(A @ (self.manifold.log_map(self.mean)), base=b).view((-1, self.manifold.dim_M)) # (2.53) + sigma_trafo = self.manifold.matrix_parallel_transport(A @ self.cov @ A.transpose(-2, -1), b, mu_trafo).view((-1, self.manifold.dim_T, self.manifold.dim_T)) # (2.54) + gaussians = [] + for i in range(mu_trafo.shape[0]): + gaussians.append(Gaussian(self.manifold, mu_trafo[i], sigma_trafo[i])) + gaussians = np.array(gaussians).reshape(batch_dim) + return gaussians + + def kl_divergence_mvn(self, g): + term1 = torch.log(torch.linalg.det(g.cov)) - torch.log(torch.linalg.det(self.cov)) + term2 = torch.trace(torch.linalg.solve(g.cov, self.cov)) - self.manifold.dim_T + v = g.manifold.log_map(self.mean, base=g.mean) + term3 = v.transpose(-2, -1) @ g.cov_inv @ v + return (term1 + term2 + term3) / 2 + + def recompute_inv(self): + self._cov_inv = torch.linalg.inv(self.cov) + self._nf = 1 / (torch.sqrt((2 * np.pi)**self.manifold.dim_T * torch.linalg.det(self.cov))) + + @property + def manifold(self): + return self._manifold + + @property + def cov(self): + return self._cov + + @property + def nf(self): + return self._nf + + @cov.setter + def cov(self, value): + self._cov = value + self.recompute_inv() + + @property + def cov_inv(self): + return self._cov_inv diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py new file mode 100644 index 0000000000000000000000000000000000000000..a7e6c80be65585526fd2fa66910432c89d82452a --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/euclidean.py @@ -0,0 +1,20 @@ + +def e_log_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x - base + + +def e_exp_map(x, base=None): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + if base is None: + return x + else: + return x + base + + +def e_parallel_transport(x, g, h): + x = x.unsqueeze(0) if len(x.shape) == 1 else x + return x \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py new file mode 100644 index 0000000000000000000000000000000000000000..27c775ebcfdba8669e4c79e18627d97621842987 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/frame.py @@ -0,0 +1,137 @@ +from __future__ import annotations +from typing import Optional +import torch +import math + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_rotation_matrix +from torch_robotics.torch_kinematics_tree.geometrics.utils import vector3_to_skew_symm_matrix, multiply_inv_transform, \ + multiply_transform + + + +class Frame(object): + + def __init__(self, + rot: Optional[torch.Tensor] = None, + trans: Optional[torch.Tensor] = None, + pose: Optional[torch.Tensor] = None, + device='cpu'): + + self.device = device + + if rot is None: + self._rot = torch.eye(3, device=device).unsqueeze(0) + else: + self._rot = rot.to(device) + if len(self._rot.shape) == 2: + self._rot = self._rot.unsqueeze(0) + + if trans is None: + self._trans = torch.zeros(1, 3, device=device) + else: + self._trans = trans.to(device) + if len(self._trans.shape) == 1: + self._trans = self._trans.unsqueeze(0) + + if (pose is not None): + self.set_pose(pose) + assert self._trans.shape[0] == self._rot.shape[0] + self.batch_size = self._trans.shape[0] + + def set_pose(self, pose: torch.Tensor) -> None: + """ + Args: + pose[0, :]: x, y, z, qw, qx, qy, qz + """ + if pose.ndim == 1: + pose = pose.unsqueeze(0) + self._trans = pose[:, :3].clone().to(self.device) + self._rot = q_to_rotation_matrix(pose[:, 3:]).to(self.device) + + def set_translation(self, t: torch.Tensor) -> None: + self._trans = t.to(self.device) + + def set_rotation(self, rot: torch.Tensor) -> None: + self._rot = rot.to(self.device) + + def inverse(self) -> Frame: + rot_transpose = self._rot.transpose(-2, -1) + return Frame( + rot_transpose, + -(rot_transpose @ self._trans.unsqueeze(2)).squeeze(2), + device=self.device) + + def multiply_transform(self, frame: Frame) -> Frame: + new_rot, new_trans = multiply_transform( + self._rot, self._trans, frame.rotation, + frame.translation) + return Frame(new_rot, new_trans, device=self.device) + + def multiply_inv_transform(self, frame: Frame) -> Frame: + new_rot, new_trans = multiply_inv_transform( + frame.rotation, + frame.translation, self._rot, self._trans) + return Frame(new_rot, + new_trans, + device=self.device) + + def trans_cross_rot(self) -> torch.Tensor: + return vector3_to_skew_symm_matrix(self._trans) @ self._rot + + def get_transform_matrix(self) -> torch.Tensor: + mat = torch.eye(4, device=self.device).repeat(self.batch_size, 1, 1) + mat[:, :3, :3] = self._rot + mat[:, :3, 3] = self._trans + return mat + + def get_quaternion(self) -> torch.Tensor: + M = torch.zeros((self.batch_size, 4, 4)).to(self._rot.device) + M[:, :3, :3] = self._rot + M[:, :3, 3] = self._trans + M[:, 3, 3] = 1 + q = torch.empty((self.batch_size, 4)).to(self._rot.device) + t = torch.einsum('bii->b', M) + for n in range(self.batch_size): + tn = t[n] + if tn > M[n, 3, 3]: + q[n, 3] = tn + q[n, 2] = M[n, 1, 0] - M[n, 0, 1] + q[n, 1] = M[n, 0, 2] - M[n, 2, 0] + q[n, 0] = M[n, 2, 1] - M[n, 1, 2] + else: + i, j, k = 0, 1, 2 + if M[n, 1, 1] > M[n, 0, 0]: + i, j, k = 1, 2, 0 + if M[n, 2, 2] > M[n, i, i]: + i, j, k = 2, 0, 1 + tn = M[n, i, i] - (M[n, j, j] + M[n, k, k]) + M[n, 3, 3] + q[n, i] = tn + q[n, j] = M[n, i, j] + M[n, j, i] + q[n, k] = M[n, k, i] + M[n, i, k] + q[n, 3] = M[n, k, j] - M[n, j, k] + + q[n, :] *= 0.5 / math.sqrt(tn * M[n, 3, 3]) + return q + + def transform_point(self, point: torch.Tensor) -> torch.Tensor: + new_point = ((self._rot @ point.unsqueeze(0).transpose(-1, -2)).transpose(-1, -2)) + self._trans.unsqueeze(-2) + return new_point + + def get_euler(self) -> torch.Tensor: + return torch.atan2(self._rot[:, 2, 1], self._rot[:, 2, 2]), torch.asin(-self._rot[:, 2, 0]), torch.atan2(self._rot[:, 1, 0], self._rot[:, 0, 0]) + + @property + def rotation(self) -> torch.Tensor: + return self._rot + + @property + def translation(self) -> torch.Tensor: + return self._trans + + +if __name__ == '__main__': + # test Frame + frame = Frame() + print(frame.get_transform_matrix()) + print(frame.get_quaternion()) + print(frame.get_euler()) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py new file mode 100644 index 0000000000000000000000000000000000000000..b515eef0fd46038211629a5a5b819c83b39a16c6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/manifold.py @@ -0,0 +1,278 @@ +import torch +import numpy as np +import logging +from torch_kinematics_tree.geometrics.quaternion import q_log_map, q_exp_map, q_parallel_transport +from torch_kinematics_tree.geometrics.euclidean import e_log_map, e_exp_map, e_parallel_transport +from torch_kinematics_tree.geometrics.distributions import Gaussian + + +class Manifold(object): + """ + Riemannian manifolds. + "Programming by Demonstration on Riemannian Manifolds", M.J.A. Zeestraten, 2018. + Attributes + ---------- + self.name, str, the manifold name, e.g. "R^3 x S^3" + self.dim_M, int, the manifold space dim_M + self.dim_T, int, the tangent space dim_M, dim_T <= dim_M + self.log_map, function, the map from tangent space to manifold: p_M = self.log_map(p_T, base=None), base=None is global map + self.exp_map, function, the map from manifold to tangent space: p_T = self.exp_map(p_M, base=None), base=None is global map + self.parallel_transport, the parallel transport map: p_hT = self.parallel_transport(p_gT, g, h) + """ + logger = logging.getLogger(__name__) + + def __init__(self, dim_M, dim_T, log_map, exp_map, parallel_transport, name, tensor_args=None): + self._dim_M = dim_M + self._dim_T = dim_T + self._log_map = log_map + self._exp_map = exp_map + self._parallel_transport = parallel_transport + self._name = name + if tensor_args is None: + device = torch.device('cuda:' + str(0) if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + self.tensor_args = tensor_args + + def cartesian_product(self, other): + """ + Compute the cartesian product between the current manifold self and another manifold other. + """ + dim_M = self.dim_M + other.dim_M + dim_T = self.dim_T + other.dim_T + name = self.name + " x " + other.name + + def log_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.log_map(p1, b1), other.log_map(p2, b2)), dim=-1) + + def exp_map(p, base=None): + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + if base is None: + b1 = None + b2 = None + else: + b1 = base[..., :self.dim_M] + b2 = base[..., self.dim_M:] + return torch.cat((self.exp_map(p1, b1), other.exp_map(p2, b2)), dim=-1) + + def parallel_transport(p, g, h): + g1 = g[..., :self.dim_M] + g2 = g[..., self.dim_M:] + h1 = h[..., :self.dim_M] + h2 = h[..., self.dim_M:] + p1 = p[..., :self.dim_M] + p2 = p[..., self.dim_M:] + return torch.cat((self.parallel_transport(p1, g1, h1), other.parallel_transport(p2, g2, h2)), dim=-1) + + return Manifold(dim_M, dim_T, log_map, exp_map, parallel_transport, name) + + def matrix_parallel_transport(self, sigma, g, h): + Lp = torch.linalg.cholesky(sigma).transpose(-2, -1) + A_g_h_L = self.parallel_transport(Lp, g, h) + sigma_parallel = A_g_h_L.transpose(-2, -1) @ A_g_h_L + return sigma_parallel + + def mean(self, points_in_manifold, **kwargs): + """ + Computes the mean of the points points_in_manifold on the manifold according to the iterative likelihood maximization. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 50) + return_projections = kwargs.get('return_projections', False) + if isinstance(points_in_manifold, np.ndarray): + points_in_manifold = torch.from_numpy(points_in_manifold).to(**self.tensor_args) + if weights is None: + weights = torch.ones((points_in_manifold.shape[0], 1), **self.tensor_args) # batch size + if init_mu is None: + mu = points_in_manifold[0] + else: + mu = init_mu + eps_of_mu = None + k = 0 + for k in range(max_iter): + mu_last = mu + eps_of_mu = self.log_map(points_in_manifold, base=mu) + delta = (eps_of_mu * weights).mean(0) + mu = self.exp_map(delta, base=mu) + if torch.norm(mu - mu_last) < eps: + break + if k == max_iter - 1: + Manifold.logger.warning(f'Maximum of {max_iter} iterations reached!') + if return_projections: + return mu.squeeze(), eps_of_mu.squeeze() + else: + return mu.squeeze() + + def normal_distribution(self, points_in_manifold, **kwargs): + """ + Computes the Manifold Gaussian (mean and covariance) of the points points_in_manifold on the manifold + Parameters + ---------- + :param points_in_manifold: list of np.array of shape (dim_M,) or np.array of shape (dim_M, length). + Optional parameters + ------------------- + :param weights: np.array of shape (length,) that contains a weight for each point. None for no weights (all 1). + :param init_mu: np.array of shape (dim_M,), the initial mean for the iterative algorithm. + :param regularization: float, a regularization factor that prevents singular covariances + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: ManifoldGaussian, the Gaussian in this manifold. + """ + weights = kwargs.get('weights', None) + init_mu = kwargs.get('init_mu', None) + regularization = kwargs.get('regularization', 1e-5) + eps = kwargs.get('eps', 1e-5) + max_iter = kwargs.get('max_iter', 100) + mu, eps_of_mu = self.mean( + points_in_manifold, + weights=weights, + init_mu=init_mu, + eps=eps, + max_iter=max_iter, + return_projections=True, + ) + sigma = torch.cov(eps_of_mu.T, aweights=weights) + regularization * torch.eye(self.dim_T, **self.tensor_args) # Table 2.4 + return Gaussian(self, mu, sigma) + + def gaussian_product(self, g_list, eps=1e-5, max_iter=100): + """ + Computes the Gaussian product of the ManifoldGaussians specified in g_list with the iterative likelihood maximization. + Table 2.3, Table 2.4 + Parameters + ---------- + :param g_list: list of ManifoldGaussian + Optional parameters + ------------------- + :param eps: float, if the change in norm of mean is less than eps the algorithm stops. + :param max_iter: int, the maximum number of iterations. + Returns + ------- + :return g: Gaussian, the combined Gaussian in this manifold. + """ + dim_T = self.dim_T + mus = torch.stack([g.mean.squeeze() for g in g_list], dim=0) + mu = self.mean(mus) # initial guess + J_T_W = torch.zeros((dim_T, len(g_list) * dim_T), **self.tensor_args) + W = torch.zeros((len(g_list) * dim_T, len(g_list) * dim_T), **self.tensor_args) + sigma_inv = torch.zeros((dim_T, dim_T), **self.tensor_args) + # start + k = 0 + log_last = -np.inf + eps_of_mu = torch.zeros(dim_T * len(g_list), **self.tensor_args) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + for k in range(max_iter): + sigma_inv *= 0 + mu_last = mu + for p, g in enumerate(g_list): + sigma_parallel_p = self.matrix_parallel_transport(g.cov, g.mean, mu) + J_T_W[:, dim_T * p:dim_T * (p + 1)] = -torch.linalg.inv(sigma_parallel_p) + W[dim_T * p:dim_T * (p + 1), dim_T * p:dim_T * (p + 1)] = J_T_W[:, dim_T * p:dim_T * (p + 1)] + sigma_inv += -J_T_W[:, dim_T * p:dim_T * (p + 1)] + # backtracking line search + log_likelihood = -np.inf + delta = -torch.linalg.solve(sigma_inv, J_T_W @ eps_of_mu) + s = 1 + while log_likelihood <= log_last and np.abs(s) > 1e-30: + mu = self.exp_map(delta * s, base=mu_last) + for p, g in enumerate(g_list): + eps_of_mu[dim_T * p:dim_T * (p + 1)] = self.log_map(g.mean, base=mu) + log_likelihood = eps_of_mu @ W @ eps_of_mu + s /= -np.sqrt(2) + if log_likelihood - log_last < eps: + break + log_last = log_likelihood + if k == max_iter - 1: + Manifold.logger.warning(f'Iterative likelihood maximization for mean terminated after reaching {max_iter} iterations') + return Gaussian(self, mu.squeeze(), torch.linalg.inv(sigma_inv)) + + def get_origin(self): + spaces = self.name.split(" x ") + origin = [] + for man_name in spaces: + if man_name == "S^3": + origin.extend([1., 0., 0., 0.]) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + origin.extend([0.] * n) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return torch.tensor(origin, **self.tensor_args) + + def get_pos_quat_indices(self, tangent=False): + spaces = self.name.split(" x ") + pos_idx, quat_idx = [], [] + i = 0 + for man_name in spaces: + if man_name == "S^3": + r = 3 if tangent else 4 + quat_idx.extend(range(i, i + r)) + i += r + elif man_name[:2] == "R^": + n = int(man_name[2:]) + pos_idx.extend(range(i, i + n)) + i += n + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + return np.array(pos_idx), np.array(quat_idx) + + @staticmethod + def get_quaternion_manifold(): + return Manifold(4, 3, q_log_map, q_exp_map, q_parallel_transport, "S^3") + + @staticmethod + def get_euclidean_manifold(n): + return Manifold(n, n, e_log_map, e_exp_map, e_parallel_transport, ("R^%s" % n)) + + @staticmethod + def get_manifold_from_name(name): + manifold_names = name.split(" x ") + manifolds = [] + for man_name in manifold_names: + if man_name == "S^3": + manifolds.append(Manifold.get_quaternion_manifold()) + elif man_name[:2] == "R^": + n = int(man_name[2:]) + manifolds.append((Manifold.get_euclidean_manifold(n))) + else: + Manifold.logger.error(f'Invalid manifold naming {man_name}.') + full_manifold = manifolds[0] + for man in manifolds[1:]: + full_manifold = full_manifold.cartesian_product(man) + return full_manifold + + @property + def dim_M(self): # noqa + return self._dim_M + + @property + def dim_T(self): # noqa + return self._dim_T + + @property + def log_map(self): + return self._log_map + + @property + def exp_map(self): + return self._exp_map + + @property + def parallel_transport(self): + return self._parallel_transport + + @property + def name(self): + return self._name \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py new file mode 100644 index 0000000000000000000000000000000000000000..48ab1413f8f8b3aadf1caa674e530a4c53dbf093 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/quaternion.py @@ -0,0 +1,242 @@ +import numpy as np +import torch +import torch.nn.functional as F + + +''' +"Programming by Demonstration on Riemannian Manifolds", M.J.A. Zeestraten, 2018. TABLE I +https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles +Format: q = [w, x, y, z]. # NOTE: change to [x, y, z, w] for pyBullet +''' + + +def sqrt_with_mask(x): + """ + torch.sqrt(torch.max(0, x)) + """ + ret = torch.zeros_like(x) + positive_mask = x > 0 + ret[positive_mask] = torch.sqrt(x[positive_mask]) + return ret + + +def q_exp_map(v, base=None): + v = v.unsqueeze(0) if len(v.shape) == 1 else v + tensor_args = {'device': v.device, 'dtype': v.dtype} + batch_dim = v.shape[:-1] + if base is None: + norm_v = torch.norm(v, dim=-1) + q = torch.zeros(batch_dim + (4,), **tensor_args) + q[..., 0] = 1. + non_0 = torch.where(norm_v > 0) + non_0_norm = norm_v[non_0].unsqueeze(-1) + q[non_0] = torch.cat(( + torch.cos(non_0_norm), + (torch.sin(non_0_norm) / non_0_norm) * v[non_0]), dim=-1) + return q + else: + return q_mul(base, q_exp_map(v)) + + +def q_log_map(q, base=None, eps=1e-10): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + tensor_args = {'device': q.device, 'dtype': q.dtype} + batch_dim = q.shape[:-1] + if base is None: + norm_q = torch.norm(q[..., 1:], dim=-1) + non_0 = torch.where((norm_q > 0) * (torch.abs(q[..., 0]) <= 1)) # eps for numerical stability + q_non_singular = q[non_0] + non_0_norm = norm_q[non_0].unsqueeze(-1) + acos = torch.acos(q_non_singular[..., 0]).unsqueeze(-1) + acos[torch.where(q_non_singular[..., 0] < 0)] += -np.pi # q and -q maps to the same point in SO(3) + v = torch.zeros(batch_dim + (3,), **tensor_args) + # print(q_non_singular[..., 1:].shape, (acos / non_0_norm.unsqueeze(-1)).repeat((1,)*len(batch_dim) + (3,)).shape) + v[non_0] = q_non_singular[..., 1:] * (acos / non_0_norm) + return v + else: + return q_log_map(q_mul(q_inverse(base), q)) + + +def q_parallel_transport(p_g, g, h, eps=1e-10): + p_g = p_g.unsqueeze(0) if len(p_g.shape) == 1 else p_g + g, h = g.squeeze(), h.squeeze() + tensor_args = {'device': p_g.device, 'dtype': p_g.dtype} + Q_g = q_to_quaternion_matrix(g).squeeze() + Q_h = q_to_quaternion_matrix(h).squeeze() + B = torch.cat([ + torch.zeros((1, 3)), torch.eye(3) + ], dim=0).to(**tensor_args) + log_g_h = q_log_map(h, base=g) + m = torch.norm(log_g_h, dim=-1) + if m < eps: # divide by zero + return p_g + q_temp = torch.zeros((1, 4), **tensor_args) + q_temp[0, 1:] = log_g_h / m + u = (Q_g @ q_temp.unsqueeze(-1)).squeeze() + I4 = torch.eye(4).to(**tensor_args) + R_g_h = I4 - torch.sin(m) * torch.outer(g, u) + (torch.cos(m) - 1) * torch.outer(u, u) + A_g_h = B.T @ Q_h.T @ R_g_h @ Q_g @ B + res = (A_g_h @ p_g.unsqueeze(-1)).squeeze(-1) + return res + + +def q_mul(q1, q2): + res = q_to_quaternion_matrix(q1) @ q2.unsqueeze(-1) + return res.squeeze(-1) + + +def q_inverse(q): + tensor_args = {'device': q.device, 'dtype': q.dtype} + scaling = torch.tensor([1, -1, -1, -1], **tensor_args) + return q * scaling / q_norm_squared(q) + + +def q_div(q1, q2): + return q_mul(q1, q_inverse(q2)) + + +def q_norm_squared(q): + return torch.sum(q ** 2, dim=-1, keepdim=True) + + +def q_to_rotation_matrix(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + double_cover = 2.0 / (q ** 2).sum(-1) + o = torch.stack( + ( + 1 - double_cover * (y * y + z * z), + double_cover * (x * y - z * w), + double_cover * (x * z + y * w), + double_cover * (x * y + z * w), + 1 - double_cover * (x * x + z * z), + double_cover * (y * z - x * w), + double_cover * (x * z - y * w), + double_cover * (y * z + x * w), + 1 - double_cover * (x * x + y * y), + ), + dim=-1 + ) + return o.reshape(q.shape[:-1] + (3, 3)) + + +def q_to_quaternion_matrix(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + o = torch.stack([ + w, -x, -y, -z, + x, w, -z, y, + y, z, w, -x, + z, -y, x, w + ], dim=-1) + return o.reshape(q.shape[:-1] + (4, 4)) + + +def rotation_matrix_to_q(rot_mat): + rot_mat = rot_mat.unsqueeze(0) if len(rot_mat.shape) == 1 else rot_mat + assert rot_mat.size(-1) == 3 or rot_mat.size(-2) == 3 + batch_dim = rot_mat.shape[:-2] + tensor_args = {'device': rot_mat.device, 'dtype': rot_mat.dtype} + m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind( + rot_mat.reshape(batch_dim + (9,)), dim=-1 + ) + q_abs = sqrt_with_mask( + torch.stack( + [ + 1.0 + m00 + m11 + m22, + 1.0 + m00 - m11 - m22, + 1.0 - m00 + m11 - m22, + 1.0 - m00 - m11 + m22, + ], + dim=-1, + ) + ).to(**tensor_args) + quat_by_wxyz = torch.stack( + [ + torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1), + torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1), + torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1), + torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1), + ], + dim=-2, + ).to(**tensor_args) + flr = torch.tensor(0.1).to(**tensor_args) + quat_candidates = quat_by_wxyz / (2.0 * q_abs[..., None].max(flr)) + # choose the best-conditioned quaternion with the largest denominator + return quat_candidates[F.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape(batch_dim + (4,)) + + +def q_to_axis_angles(q, eps=1e-10): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + norm_q = torch.norm(q[..., 1:], p=2, dim=-1, keepdim=True) + half_angles = torch.atan2(norm_q, q[..., :1]) + angles = 2 * half_angles + beta = angles.abs() < eps + s_half_angles = torch.empty_like(angles) + s_half_angles[~beta] = ( + torch.sin(half_angles[~beta]) / angles[~beta] + ) + s_half_angles[beta] = ( + 0.5 - (angles[beta] * angles[beta]) / 48 + ) + return q[..., 1:] / s_half_angles + + +def axis_angles_to_q(axis_angles, eps=1e-10): + axis_angles = axis_angles.unsqueeze(0) if len(axis_angles.shape) == 1 else axis_angles + angles = torch.norm(axis_angles, p=2, dim=-1, keepdim=True) + half_angles = angles / 2 + beta = angles.abs() < eps + s_half_angles = torch.empty_like(angles) + s_half_angles[~beta] = ( + torch.sin(half_angles[~beta]) / angles[~beta] + ) + s_half_angles[beta] = ( + 0.5 - (angles[beta] * angles[beta]) / 48 + ) + q = torch.cat( + [torch.cos(half_angles), axis_angles * s_half_angles], dim=-1 + ) + return q + + +def q_to_euler(q): + q = q.unsqueeze(0) if len(q.shape) == 1 else q + w, x, y, z = torch.unbind(q, -1) + t0 = 2.0 * (w * x + y * z) + t1 = 1.0 - 2.0 * (x * x + y * y) + roll = torch.atan2(t0, t1) + t2 = torch.clamp(2.0 * (w * y - z * x), -1.0, 1.0) + pitch = torch.asin(t2) + t3 = 2.0 * (w * z + x * y) + t4 = 1.0 - 2.0 * (y * y + z * z) + yaw = torch.atan2(t3, t4) + euler = torch.stack([roll, pitch, yaw], dim=-1) + return euler + + +def euler_to_q(euler): + euler = euler.unsqueeze(0) if len(euler.shape) == 1 else euler + roll, pitch, yaw = torch.unbind(euler, -1) + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + cp = torch.cos(pitch * 0.5) + sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5) + sr = torch.sin(roll * 0.5) + w = cr * cp * cy + sr * sp * sy + x = sr * cp * cy - cr * sp * sy + y = cr * sp * cy + sr * cp * sy + z = cr * cp * sy - sr * sp * cy + q = torch.stack([w, x, y, z], dim=-1) + return q + + +def q_convert_xyzw(q): + w, x, y, z = torch.unbind(q, dim=-1) + return torch.stack([x, y, z, w], dim=-1) + + +def q_convert_wxyz(q): + x, y, z, w = torch.unbind(q, dim=-1) + return torch.stack([w, x, y, z], dim=-1) \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py new file mode 100644 index 0000000000000000000000000000000000000000..e61ff5c30a65b2eb302bc69c664d528dbe18ce15 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/skeleton.py @@ -0,0 +1,205 @@ +from copy import deepcopy +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph +import json +import torch +import numpy as np +import torch.distributions as D +from torch_robotics.torch_kinematics_tree.geometrics.utils import MinMaxScaler, euclidean_distance + + +class Skeleton(): + + def __init__(self, data, node_list=None, distance_func=None, device='cpu'): + self.load_skeleton(data) + self.node_pos = nx.get_node_attributes(self.skeleton, 'pose') + self.node_var = nx.get_node_attributes(self.skeleton, 'var') + self.node_name = nx.get_node_attributes(self.skeleton, 'name') + self.node_ids = list(self.node_name.keys()) + if node_list is None: + node_list = deepcopy(self.node_ids) + self.node_list = node_list + self.num_node = len(self.node_ids) + # self.node_ids.sort() + if distance_func is None: + distance_func = euclidean_distance + self.distance_func = distance_func + self.device = device + self.node_dist = { + id: D.MultivariateNormal(torch.tensor(self.node_pos[id]).to(device), + covariance_matrix=self.node_var[id] * torch.eye(2, device=device)) + for id in self.node_ids + } + # compute link lengths + self.compute_link_lengths() + # for plotting + self.three_d = len(self.node_pos[self.node_list[-1]]) == 3 + + def load_skeleton(self, data): + if isinstance(data, Path): + with open(data) as f: + data = json.load(f) + self.skeleton = json_graph.node_link_graph(data) + else: # assume dict + self.skeleton = json_graph.node_link_graph(data) + + def compute_link_lengths(self): + for u, v in self.skeleton.edges(): + u_pos, v_pos = torch.tensor(self.node_pos[u], device=self.device), torch.tensor(self.node_pos[v], device=self.device) + self.skeleton[u][v][0]['length'] = self.distance_func(u_pos, v_pos).squeeze().cpu().item() + + def compute_adjacency_matrix(self, weight='length'): + A = nx.adjacency_matrix(self.skeleton, weight=weight) + A = A.todense() + return A + + def get_edges(self, weighted=False): + edges = np.array(list(self.skeleton.edges())) + if weighted: + weights = [[self.skeleton[u][v][0]['length']] for u, v in edges] + else: + weights = [[1.0]] * len(edges) + weights = np.array(weights) + return edges, weights + + def update_node_pose(self, node_dict): + nx.set_node_attributes(self.skeleton, node_dict) + self.node_pos = nx.get_node_attributes(self.skeleton, 'pose') + self.compute_link_lengths() + + def compute_self_distance(self, length_cutoff=None, normalized=False): + res = dict(nx.all_pairs_dijkstra_path_length(self.skeleton, cutoff=length_cutoff, weight='length')) + # since res is a generator + D = np.zeros((len(self.node_list), len(self.node_list))) + for i, id1 in enumerate(self.node_list): + for j, id2 in enumerate(self.node_list): + D[i, j] = res[id1][id2] + if normalized: + scaler = MinMaxScaler() + D = scaler.scale(D) + return D + + def get_all_neighbors(self): + N = np.zeros(len(self.node_list)) + for i, id in enumerate(self.node_list): + N[i] = len(list(self.skeleton.neighbors(id))) + return N + + def get_pos_tensor(self): + return torch.tensor(np.array([self.node_pos[id] for id in self.node_ids]), device=self.device) + + def get_pos_tensor_from_list(self, node_list=None): + if node_list is None: + node_list = self.node_list + return torch.tensor(np.array([self.node_pos[id] for id in node_list]), device=self.device) + + def get_node_list_dict(self): + return {k: v for k, v in self.node_pos.items() if k in self.node_list} + + def draw_skeleton(self, pos=None, shift=None, with_labels=False, color='b', ax=None): + if pos is None: + pos = self.node_pos + else: + if torch.is_tensor(next(iter(pos.values()))): + pos = {k: v.cpu().numpy() for k, v in pos.items()} + if shift is not None: + pos = {k: v + shift for k, v in pos.items()} + if self.three_d: + ax.scatter3D( + xs=[pos[id][0] for id in self.node_list], + ys=[pos[id][1] for id in self.node_list], + zs=[pos[id][2] for id in self.node_list], + color=color, + linewidth=10) + for u, v in self.skeleton.edges(): + if u in self.node_list and v in self.node_list: + ax.plot3D( + xs=[pos[u][0], pos[v][0]], + ys=[pos[u][1], pos[v][1]], + zs=[pos[u][2], pos[v][2]], + color='k', + linewidth=7) + else: + nx.draw(self.skeleton, pos, ax, labels=self.node_name, with_labels=with_labels, font_size=6) + + def sample_posture(self, batch_size): + postures = [] + for id in self.node_ids: + postures.append(self.node_dist[id].sample((batch_size, ))) + return torch.stack(postures, dim=1) + + +def get_skeleton_from_model(model, q_calib, link_list=None): + '''Get Skeleton from robots model''' + if isinstance(q_calib, np.ndarray) or isinstance(q_calib, list): + q_calib = torch.tensor(q_calib, device=model._device) + if q_calib.ndim == 1: + q_calib = q_calib.unsqueeze(0) + H = model.compute_forward_kinematics_all_links(q_calib, return_dict=True) + node_list = [] + # only get euclidean positions + for name, frame in H.items(): + pose = frame.translation + pose = pose.squeeze().detach().cpu().numpy() + node_list.append({ + 'id': name, 'name': name, 'pose': pose, 'var': 0.001 + }) + child_map = model._model.robot.child_map + edge_list = [] + for parent, childs in child_map.items(): + for child in childs: + edge_list.append({ + 'source': parent, + 'target': child[1] + }) + data = {'nodes': node_list, 'links': edge_list} + skeleton = Skeleton(data, node_list=link_list, device=model._device) + return skeleton + + +def get_skeleton_from_mediapipe(landmark_list, connections, link_list=None, present_thres=0.5, vis_thres=0.5, mirror=False, relative_pose=False, shift=np.zeros(3), device='cpu'): + if landmark_list is None: + return None + + plotted_landmarks = {} + for idx, landmark in enumerate(landmark_list.landmark): + if ((landmark.HasField('visibility') and + landmark.visibility < vis_thres) or + (landmark.HasField('presence') and + landmark.presence < present_thres)): + continue + if mirror: + plotted_landmarks[idx] = [-landmark.z, -landmark.x, landmark.y] + else: + plotted_landmarks[idx] = [landmark.z, -landmark.x, -landmark.y] + + + if link_list is not None: + # return None if fail to recognize correct links + if not all(item in plotted_landmarks for item in link_list): + return None + node_list = [] + # only get euclidean positions + base_pose = np.array(plotted_landmarks[0]) + for name, pose in plotted_landmarks.items(): + pose = np.array(pose) + node_list.append({ + 'id': name, 'name': name, 'pose': ((pose - base_pose) if relative_pose else pose) + shift, 'var': 0.001 + }) + num_landmarks = len(landmark_list.landmark) + edge_list = [] + for connection in connections: + start_idx = connection[0] + end_idx = connection[1] + if not (0 <= start_idx < num_landmarks and 0 <= end_idx < num_landmarks): + raise ValueError(f'Landmark index is out of range. Invalid connection ' + f'from landmark #{start_idx} to landmark #{end_idx}.') + if start_idx in plotted_landmarks and end_idx in plotted_landmarks: + edge_list.append({ + 'source': start_idx, + 'target': end_idx + }) + data = {'nodes': node_list, 'links': edge_list} + skeleton = Skeleton(data, node_list=link_list, device=device) + return skeleton diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py new file mode 100644 index 0000000000000000000000000000000000000000..547c7dc97ab9745a1c41e739bbb11dea73f531aa --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/spatial_vector.py @@ -0,0 +1,106 @@ +from __future__ import annotations +from typing import Optional +import torch +from torch_robotics.torch_kinematics_tree.geometrics.utils import cross_product, to_torch_2d_min +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame + + +def x_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.ones(batch_size) + R[:, 1, 1] = torch.cos(angle) + R[:, 1, 2] = -torch.sin(angle) + R[:, 2, 1] = torch.sin(angle) + R[:, 2, 2] = torch.cos(angle) + return R + + +def y_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.cos(angle) + R[:, 0, 2] = torch.sin(angle) + R[:, 1, 1] = torch.ones(batch_size) + R[:, 2, 0] = -torch.sin(angle) + R[:, 2, 2] = torch.cos(angle) + return R + + +def z_rot(angle): + if len(angle.shape) == 0: + angle = angle.unsqueeze(0) + angle = to_torch_2d_min(angle).squeeze(1) + batch_size = angle.shape[0] + R = torch.zeros((batch_size, 3, 3), device=angle.device) + R[:, 0, 0] = torch.cos(angle) + R[:, 0, 1] = -torch.sin(angle) + R[:, 1, 0] = torch.sin(angle) + R[:, 1, 1] = torch.cos(angle) + R[:, 2, 2] = torch.ones(batch_size) + return R + + +class MotionVec(object): + def __init__( + self, + lin_motion: Optional[torch.Tensor] = None, + ang_motion: Optional[torch.Tensor] = None, + device='cpu', + ): + if lin_motion is None or ang_motion is None: + device = device + self.lin = ( + lin_motion if lin_motion is not None else torch.zeros((1, 3), device=device) + ) + self.ang = ( + ang_motion if ang_motion is not None else torch.zeros((1, 3), device=device) + ) + + def add_motion_vec(self, mv: MotionVec) -> MotionVec: + """ + Args: + mv: spatial motion vector + Returns: + the sum of motion vectors + """ + + return MotionVec(self.lin + mv.lin, self.ang + mv.ang) + + def cross_motion_vec(self, mv: MotionVec) -> MotionVec: + """ + Args: + mv: spatial motion vector + Returns: + the cross product between motion vectors + """ + new_ang = cross_product(self.ang, mv.ang) + new_lin = cross_product(self.ang, mv.lin) + cross_product(self.lin, mv.ang) + return MotionVec(new_lin, new_ang) + + def transform(self, transform: Frame) -> MotionVec: + """ + Args: + transform: a coordinate transform object + Returns: + the motion vector (self) transformed by the coordinate transform + """ + new_ang = (transform.rotation @ self.ang.unsqueeze(2)).squeeze(2) + new_lin = (transform.trans_cross_rot() @ self.ang.unsqueeze(2)).squeeze(2) + new_lin += (transform.rotation @ self.lin.unsqueeze(2)).squeeze(2) + return MotionVec(new_lin, new_ang) + + def get_vector(self): + return torch.cat([self.ang, self.lin], dim=1) + + def dot(self, mv): + tmp1 = torch.sum(self.ang * mv.ang, dim=-1) + tmp2 = torch.sum(self.lin * mv.lin, dim=-1) + return tmp1 + tmp2 + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py new file mode 100644 index 0000000000000000000000000000000000000000..6c388f45fb25ea4301ba90289eae63241cb8c6ba --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/trajectory.py @@ -0,0 +1,102 @@ +import numpy as np +import torch + +from torch_kinematics_tree.geometrics.manifold import Manifold + + +def compute_traj_derivatives(traj, dt, manifold=None, smooth=False): + """ + Estimate the trajectory dynamics. + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param smooth (boolean): whether smoothing is applied to traj first. + Returns + ---------- + :return traj (torch of shape (length, dim_M)): pose trajectory without/with smoothing. + :return d_traj (torch of shape (length, dim_T)): first derivative of traj. + :return dd_traj (torch of shape (length, dim_T)): second derivative of traj. + """ + dim_M = traj.shape[-1] + if not manifold: + # if no manifold specified, Euclidean manifold is used. + manifold = Manifold.get_euclidean_manifold(dim_M) + # smoothing first + if smooth: + traj = smooth_traj(traj, manifold=manifold) + # compute derivatives + d_traj = compute_traj_velocity(traj, dt, manifold=manifold) + dd_traj = compute_traj_velocity(d_traj, dt) + # copy the last 2 entries (corner case) + dd_traj[-2, :] = dd_traj[-3, :] + dd_traj[-1, :] = dd_traj[-3, :] + return traj, d_traj, dd_traj + + +def smooth_traj(traj, manifold=None, window_length=30, beta=14): + """ + Smooth the pose using the kaiser window np.kaiser(window_length, beta). + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param window_length (int): the length of the window. (Default: 30). + :param beta (float): shape parameter for the kaiser window. (Default: 14). + Returns + ---------- + :return smooth_traj (torch of shape (length, dim_M)): pose trajectory after smoothing. + """ + length, dim_M = traj.shape[-2:] + tensor_args = {'device': traj.device, 'dtype': traj.dtype} + if not manifold: + manifold = Manifold.get_euclidean_manifold(dim_M) + if dim_M != manifold.dim_M: + raise ValueError('[Trajectory]: Input X shape %s is not consistent with manifold.dim_M %s' % (dim_M, manifold.dim_M)) + # apply kaiser filter + half_wl = int(window_length / 2) + window = torch.kaiser_window(window_length, periodic=False, beta=beta, **tensor_args).unsqueeze(-1) + smooth_traj = traj.detach().clone() + for t in range(length): + weights = window[max(half_wl - t, 0):(2 * half_wl - max(0, t + half_wl - length))] + smooth_traj[t, :] = manifold.mean(smooth_traj[max(t - half_wl, 0):(t + half_wl), :], weights=weights) + return smooth_traj + + +def compute_traj_velocity(traj, dt, manifold=None): + """ + Estimate the first derivative of input trajectory traj. + Parameters + ---------- + :param traj (torch of shape (length, dim_M)): trajectory. + :param dt (float): sampling time associated with traj. + Returns + ---------- + :return d_traj (torch of shape (length, dim_T)): first derivative of traj. + """ + length, dim_M = traj.shape[-2:] + if not manifold: + manifold = Manifold.get_euclidean_manifold(dim_M) + if dim_M != manifold.dim_M: + raise ValueError('[Trajectory]: Input X shape %s is not consistent with manifold.dim_M %s' % (dim_M, manifold.dim_M)) + # estimate d_traj + d_traj = [] + for t in range(length - 1): + d_traj_t = manifold.log_map(traj[t + 1, :], base=traj[t, :]) / dt + d_traj.append(d_traj_t.squeeze()) + d_traj.append(d_traj_t.squeeze()) + d_traj = torch.stack(d_traj, dim=0) + names = np.array(manifold.name.split(' x ')) + if 'S^3' in names: + indices = np.where(names == 'S^3')[0] + for i in indices: + d_traj[:, i * 3:i * 3 + 3] *= 2 # convert to angular + if d_traj.shape[-1] != manifold.dim_T: + raise ValueError('[Trajectory]: d_traj shape %s is not consistent with manifold.dim_T %s' % (d_traj.shape[0], manifold.dim_T)) + if d_traj.shape[-2] != length: + raise ValueError('[Trajectory]: Length of d_traj %s is not consistent with input traj length %s' % (d_traj.shape[1], length)) + return d_traj + + +def interpolate(p1, p2, d_param, dist): + """ interpolate between points """ + alpha = min(max(d_param / dist, 0.), 1.) + return (1. - alpha) * p1 + alpha * p2 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..9580b14dca47725d673be6d3e5c295cb3340d048 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/geometrics/utils.py @@ -0,0 +1,349 @@ +import torch +import numpy as np + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import rotation_matrix_to_q +from torch_robotics.torch_utils.torch_utils import to_torch, to_torch_2d_min + +DEFAULT_ACOS_BOUND: float = 1.0 - 1e-4 + + +@torch.jit.script +def multiply_transform(w_rot_l, w_trans_l, l_rot_c, l_trans_c): + + l_trans_c = l_trans_c.float() + w_rot_c = w_rot_l @ l_rot_c + w_trans_c = (w_rot_l @ l_trans_c.unsqueeze(-1)).squeeze(-1) + w_trans_l + + return w_rot_c, w_trans_c + + +@torch.jit.script +def multiply_inv_transform(l_rot_w, l_trans_w, l_rot_c, l_trans_c): + w_rot_l = l_rot_w.transpose(-1, -2) + w_rot_c = w_rot_l @ l_rot_c + + w_trans_l = -(w_rot_l @ l_trans_w.unsqueeze(2)).squeeze(2) + w_trans_c = (w_rot_l @ l_trans_c.unsqueeze(-1)).squeeze(-1) + w_trans_l + + return w_rot_c, w_trans_c + + +@torch.jit.script +def transform_point(point, rot, trans): + new_point = (point @ rot.transpose(-1, -2)) + trans + return new_point + + +@torch.jit.script +def rotate_point(point, rot): + new_point = (point @ rot.transpose(-1, -2)) + return new_point + + +def vector3_to_skew_symm_matrix(vec3): + vec3 = to_torch_2d_min(vec3) + batch_size = vec3.shape[0] + skew_symm_mat = vec3.new_zeros((batch_size, 3, 3)) + skew_symm_mat[:, 0, 1] = -vec3[:, 2] + skew_symm_mat[:, 0, 2] = vec3[:, 1] + skew_symm_mat[:, 1, 0] = vec3[:, 2] + skew_symm_mat[:, 1, 2] = -vec3[:, 0] + skew_symm_mat[:, 2, 0] = -vec3[:, 1] + skew_symm_mat[:, 2, 1] = vec3[:, 0] + return skew_symm_mat + + +def skew_symm_matrix_to_vec(R): + if R.ndim < 3: + R = R.unsqueeze(0) + batch_dim = R.shape[:-2] + vec = torch.zeros(batch_dim + (3,)) + vec[..., 0] = R[..., 2, 1] + vec[..., 1] = R[..., 0, 2] + vec[..., 2] = R[..., 1, 0] + return vec + + +class MinMaxScaler(): + # NOTE: scale x, y to range [0, 1] + def __init__(self, min=None, max=None, dim=None) -> None: + self.min = min + self.max = max + self.dim = dim + + def scale(self, X): + if isinstance(X, np.ndarray): + if self.min is None: + self.min = X.min(self.dim) # link dim + if self.max is None: + self.max = X.max(self.dim) # link dim + else: + if self.min is None: + if self.dim is None: + self.min = X.detach().min() + else: + self.min = X.detach().min(self.dim)[0] # link dim + if self.max is None: + if self.max is None: + self.max = X.detach().max() + else: + self.max = X.detach().min(self.dim)[0] # link dim + + X_scaled = (X - self.min) / (self.max - self.min) + return X_scaled + + +def euclidean_distance(x_batch, x_target, vel_batch=None, vel_target=None, w_pos=1., w_rot=0., w_vpos=1., w_vrot=0., broadcast_target=False, normalized_input=False): + # NOTE: normalizing x inputs + if x_batch.ndim == 1: + x_batch = x_batch.unsqueeze(0) + + if x_target.ndim == 1: + x_target = x_target.unsqueeze(0) + + dim_scale_target = -2 + if broadcast_target: + x_target = x_target.unsqueeze(1) + dim_scale_target = -3 + if normalized_input: + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + x_batch = scaler1.scale(x_batch) + x_target = scaler2.scale(x_target) + T_distance = torch.linalg.norm(x_batch - x_target, dim=-1) + D = w_pos * T_distance + + # now compute vel distance + if vel_batch is not None and vel_target is not None: + if vel_target.ndim == 1: + vel_target = vel_target.unsqueeze(0) + if broadcast_target: + vel_target = vel_target.unsqueeze(1) + if normalized_input: # normalize to range [0, 1] + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + vel_batch = scaler1.scale(vel_batch) + vel_target = scaler2.scale(vel_target) + D += w_vpos * torch.linalg.norm(vel_batch - vel_target, dim=-1) + + return w_pos * T_distance + + +def SE3_distance(H_batch, H_target, vel_batch=None, vel_target=None, w_pos=1., w_rot=1., w_vpos=1., w_vrot=1., broadcast_target=False, normalized_input=False): + # NOTE: normalizing x inputs + if H_batch.ndim == 2: + H_batch = H_batch.unsqueeze(0) + + if H_target.ndim == 2: + H_target = H_target.unsqueeze(0) + + dim_scale_target = -2 + if broadcast_target: + H_target = H_target.unsqueeze(1) + dim_scale_target = -3 + + x_batch, x_target = H_batch[..., :-1, -1], H_target[..., :-1, -1] + if normalized_input: # normalize to range [0, 1] + scaler1, scaler2 = MinMaxScaler(dim=-2), MinMaxScaler(dim=dim_scale_target) + x_batch = scaler1.scale(x_batch) + x_target = scaler2.scale(x_target) + D = 0. + if w_rot > 0.: + R_distance = (1 - so3_relative_angle(H_batch[..., :3, :3], H_target[..., :3, :3], cos_angle=True)) + D += w_rot * R_distance + if w_pos > 0.: + T_distance = torch.linalg.norm(x_batch - x_target, dim=-1) + D += w_pos * T_distance + + # now compute vel distance + if vel_batch is not None and vel_target is not None: + v_trans, v_rot = vel_batch[0], vel_batch[1] + v_trans_target, v_rot_target = vel_target[0], vel_target[1] + if v_trans_target.ndim == 1: + v_trans_target = v_trans_target.unsqueeze(0) + if v_rot_target.ndim == 1: + v_rot_target = v_rot_target.unsqueeze(0) + if broadcast_target: + v_trans_target = v_trans_target.unsqueeze(1) + v_rot_target = v_rot_target.unsqueeze(1) + if normalized_input: # normalize to range [0, 1] + v_trans_scaler1, v_rot_scaler1 = MinMaxScaler(dim=-2), MinMaxScaler(dim=-2) + v_trans = v_trans_scaler1.scale(v_trans) + v_rot = v_rot_scaler1.scale(v_rot) + v_trans_scaler2, v_rot_scaler2 = MinMaxScaler(dim=dim_scale_target), MinMaxScaler(dim=dim_scale_target) + v_trans_target = v_trans_scaler2.scale(v_trans_target) + v_rot_target = v_rot_scaler2.scale(v_rot_target) + if w_vpos > 0.: + D += w_vpos * torch.linalg.norm(v_trans - v_trans_target, dim=-1) + if w_vrot > 0.: + D += w_vrot * torch.linalg.norm(v_rot - v_rot_target, dim=-1) + return D + + +def minus_SO3(R1, R2, eps=1.0e-14, add_dim=True): + R12 = torch.matmul(R1, R2.transpose(-2, -1)) + skew_mat = log_SO3(R12, eps=eps) + vec = skew_symm_matrix_to_vec(skew_mat) + if add_dim: + vec = vec.unsqueeze(0) + return vec + + +def log_SO3(R, eps=1.0e-14): + # assert ( + # torch.abs(torch.abs(torch.det(R)) - 1.0) < 1e-3 + # ), "det(R) = %f" % torch.det(R) + trR = (torch.einsum('...ii->...', R) - 1.0) / 2.0 + cond = trR < -1 + if cond.any(): + print("Warning: trR/2-1 = %f < -1.0" % trR) + trR[cond] = -1. + cond = trR > 1 + if cond.any(): + print("Warning: trR/2-1 = %f > 1.0" % trR) + trR[cond] = 1. + + theta = torch.acos(trR).view(R.shape[:-2] + (1, 1)) + omegahat = (R - R.mT) / ((2.0 * torch.sin(theta)) + eps) + return theta * omegahat + + +def so3_relative_angle(R1, R2, cos_angle=False, eps=1e-4): + R12 = torch.matmul(R1, R2.transpose(-2, -1)) + return so3_rotation_angle(R12, cos_angle=cos_angle, eps=eps) + + +def so3_rotation_angle(R, cos_angle=False, eps=1e-4): + dim1, dim2 = R.shape[-2:] + if dim1 != 3 or dim2 != 3: + raise ValueError("Input has to be a batch of 3x3 Tensors.") + rot_trace = R[..., 0, 0] + R[..., 1, 1] + R[..., 2, 2] + + # phi rotation angle + phi_cos = (rot_trace - 1.0) * 0.5 + + if cos_angle: + return phi_cos + else: + if eps > 0.0: + bound = 1.0 - eps + return acos_linear_extrapolation(phi_cos, (-bound, bound)) + else: + return torch.acos(phi_cos) + + +def acos_linear_extrapolation(x, bounds=(-(DEFAULT_ACOS_BOUND), DEFAULT_ACOS_BOUND)): + lower_bound, upper_bound = bounds + if lower_bound > upper_bound: + raise ValueError("lower bound has to be smaller or equal to upper bound.") + if lower_bound <= -1.0 or upper_bound >= 1.0: + raise ValueError("Both lower bound and upper bound have to be within (-1, 1).") + + # init an empty tensor and define the domain sets + acos_extrap = torch.empty_like(x) + x_upper = x >= upper_bound + x_lower = x <= lower_bound + x_mid = (~x_upper) & (~x_lower) + + # acos calculation for upper_bound < x < lower_bound + acos_extrap[x_mid] = torch.acos(x[x_mid]) + # the linear extrapolation for x >= upper_bound + acos_extrap[x_upper] = _acos_linear_approximation(x[x_upper], upper_bound) + # the linear extrapolation for x <= lower_bound + acos_extrap[x_lower] = _acos_linear_approximation(x[x_lower], lower_bound) + + return acos_extrap + + +def _acos_linear_approximation(x: torch.Tensor, x0: float) -> torch.Tensor: + """ + Calculates the 1st order Taylor expansion of `arccos(x)` around `x0`. + """ + return (x - x0) * _dacos_dx(x0) + np.arccos(x0) + + +def _dacos_dx(x: float) -> float: + """ + Calculates the derivative of `arccos(x)` w.r.t. `x`. + """ + return (-1.0) / np.sqrt(1.0 - x * x) + + +def cross_product(vec3a, vec3b): + vec3a = to_torch_2d_min(vec3a) + vec3b = to_torch_2d_min(vec3b) + skew_symm_mat_a = vector3_to_skew_symm_matrix(vec3a) + return (skew_symm_mat_a @ vec3b.unsqueeze(2)).squeeze(2) + + +def bfill_lowertriangle(A: torch.Tensor, vec: torch.Tensor): + ii, jj = np.tril_indices(A.size(-2), k=-1, m=A.size(-1)) + A[..., ii, jj] = vec + return A + + +def bfill_diagonal(A: torch.Tensor, vec: torch.Tensor): + ii, jj = np.diag_indices(min(A.size(-2), A.size(-1))) + A[..., ii, jj] = vec + return A + +def torch_square(x): + return x * x + + +def exp_map_so3(omega, eps=1.0e-14): + omegahat = vector3_to_skew_symm_matrix(omega).squeeze() + + norm_omega = torch.norm(omega, p=2) + exp_omegahat = ( + torch.eye(3) + + ((torch.sin(norm_omega) / (norm_omega + eps)) * omegahat) + + ( + ((1.0 - torch.cos(norm_omega)) / (torch_square(norm_omega + eps))) + * (omegahat @ omegahat) + ) + ) + return exp_omegahat + + +def rot_mat_to_euler(R): + sy = torch.sqrt(R[0, 0] * R[0, 0] + R[1, 0] * R[1, 0]) + singular = sy < 1e-10 + if not singular: + x = torch.atan2(R[2, 1] , R[2, 2]) + y = torch.atan2(-R[2, 0], sy) + z = torch.atan2(R[1, 0], R[0, 0]) + else: + x = torch.atan2(-R[1, 2], R[1, 1]) + y = torch.atan2(-R[2, 0], sy) + z = 0 + return torch.tensor([x, y, z]) + + +def link_pos_from_link_tensor(link_tensor): + """ + Extracts the position of the link from the link tensor. + """ + if link_tensor.shape[-1] == 3: + link_pos = link_tensor[..., :2, 2] + elif link_tensor.shape[-1] == 4: + link_pos = link_tensor[..., :3, 3] + else: + raise ValueError + return link_pos + + +def link_rot_from_link_tensor(link_tensor): + if link_tensor.shape[-1] == 3: + link_rot = link_tensor[..., :2, :2] + elif link_tensor.shape[-1] == 4: + link_rot = link_tensor[..., :3, :3] + else: + raise ValueError + return link_rot + + +def link_quat_from_link_tensor(link_tensor): + rot = link_rot_from_link_tensor(link_tensor) + quat = rotation_matrix_to_q(rot) + return quat + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..34c82471022f4739f91761406b70d9edb13beb86 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/LICENSE @@ -0,0 +1,52 @@ +MIT License + +Copyright (c) 2022 An Thai Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +********************************************************************** +The first version of some files were licensed as +"Original Source License" (see below). Several enhancements and bug fixes +were done by An Thai Le since obtaining the first version. + + + +Original Source License: + +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py new file mode 100644 index 0000000000000000000000000000000000000000..30cb9af896e970bd658a6bef7ff7c98f95a6986d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/rigid_body.py @@ -0,0 +1,270 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) Facebook, Inc. and its affiliates. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List, Dict, Optional + +import torch +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import ( + MotionVec, + z_rot, + y_rot, + x_rot, +) +from torch_robotics.torch_kinematics_tree.geometrics.frame import Frame + + +class DifferentiableRigidBody(torch.nn.Module): + """ + Differentiable Representation of a link + """ + + _parents: Optional["DifferentiableRigidBody"] + _children: List["DifferentiableRigidBody"] + + def __init__(self, rigid_body_params, device='cpu'): + + super().__init__() + + self.is_root = False + self._parents = None + self._children = [] + + self._device = device + self.joint_id = rigid_body_params["joint_id"] + self.name = rigid_body_params["link_name"] + + self.joint_damping = rigid_body_params["joint_damping"] + self.trans = rigid_body_params["trans"].reshape(1, 3) + self.rot_angles = rigid_body_params["rot_angles"].reshape(1, 3) + rot_angles_vals = self.rot_angles + roll = rot_angles_vals[0, 0] + pitch = rot_angles_vals[0, 1] + yaw = rot_angles_vals[0, 2] + self.fixed_rotation = (z_rot(yaw) @ y_rot(pitch)) @ x_rot(roll) + + # local joint axis (w.r.t. joint coordinate frame): + self.joint_axis = rigid_body_params["joint_axis"] + self.axis_idx = torch.nonzero(self.joint_axis.squeeze(0)) + if self.axis_idx.nelement() > 0: + self.axis_idx = self.axis_idx[0] + + if self.joint_axis[0, 0] == 1: + self.axis_rot_fn = x_rot + elif self.joint_axis[0, 1] == 1: + self.axis_rot_fn = y_rot + else: + self.axis_rot_fn = z_rot + self.joint_type = rigid_body_params["joint_type"] + self.joint_limits = rigid_body_params["joint_limits"] + + self.joint_pose = Frame(device=self._device) + self.joint_pose.set_translation(torch.reshape(self.trans, (1, 3))) + + # local velocities and accelerations (w.r.t. joint coordinate frame): + self.joint_vel = MotionVec(device=self._device) + self.joint_acc = MotionVec(device=self._device) + + self.update_joint_state( + torch.zeros([1, 1], device=self._device), + torch.zeros([1, 1], device=self._device), + ) + self.update_joint_acc(torch.zeros([1, 1], device=self._device)) + + self.reset() + + def reset(self): + ''' + Reset pose, velocity and acceleration to init state. + ''' + self.pose = Frame(device=self._device) + self.vel = MotionVec(device=self._device) + self.acc = MotionVec(device=self._device) + + def update_pose(self, pose_vec): + pose = Frame(device=self._device) + pose.set_pose(pose_vec) + self.pose = pose + + # Kinematic tree construction + def set_parent(self, link: "DifferentiableRigidBody"): + self._parent = link + + def add_child(self, link: "DifferentiableRigidBody"): + self._children.append(link) + + # Recursive algorithms + def forward_kinematics(self, q_dict, batch_size): + """Recursive forward kinematics + Computes transformations from self to all descendants. + + Returns: Dict[link_name, transform_from_self_to_link] + """ + # Compute joint pose + if self.name in q_dict: + q = q_dict[self.name] + + # bound q with limits + if self.joint_limits is not None: + q_clamped = torch.clamp(q, min=self.joint_limits['lower'], max=self.joint_limits['upper']) + else: + q_clamped = q + + if self.joint_type in ['revolute', 'continuous']: + if torch.abs(self.joint_axis[0, 0]) == 1: + rot = x_rot(torch.sign(self.joint_axis[0, 0]) * q_clamped) + elif torch.abs(self.joint_axis[0, 1]) == 1: + rot = y_rot(torch.sign(self.joint_axis[0, 1]) * q_clamped) + else: + rot = z_rot(torch.sign(self.joint_axis[0, 2]) * q_clamped) + + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1) @ rot, + trans=self.trans.repeat(batch_size, 1), + device=self._device, + ) + elif self.joint_type == 'prismatic': + trans = self.joint_axis * q_clamped + + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1), + trans=self.trans + trans, + device=self._device, + ) + else: + raise NotImplementedError() + else: + joint_pose = Frame( + rot=self.fixed_rotation.repeat(batch_size, 1, 1), + trans=self.trans.repeat(batch_size, 1), + device=self._device, + ) + if self.is_root: + joint_pose = self.pose.multiply_transform(joint_pose) + + # Compute forward kinematics of children + pose_dict = {self.name: self.pose} + for child in self._children: + pose_dict.update(child.forward_kinematics(q_dict, batch_size)) + + # Apply joint pose + result = {} + for body_name in pose_dict: + if self.is_root and body_name == self.name: + base_pose = Frame( + rot=self.pose.rotation.repeat(batch_size, 1, 1), + trans=self.pose.translation.repeat(batch_size, 1), + device=self._device, + ) + result[body_name] = base_pose + else: + result[body_name] = joint_pose.multiply_transform(pose_dict[body_name]) + return result + + # Get/set + def update_joint_state(self, q, qd): + batch_size = q.shape[0] + + # bound q with limits + if self.joint_limits is not None: + q_clamped = torch.clamp(q, min=self.joint_limits['lower'], max=self.joint_limits['upper']) + qd_clamped = qd # not clamping velocity for now + # qd_clamped = torch.clamp(qd, min=-self.joint_limits['velocity'], max=self.joint_limits['velocity']) + else: + q_clamped = q + qd_clamped = qd + + if self.joint_type in ['revolute', 'continuous']: + rot = self.axis_rot_fn(q_clamped) + # if torch.abs(self.joint_axis[0, 0]) == 1: + # rot = x_rot(torch.sign(self.joint_axis[0, 0]) * q_clamped) + # elif torch.abs(self.joint_axis[0, 1]) == 1: + # rot = y_rot(torch.sign(self.joint_axis[0, 1]) * q_clamped) + # else: + # rot = z_rot(torch.sign(self.joint_axis[0, 2]) * q_clamped) + + self.joint_pose.set_translation( + self.trans.repeat(batch_size, 1) + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1) @ rot) + elif self.joint_type == 'prismatic': + trans = self.joint_axis * q_clamped + self.joint_pose.set_translation( + self.trans + trans + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1)) + elif self.joint_type == 'fixed': + self.joint_pose.set_translation( + self.trans.repeat(batch_size, 1) + ) + self.joint_pose.set_rotation(self.fixed_rotation.repeat(batch_size, 1, 1)) + else: + raise NotImplementedError(f'Joint Type: {self.joint_type}') + + joint_ang_vel = qd_clamped @ self.joint_axis + self.joint_vel = MotionVec( + torch.zeros_like(joint_ang_vel), joint_ang_vel + ) + + def update_joint_acc(self, qdd): + # local z axis (w.r.t. joint coordinate frame): + joint_ang_acc = qdd @ self.joint_axis + self.joint_acc = MotionVec( + torch.zeros_like(joint_ang_acc), joint_ang_acc + ) + return + + def get_joint_limits(self): + return self.joint_limits + + def get_joint_damping_const(self): + return self.joint_damping diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py new file mode 100644 index 0000000000000000000000000000000000000000..31ed40ab082407e22488eae52c2c248ea0694204 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robot_tree.py @@ -0,0 +1,492 @@ +# MIT License + +# Copyright (c) 2022 An Thai Le + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# ********************************************************************** +# The first version of some files were licensed as +# "Original Source License" (see below). Several enhancements and bug fixes +# were done by An Thai Le since obtaining the first version. + + + +# Original Source License: + +# MIT License + +# Copyright (c) Facebook, Inc. and its affiliates. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List, Tuple, Dict, Optional +from dataclasses import dataclass + +import torch +import numpy as np +from torch.autograd.functional import jacobian + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_axis_angles, q_div, q_convert_wxyz +from torch_robotics.torch_kinematics_tree.geometrics.skeleton import get_skeleton_from_model +from torch_robotics.torch_kinematics_tree.models.rigid_body import DifferentiableRigidBody +from torch_robotics.torch_kinematics_tree.models.utils import URDFRobotModel, MJCFRobotModel +from torch_robotics.torch_kinematics_tree.geometrics.spatial_vector import MotionVec +from torch_robotics.torch_kinematics_tree.geometrics.utils import SE3_distance, link_pos_from_link_tensor, \ + link_quat_from_link_tensor, link_rot_from_link_tensor +from torch_robotics.torch_utils.torch_utils import to_torch, torch_intersect_1d + + +def convert_link_dict_to_tensor(link_dict, link_list): + return torch.stack([link_dict[name].get_transform_matrix() for name in link_list], dim=1) + + +class DifferentiableTree(torch.nn.Module): + + def __init__(self, model_path: str, name="", link_list=None, device='cpu'): + + super().__init__() + + self.name = name + self.link_list = link_list + + self._device = device + self.model_type = model_path.split('.')[-1] + if self.model_type == 'urdf': + self._model = URDFRobotModel(model_path=model_path, device=self._device) + elif self.model_type == 'xml': + self._model = MJCFRobotModel(model_path=model_path, device=self._device) + else: + raise NotImplementedError(f'{self.model_type} is not supported!') + self._bodies = torch.nn.ModuleList() + self._n_dofs = 0 + self._controlled_joints = [] + + # NOTE: making the joint a part of the rigid body + # while urdfs model joints and rigid bodies separately + # NOTE: joint is at the beginning of a link + self._name_to_idx_map = dict() + + for (i, link) in enumerate(self._model.links): + # Initialize body object + rigid_body_params = self._model.get_body_parameters(i, link) + body = DifferentiableRigidBody( + rigid_body_params=rigid_body_params, device=self._device + ) + if i == 0: + body.is_root = True # set root + + # Joint properties + body.joint_idx = None + if rigid_body_params["joint_type"] != "fixed": + body.joint_idx = self._n_dofs + self._n_dofs += 1 + self._controlled_joints.append(i) + + # Add to data structures + self._bodies.append(body) + self._name_to_idx_map[body.name] = i + + # Once all bodies are loaded, connect each body to its parent + for body in self._bodies[1:]: + parent_body_name = self._model.get_name_of_parent_body(body.name) + parent_body_idx = self._name_to_idx_map[parent_body_name] + body.set_parent(self._bodies[parent_body_idx]) + self._bodies[parent_body_idx].add_child(body) + + def reset(self): + '''Reset the robots FK after stateful update''' + for body in self._bodies: + body.reset() + + def update_base_pose(self, pose_vec): + self._bodies[0].update_pose(pose_vec) + + def update_kinematic_state(self, q: torch.Tensor, qd: torch.Tensor) -> None: + """ + Updates the kinematic state of the robots in a stateful way + Args: + q: joint angles [batch_size x n_dofs] + qd: joint velocities [batch_size x n_dofs] + + Returns: + + """ + assert q.ndim == 2 + assert qd.ndim == 2 + assert q.shape[1] == self._n_dofs + assert qd.shape[1] == self._n_dofs + + batch_size = q.shape[0] + + # update the state of the joints + for i in range(q.shape[1]): + idx = self._controlled_joints[i] + self._bodies[idx].update_joint_state( + q[:, i].unsqueeze(1), qd[:, i].unsqueeze(1) + ) + + # we assume a non-moving base + parent_body = self._bodies[0] + parent_body.vel = MotionVec( + torch.zeros((batch_size, 3), device=self._device), + torch.zeros((batch_size, 3), device=self._device), + ) + + # propagate the new joint state through the kinematic chain to update bodies position/velocities + for i in range(1, len(self._bodies)): + + body = self._bodies[i] + parent_name = self._model.get_name_of_parent_body(body.name) + # find the joint that has this link as child + parent_body = self._bodies[self._name_to_idx_map[parent_name]] + + # transformation operator from child link to parent link + childToParentT = body.joint_pose + # transformation operator from parent link to child link + parentToChildT = childToParentT.inverse() + + # the position and orientation of the body in world coordinates, with origin at the joint + body.pose = parent_body.pose.multiply_transform(childToParentT) + + # we rotate the velocity of the parent's body into the child frame + new_vel = parent_body.vel.transform(parentToChildT) + + # this body's angular velocity is combination of the velocity experienced at it's parent's link + # + the velocity created by this body's joint + body.vel = body.joint_vel.add_motion_vec(new_vel) + + return + + def compute_forward_kinematics( + self, q: torch.Tensor, qd: torch.Tensor, link_name: str, state_less: bool = False + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + + Args: + q: joint angles [batch_size x n_dofs] + link_name: name of link + + Returns: translation and rotation of the link frame + + """ + + assert q.ndim == 2 + + if state_less: + return self.compute_forward_kinematics_all_links(q, link_list=[link_name]) # return SE3 matrix + else: + self.update_kinematic_state(q, qd) + + pose = self._bodies[self._name_to_idx_map[link_name]].pose + pos = pose.translation + rot = pose.get_quaternion() + rot = q_convert_wxyz(rot) # pose.get_quaternion() returns a quaternion xyzw + return pos, rot # return tuple of translation and rotation + + def compute_forward_kinematics_and_geometric_jacobian( + self, q: torch.Tensor, qd: torch.Tensor, link_name: str + ) -> Tuple[torch.Tensor, torch.Tensor]: + r""" + Args: + link_name: name of link name for the jacobian + q: joint angles [batch_size x n_dofs] + qd: joint velocities [batch_size x n_dofs] + Returns: ee_pos, ee_rot and linear and angular jacobian + """ + batch_size = q.shape[0] + ee_pos, ee_rot = self.compute_forward_kinematics(q, qd, link_name) + + lin_jac = torch.zeros([batch_size, 3, self._n_dofs], device=self._device) + ang_jac = torch.zeros([batch_size, 3, self._n_dofs], device=self._device) + # any joints larger than this joint, will have 0 in the jacobian + # parent_name = self._urdf_model.get_name_of_parent_body(link_name) + parent_joint_id = self._model.find_joint_of_body(link_name) + + # TODO - implement as a tensor cross product + for i, idx in enumerate(self._controlled_joints): + if (idx - 1) > parent_joint_id: + continue + pose = self._bodies[idx].pose + axis_idx = self._bodies[idx].axis_idx + p_i = pose.translation + z_i = torch.index_select(pose.rotation, -1, axis_idx).squeeze(-1) + lin_jac[:, :, i] = torch.cross(z_i, ee_pos - p_i) + ang_jac[:, :, i] = z_i + + return ee_pos, ee_rot, lin_jac, ang_jac + + def compute_analytical_jacobian_all_links( + self, q: torch.Tensor + ): + # Batch computation of analytical jacobian for all links + if q.ndim == 1: + q = q.unsqueeze(0) + + def surrogate_fn(x): + # Surrogate function that sums over the batch size + links_tensor = self.compute_forward_kinematics_all_links(x) + pos = link_pos_from_link_tensor(links_tensor) + quat = link_quat_from_link_tensor(links_tensor) + return torch.cat((pos, quat), dim=-1).sum(0) + + links_jac = jacobian(surrogate_fn, q).movedim(-2, 0) + return links_jac + + def compute_forward_kinematics_all_links( + self, q: torch.Tensor, return_dict=False, link_list=None + ) -> [torch.Tensor, dict]: + """ + Stateless forward kinematics + Args: + q: joint angles [batch_size x n_dofs] + link_name: name of link + + Returns: dict link frame + + """ + if q.ndim == 1: + q = q.unsqueeze(0) + + batch_size = q.shape[0] + + if link_list is None: + link_list = self.link_list + + # Create joint state dictionary + q_dict = {} + for i, body_idx in enumerate(self._controlled_joints): + q_dict[self._bodies[body_idx].name] = q[:, i].unsqueeze(1) + + # Call forward kinematics on root node + pose_dict = self._bodies[0].forward_kinematics(q_dict, batch_size) + + if link_list is None: # link list is None again, output whole body + link_list = self.get_link_names() + if not return_dict: + return convert_link_dict_to_tensor(pose_dict, link_list) + else: + link_dict = {k: v for k, v in pose_dict.items() if k in link_list} + return link_dict + + def inverse_kinematics( + self, H_target, link_name='ee_link', + batch_size=1, + max_iters=1000, lr=1e-2, + se3_eps=1e-1, + q0=None, + q0_noise=torch.pi/8, + eps_joint_lim=torch.pi/100, + print_freq=50, + debug=False + ): + """ + Solve IK using Adam optimizer + Args: + H_target: target SE3 matrix [batch_size x 4 x 4] + num_iters: number of iterations + se3_eps: threshold for stopping the iteration + q0: initial guess for the joint angles + + Returns: joint angles + + """ + if H_target.ndim == 2: + H_target = H_target.unsqueeze(0) + + # Sample configurations uniformly + lower, upper, _, _ = self.get_joint_limit_array() + lower += eps_joint_lim + upper -= eps_joint_lim + lower = to_torch(lower, device=self._device) + upper = to_torch(upper, device=self._device) + if q0 is None: + q0 = torch.rand(batch_size, self._n_dofs, device=self._device) + q0 = lower + q0 * (upper - lower) + else: + # add some noise to get diverse solutions + q0 += torch.randn(batch_size, self._n_dofs, device=self._device) * q0_noise + q0 = torch.clamp(q0, lower, upper) + assert q0.shape[0] == batch_size + assert q0.shape[1] == self._n_dofs + + # Optimize + q = q0.clone().requires_grad_(True) + optimizer = torch.optim.Adam([q], lr=lr) + for i in range(max_iters): + optimizer.zero_grad() + # Check if all configurations respect the termination criteria + idx_valid = self.ik_termination( + q, H_target, link_name, + lower, upper, + se3_eps=se3_eps, + debug=debug + ) + if idx_valid.nelement() == batch_size: + print(f'\nIK converged for all joint configurations in {i} iterations') + break + + # TODO - optimize only the joint configurations that are not valid + + # loss and gradient step + err_per_q = self.loss_fn_ik_per_q( + q, H_target, link_name, + w_se3=1.0, + w_joint_limits=300.0, lower=lower, upper=upper, + w_q_rest=1., q_rest=None, + debug=debug + ) + if (i == 0 or (i % print_freq) == 0) and print_freq != -1: + print(f'\n---> Iter {i}/{max_iters}') + print(f'Error mean, std: {err_per_q.mean():.3f}, {err_per_q.std():.3f}') + print(f'idx_valid: {len(idx_valid)}/{batch_size}') + + # do not optimize configurations that are already valid + err_per_q.sum().backward() + optimizer.step() + + if i == max_iters - 1: + print(f'\nIK did not converge for all joint configurations!') + print(f'Error mean, std: {err_per_q.mean():.3f}, {err_per_q.std():.3f}') + print(f'idx_valid: {len(idx_valid)}/{batch_size}') + + return q, idx_valid + + def loss_fn_ik_per_q( + self, + q, H_target, link_name, + w_se3=1.0, + w_joint_limits=1.0, lower=None, upper=None, + w_q_rest=1., q_rest=None, + debug=False + ): + # EE cost + H = self.compute_forward_kinematics_all_links(q, link_list=[link_name]).squeeze(1) + err_se3 = SE3_distance(H, H_target, w_pos=1., w_rot=1.) + + # Joint limit cost + lower_mask = torch.where(q < lower, 1, 0) + err_joint_limit_lower = ((lower - q).pow(2) * lower_mask).sum(-1) + upper_mask = torch.where(q > upper, 1, 0) + err_joint_limit_upper = ((upper - q).pow(2) * upper_mask).sum(-1) + err_joint_limits = err_joint_limit_lower + err_joint_limit_upper + + # Rest configuration cost + err_q_rest = 0. + if q_rest is not None: + err_q_rest = torch.linalg.norm(q - q_rest, dim=-1) + + # Total error + if debug: + print(f'w_se3 * err_se3 : {w_se3 * err_se3}') + print(f'w_joint_limits * err_joint_limits: {w_joint_limits * err_joint_limits}') + print(f'w_q_rest * err_q_rest : {w_q_rest * err_q_rest}') + + err = w_se3 * err_se3 + w_joint_limits * err_joint_limits + w_q_rest * err_q_rest + return err + + def ik_termination( + self, + q, H_target, link_name, + lower, upper, + se3_eps=1e-1, + debug=False + ): + # Check joint limits + idx_valid_joints = torch.argwhere(torch.all(torch.logical_and(q >= lower, q <= upper), dim=-1)).squeeze() + idx_valid_joints = torch.atleast_1d(idx_valid_joints) + + # Check SE3 distance + H = self.compute_forward_kinematics_all_links(q, link_list=[link_name]).squeeze(1) + err_se3 = SE3_distance(H, H_target, w_pos=1., w_rot=1.) + idx_valid_se3 = torch.argwhere(err_se3 < se3_eps).squeeze() + idx_valid_se3 = torch.atleast_1d(idx_valid_se3) + + if debug: + print(f'#idx_valid_joints : {len(idx_valid_joints) if idx_valid_joints.nelement()>0 else 0}') + print(f'#idx_valid_se3 : {len(idx_valid_se3) if idx_valid_se3.nelement()>0 else 0}') + + idx_valid = torch_intersect_1d(idx_valid_joints, idx_valid_se3) + + return idx_valid + + def get_joint_limits(self) -> List[Dict[str, torch.Tensor]]: + """ + + Returns: list of joint limit dict, containing joint position, velocity and effort limits + + """ + limits = [] + for idx in self._controlled_joints: + limits.append(self._bodies[idx].get_joint_limits()) + return limits + + def get_joint_limit_array(self) -> Tuple[np.ndarray]: + """ + + Returns: list of joint limit dict, containing joint position, velocity and effort limits + + """ + lowers = [] + uppers = [] + vel_lowers = [] + vel_uppers = [] + for idx in self._controlled_joints: + data = self._bodies[idx].get_joint_limits() + lowers.append(data['lower']) + uppers.append(data['upper']) + vel_lowers.append(-data['velocity']) + vel_uppers.append(data['velocity']) + return np.array(lowers), np.array(uppers), np.array(vel_lowers), np.array(vel_uppers) + + def get_link_names(self) -> List[str]: + """ + + Returns: a list containing names for all links + + """ + link_names = [] + for i in range(len(self._bodies)): + link_names.append(self._bodies[i].name) + return link_names + + def print_link_names(self) -> None: + """ + + print the names of all links + + """ + names = self.get_link_names() + for i in range(len(names)): + print(names[i]) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py new file mode 100644 index 0000000000000000000000000000000000000000..34a6a281444a3af6e5f93768e16089eff5946a75 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/robots.py @@ -0,0 +1,133 @@ +from pathlib import Path +from typing import Optional, List +from xml.dom import minidom + +import numpy as np +from urdf_parser_py.urdf import URDF, Joint, Link, Visual, Collision, Box, Pose + +from torch_robotics.torch_kinematics_tree.geometrics.quaternion import q_to_euler +from torch_robotics.torch_kinematics_tree.models.robot_tree import DifferentiableTree +from torch_robotics.torch_kinematics_tree.utils.files import get_robot_path +from xml.etree import ElementTree as ET + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +class DifferentiableKUKAiiwa(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'kuka_iiwa' / 'urdf' / 'iiwa7.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_kuka_iiwa" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +def modidy_franka_panda_urdf_grasped_object(robot_file, grasped_object): + robot_urdf = URDF.from_xml_file(robot_file) + joint = Joint( + name='grasped_object_fixed_joint', + parent='panda_hand', + child='grasped_object', + joint_type='fixed', + origin=Pose(xyz=to_numpy(grasped_object.pos.squeeze()), + rpy=to_numpy(q_to_euler(grasped_object.ori).squeeze()) + ) + ) + robot_urdf.add_joint(joint) + + geometry_grasped_object = grasped_object.geometry_urdf + link = Link( + name='grasped_object', + visual=Visual(geometry_grasped_object), + # inertial=None, + collision=Collision(geometry_grasped_object), + origin=Pose(xyz=[0., 0., 0.], rpy=[0., 0., 0.]) + ) + robot_urdf.add_link(link) + + # replace the robots file + robot_file = Path(str(robot_file).replace('.urdf', '_grasped_object.urdf')) + xmlstr = minidom.parseString(ET.tostring(robot_urdf.to_xml())).toprettyxml(indent=" ") + with open(str(robot_file), "w") as f: + f.write(xmlstr) + + return robot_file + + +class DifferentiableFrankaPanda(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, gripper=False, device='cpu', grasped_object=None): + if gripper: + robot_file = get_robot_path() / 'franka_description' / 'robots' / 'panda_arm_hand.urdf' + else: + robot_file = get_robot_path() / 'franka_description' / 'robots' / 'panda_arm_no_gripper.urdf' + + # Modify the urdf to append the link of the grasped object + if grasped_object is not None: + robot_file = modidy_franka_panda_urdf_grasped_object(robot_file, grasped_object) + + self.model_path = robot_file.as_posix() + self.name = "differentiable_franka_panda" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableUR10(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, attach_gripper=False, device='cpu'): + robot_path = get_robot_path() + if attach_gripper: + robot_file = robot_path / 'ur10' / 'urdf' / 'ur10_suction.urdf' + else: + robot_file = robot_path / 'ur10' / 'urdf' / 'ur10.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_ur10" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableHabitatStretch(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_path = get_robot_path() + robot_file = robot_path / 'habitat_stretch' / 'urdf' / 'hab_stretch.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_stretch" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableTiagoDualHolo(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'tiago_dual_description' / 'tiago_dual_holobase_minimal.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_tiago_dual_holo" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableTiagoDualHoloMove(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'tiago_dual_description' / 'tiago_dual_holobase_minimal_holonomic.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_tiago_dual_holo_move" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + def get_link_names(self): # pop those hacky frames for moving base + return super().get_link_names()[3:] + + +class DifferentiableShadowHand(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'shadow_hand' / 'shadow_hand.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_shadow_hand" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class DifferentiableAllegroHand(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'allegro_hand' / 'allegro_hand.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_allegro_hand" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) + + +class Differentiable2LinkPlanar(DifferentiableTree): + def __init__(self, link_list: Optional[str] = None, device='cpu'): + robot_file = get_robot_path() / 'planar_manipulators' / 'urdf' / '2_link_planar.urdf' + self.model_path = robot_file.as_posix() + self.name = "differentiable_2_link_planar" + super().__init__(self.model_path, self.name, link_list=link_list, device=device) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..520cd34a81bf6e84e79e8f990a192d7ee4e7972b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/models/utils.py @@ -0,0 +1,315 @@ +import torch +from networkx.readwrite import json_graph +from urdf_parser_py.urdf import URDF + +from torch_robotics.torch_kinematics_tree.utils.files import get_mjcf_path + +torch.set_default_dtype(torch.float32) + +JOINT_NAME_MAP = { + 'hinge': 'revolute', + 'slide': 'prismatic' +} + + +class RobotModel(object): + + def __init__(self, model_path, device="cpu") -> None: + self.model_path = model_path + self._device = torch.device(device) + + def find_joint_of_body(self, body_name): + pass + + def get_name_of_parent_body(self, body_name): + pass + + def get_body_parameters(self, i, link): + pass + + +class MJCFRobotModel(RobotModel): + def __init__(self, model_path, device="cpu"): + super().__init__(model_path, device=device) + from dm_control import mjcf + self.robot = mjcf.from_file(self.model_path) + self._device = torch.device(device) + bodies = self.robot.find_all("body") + self.links = [e for e in bodies] + self._bodies = {e.name: e for e in bodies} + self._joints = {e.name: e for e in self.robot.find_all("joint")} + # TODO: implement default logics of mujoco + # TODO: tendon forward kinematic? + # NOTE: this class does not work correctly now + # self._actuators = {e.name: e for e in self.robot.find_all("actuator")} + node_list, edge_list = [], [] + for b in self.robot.worldbody.body: + self.build_topology(b, node_list, edge_list) + self.topology = json_graph.node_link_graph({'nodes': node_list, 'links': edge_list}, directed=True) + + def build_topology(self, body, node_list, edge_list): # since there is no parent tag for joint + node_list.append({'id': body.name}) + if not body.body: + return + for b in body.body: + edge_list.append({ + 'source': body.name, + 'target': b.name + }) + self.build_topology(b, node_list, edge_list) + + def find_joint_of_body(self, body_name): + if body_name not in self._bodies: + return -1 + return self._bodies[body_name].joint + + def get_name_of_parent_body(self, body_name): + if body_name not in self._bodies: + return + return [b for b in self.topology.predecessors(body_name)] + + def get_body_parameters(self, i, link): + body_params = {} + body_params["joint_id"] = i + body_params["link_name"] = link.name + + if i == 0: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = "base_joint" + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + link_name = link.name + joint = self.find_joint_of_body(link_name) + if not joint: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = link_name + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + joint = joint[0] + joint_name = joint.name + + rot_angles = torch.zeros(3, device=self._device) # joint angle is not specified in mujoco + if joint.pos is not None: + trans = torch.tensor( + joint.pos, dtype=torch.float32, device=self._device + ) + else: + trans = torch.zeros(3, device=self._device) + if joint.type is None: + joint_type = 'revolute' + else: + joint_type = JOINT_NAME_MAP[joint.type] + if joint.range is None: + joint_limits = None + else: + joint_limits = { + "lower": joint.range[0], + "upper": joint.range[1], + } + if joint.damping is None: + joint_damping = None + else: + joint_damping = torch.tensor([joint.damping], device=self._device) + joint_axis = torch.tensor( + joint.axis, dtype=torch.float32, device=self._device + ).reshape(1, 3) + + body_params["rot_angles"] = rot_angles + body_params["trans"] = trans + body_params["joint_name"] = joint_name + body_params["joint_type"] = joint_type + body_params["joint_limits"] = joint_limits + body_params["joint_damping"] = joint_damping + body_params["joint_axis"] = joint_axis + + if link.inertial is not None: + mass = torch.tensor( + [link.inertial.mass], dtype=torch.float32, device=self._device + ) + if link.pos is not None: + pos = link.pos + else: + pos = [0, 0, 0] + com = ( + torch.tensor( + pos, + dtype=torch.float32, + device=self._device, + ) + .reshape((1, 3)) + .to(self._device) + ) + if link.inertial.diaginertia is not None: + inert_mat = torch.zeros((3, 3), device=self._device) + inert_mat[0, 0] = link.inertial.diaginertia[0] + inert_mat[1, 1] = link.inertial.diaginertia[1] + inert_mat[2, 2] = link.inertial.diaginertia[2] + else: + print( + "Warning: No inertia information for link: {}, setting inertia matrix to identity.".format( + link.name + )) + inert_mat = torch.eye(3, device=self._device) + + inert_mat = inert_mat.unsqueeze(0) + body_params["mass"] = mass + body_params["com"] = com + body_params["inertia_mat"] = inert_mat + else: + body_params["mass"] = torch.ones((1,), device=self._device) + body_params["com"] = torch.zeros((1, 3), device=self._device) + body_params["inertia_mat"] = torch.eye(3, 3, device=self._device).unsqueeze( + 0 + ) + print( + "Warning: No dynamics information for link: {}, setting all inertial properties to 1.".format( + link.name + ) + ) + + return body_params + + +class URDFRobotModel(RobotModel): + def __init__(self, model_path, device="cpu"): + super().__init__(model_path, device=device) + self.robot = URDF.from_xml_file(self.model_path) + self.links = self.robot.links + self._device = torch.device(device) + + def find_joint_of_body(self, body_name): + for (i, joint) in enumerate(self.robot.joints): + if joint.child == body_name: + return i + return -1 + + def get_name_of_parent_body(self, link_name): + jid = self.find_joint_of_body(link_name) + joint = self.robot.joints[jid] + return joint.parent + + def get_body_parameters(self, i, link): + body_params = {} + body_params["joint_id"] = i + body_params["link_name"] = link.name + + if i == 0: + rot_angles = torch.zeros(3, device=self._device) + trans = torch.zeros(3, device=self._device) + joint_name = "base_joint" + joint_type = "fixed" + joint_limits = None + joint_damping = None + joint_axis = torch.zeros((1, 3), device=self._device) + else: + link_name = link.name + jid = self.find_joint_of_body(link_name) + joint = self.robot.joints[jid] + joint_name = joint.name + # find joint that is the "child" of this body according to urdf + + rot_angles = torch.tensor( + joint.origin.rotation, dtype=torch.float32, device=self._device + ) + trans = torch.tensor( + joint.origin.position, dtype=torch.float32, device=self._device + ) + joint_type = joint.type + joint_limits = None + joint_damping = torch.zeros(1, device=self._device) + if joint.axis is not None: + joint_axis = torch.tensor( + joint.axis, dtype=torch.float32, device=self._device + ).reshape(1, 3) + else: + joint_axis = torch.zeros((1, 3), device=self._device) + if joint_type != "fixed" and joint.limit is not None: + joint_limits = { + "effort": joint.limit.effort, + "lower": joint.limit.lower, + "upper": joint.limit.upper, + "velocity": joint.limit.velocity, + } + try: + joint_damping = torch.tensor( + [joint.dynamics.damping], + dtype=torch.float32, + device=self._device, + ) + except: + joint_damping = torch.zeros(1, device=self._device) + + body_params["rot_angles"] = rot_angles + body_params["trans"] = trans + body_params["joint_name"] = joint_name + body_params["joint_type"] = joint_type + body_params["joint_limits"] = joint_limits + body_params["joint_damping"] = joint_damping + body_params["joint_axis"] = joint_axis + + if link.inertial is not None: + mass = torch.tensor( + [link.inertial.mass], dtype=torch.float32, device=self._device + ) + if link.inertial.origin is not None: + pos = link.inertial.origin.position + else: + pos = [0, 0, 0] + com = ( + torch.tensor( + pos, + dtype=torch.float32, + device=self._device, + ) + .reshape((1, 3)) + .to(self._device) + ) + if link.inertial.inertia is not None: + inert_mat = torch.zeros((3, 3), device=self._device) + inert_mat[0, 0] = link.inertial.inertia.ixx + inert_mat[0, 1] = link.inertial.inertia.ixy + inert_mat[0, 2] = link.inertial.inertia.ixz + inert_mat[1, 0] = link.inertial.inertia.ixy + inert_mat[1, 1] = link.inertial.inertia.iyy + inert_mat[1, 2] = link.inertial.inertia.iyz + inert_mat[2, 0] = link.inertial.inertia.ixz + inert_mat[2, 1] = link.inertial.inertia.iyz + inert_mat[2, 2] = link.inertial.inertia.izz + else: + print( + "Warning: No inertia information for link: {}, setting inertia matrix to identity.".format( + link.name + )) + inert_mat = torch.eye(3, device=self._device) + + inert_mat = inert_mat.unsqueeze(0) + body_params["mass"] = mass + body_params["com"] = com + body_params["inertia_mat"] = inert_mat + else: + body_params["mass"] = torch.ones((1,), device=self._device) + body_params["com"] = torch.zeros((1, 3), device=self._device) + body_params["inertia_mat"] = torch.eye(3, 3, device=self._device).unsqueeze( + 0 + ) + print( + "Warning: No dynamics information for link: {}, setting all inertial properties to 1.".format( + link.name + ) + ) + + return body_params + + +if __name__ == '__main__': + hand = MJCFRobotModel(get_mjcf_path() / 'shadow_hand_series_e.xml') + print(hand.find_joint_of_body('forearm')) + print(hand.get_body_parameters(0, hand._bodies['thmiddle'])) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py new file mode 100644 index 0000000000000000000000000000000000000000..10cf9f2eaabaf37cf3aca93feee23a0327664f71 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_kinematics_tree/utils/files.py @@ -0,0 +1,64 @@ +import torch_robotics.torch_kinematics_tree +from pathlib import Path +import yaml + + +# get paths +def get_root_path(): + path = Path(torch_robotics.torch_kinematics_tree.__path__[0]).resolve() / '..' + return path + + +def get_data_path(): + path = get_root_path() / 'data' + return path + + +def get_json_path(): + path = get_data_path() / 'jsons' + return path + + +def get_imgs_path(): + path = get_data_path() / 'imgs' + return path + + +def get_urdf_path(): + path = get_data_path() / 'urdf' + return path + + +def get_mjcf_path(): + path = get_data_path() / 'mjcf' + return path + + +def get_usd_path(): + path = get_data_path() / 'usd' + return path + + +def get_robot_path(): + path = get_urdf_path() / 'robots' + return path + + +def get_objects_path(): + path = get_urdf_path() / 'objects' + return path + + +def get_configs_path(): + path = get_data_path() / 'configs' + return path + + +def load_yaml(filename): + with open(filename, 'r') as stream: + try: + config = yaml.safe_load(stream) + print(config) + return config + except yaml.YAMLError as exc: + print(exc) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5a190bf398fec5aa2021f1959380ec54ea453f1c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_planning_objectives/fields/distance_fields.py @@ -0,0 +1,409 @@ +from abc import ABC, abstractmethod + +import einops +import torch +from matplotlib import pyplot as plt + +from torch_robotics.torch_kinematics_tree.geometrics.utils import SE3_distance +from torch_robotics.visualizers.planning_visualizer import create_fig_and_axes +import torch.nn.functional as Functional + + +class DistanceField(ABC): + def __init__(self, tensor_args=None): + self.tensor_args = tensor_args + + def distances(self): + pass + + def compute_collision(self): + pass + + @abstractmethod + def compute_distance(self, *args, **kwargs): + pass + + def compute_cost(self, q, link_pos, *args, **kwargs): + q_orig_shape = q.shape + link_orig_shape = link_pos.shape + if len(link_orig_shape) == 2: + h = 1 + b, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b d -> b 1 d") # add dimension of task space link + elif len(link_orig_shape) == 3: + h = 1 + b, t, d = link_orig_shape + elif len(link_orig_shape) == 4: # batch, horizon, num_links, 3 # position tensor + b, h, t, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d -> (b h) t d") + elif len(link_orig_shape) == 5: # batch, horizon, num_links, 4, 4 # homogeneous transform tensor + b, h, t, d, d = link_orig_shape + link_pos = einops.rearrange(link_pos, "b h t d d -> (b h) t d d") + else: + raise NotImplementedError + + # link_tensor_pos + # position: (batch horizon) x num_links x 3 + cost = self.compute_costs_impl(q, link_pos, *args, **kwargs) + + if cost.ndim == 1: + cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + # if len(link_orig_shape) == 4 or len(link_orig_shape) == 5: + # cost = einops.rearrange(cost, "(b h) -> b h", b=b, h=h) + + return cost + + @abstractmethod + def compute_costs_impl(self, *args, **kwargs): + pass + + @abstractmethod + def zero_grad(self): + pass + + +def interpolate_points_v1(points, num_interpolated_points): + # https://github.com/SamsungLabs/RAMP/blob/c3bd23b2c296c94cdd80d6575390fd96c4f83d83/mppi_planning/cost/collision_cost.py#L89 + """ + Interpolates points to have num_interpolated_points between each pair of points. + """ + points = Functional.interpolate(points.transpose(-2, -1), size=num_interpolated_points, mode='linear', align_corners=True).transpose(-2, -1) + return points + + +# Old implementation +def interpolate_points_v2(points, num_interpolate, link_interpolate_range): + if num_interpolate > 0: + link_dim = points.shape[:-1] + alpha = torch.linspace(0, 1, num_interpolate + 2).type_as(points)[1:num_interpolate + 1] + alpha = alpha.view(tuple([1] * len(link_dim) + [-1, 1])) # 1 x 1 x 1 x ... x num_interpolate x 1 + X = points[..., link_interpolate_range[0]:link_interpolate_range[1] + 1, :].unsqueeze(-2) # batch_dim x num_interp_link x 1 x 3 + X_diff = torch.diff(X, dim=-3) # batch_dim x (num_interp_link - 1) x 1 x 3 + X_interp = X[..., :-1, :, :] + X_diff * alpha # batch_dim x (num_interp_link - 1) x num_interpolate x 3 + points = torch.cat([points, X_interp.flatten(-3, -2)], dim=-2) # batch_dim x (num_link + (num_interp_link - 1) * num_interpolate) x 3 + return points + + +class EmbodimentDistanceFieldBase(DistanceField): + + def __init__(self, + robot, + link_idxs_for_collision_checking=None, + num_interpolated_points=30, + collision_margins=0., + cutoff_margin=0.001, + field_type='sdf', clamp_sdf=True, + interpolate_link_pos=False, + **kwargs): + super().__init__(**kwargs) + assert robot is not None, "You need to pass a robot instance to the embodiment distance fields" + self.robot = robot + self.link_idxs_for_collision_checking = link_idxs_for_collision_checking + self.num_interpolated_points = num_interpolated_points + self.collision_margins = collision_margins + self.cutoff_margin = cutoff_margin + self.field_type = field_type + self.clamp_sdf = clamp_sdf + self.interpolate_link_pos = interpolate_link_pos + + def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + if field_type is None: + field_type = self.field_type + if field_type == 'rbf': + return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + margin = self.collision_margins + self.cutoff_margin # The cutoff margin is the self_collision_margin_vector in the self collision distance field. + # returns all distances from each link to the environment + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + if self.clamp_sdf: + if self.clamp_sdf is False: + raise ValueError("Debug: clamp intended to be True.") + clamped_sdf = torch.relu(margin_minus_sdf) + else: + clamped_sdf = margin_minus_sdf + if len(clamped_sdf.shape) == 3: # cover the multiple objects case + clamped_sdf = clamped_sdf.max(-2)[0] + # sum over link points for gradient computation + return clamped_sdf.sum(-1) + elif field_type == 'occupancy': + return self.compute_embodiment_collision(q, link_pos, **kwargs) + # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # return (distances < margin).sum(-1) + else: + raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + # def compute_embodiment_cost(self, q, link_pos, field_type=None, **kwargs): # position tensor + # if field_type is None: + # field_type = self.field_type + # if field_type == 'rbf': + # return self.compute_embodiment_rbf_distances(link_pos, **kwargs).sum((-1, -2)) + # elif field_type == 'sdf': # this computes the negative cost from the DISTANCE FUNCTION + # margin = self.collision_margins + self.cutoff_margin + # # computes sdf of all links to all objects (or sdf map) + # margin_minus_sdf = -(self.compute_embodiment_signed_distances(q, link_pos, **kwargs) - margin) + # if self.clamp_sdf: + # clamped_sdf = torch.relu(margin_minus_sdf) + # else: + # clamped_sdf = margin_minus_sdf + # + # if len(clamped_sdf.shape) == 3: # cover the multiple objects case + # clamped_sdf = clamped_sdf.max(-2)[0] + # + # # sum over link points for gradient computation + # return clamped_sdf.sum(-1) + # + # elif field_type == 'occupancy': + # return self.compute_embodiment_collision(q, link_pos, **kwargs) + # # distances = self.self_distances(link_pos, **kwargs) # batch_dim x (links * (links - 1) / 2) + # # return (distances < margin).sum(-1) + # else: + # raise NotImplementedError('field_type {} not implemented'.format(field_type)) + + def compute_costs_impl(self, q, link_pos, **kwargs): + # position link_pos tensor # batch x num_links x 3 + # interpolate to approximate link spheres + if self.robot.grasped_object is not None: + n_grasped_object_points = self.robot.grasped_object.n_base_points_for_collision + link_pos_robot = link_pos[..., :-n_grasped_object_points, :] + link_pos_grasped_object = link_pos[..., -n_grasped_object_points:, :] + else: + link_pos_robot = link_pos + + link_pos_robot = link_pos_robot[..., self.link_idxs_for_collision_checking, :] + if self.interpolate_link_pos: + # select the robot links used for collision checking + link_pos = interpolate_points_v1(link_pos_robot, self.num_interpolated_points) + + # stack collision points from grasped object + # these points do not need to be interpolated + if self.robot.grasped_object is not None: + link_pos = torch.cat((link_pos, link_pos_grasped_object), dim=-2) + + embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) + return embodiment_cost + + def compute_distance(self, q, link_pos, **kwargs): + raise NotImplementedError + link_pos = interpolate_points_v1(link_pos, self.num_interpolated_points) + self_distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs).min(-1)[0] # batch_dim + return self_distances + + def zero_grad(self): + pass + # raise NotImplementedError + + @abstractmethod + def compute_embodiment_rbf_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + @abstractmethod + def compute_embodiment_collision(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionSelfField(EmbodimentDistanceFieldBase): + + def __init__(self, *args, idxs_links_distance_matrix=None, **kwargs): + super().__init__(*args, collision_margins=0., **kwargs) + self.idxs_links_distance_matrix = idxs_links_distance_matrix + self.idxs_links_distance_matrix_tuple = tuple(zip(*idxs_links_distance_matrix)) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.cutoff_margin) + rbf_distance = torch.exp(torch.square(link_pos.unsqueeze(-2) - + link_pos.unsqueeze(-3)).sum(-1) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor + """ + Computes the distances between all pairs of links. + The distances are computed as the L2 norm between the link positions, with the position of each link being + a point in 3D space. + """ + if link_pos.shape[-2] == 1: + # if there is only one link, the self distance is very large + # implementation guarantees gradient computation + return torch.abs(link_pos).sum(-1) * 1e9 + dist_mat = torch.linalg.norm(link_pos.unsqueeze(-2) - link_pos.unsqueeze(-3), dim=-1) # batch_dim x links x links + + # select lower triangular -- distance between link points + # lower_indices = torch.tril_indices(dist_mat.shape[-1], dist_mat.shape[-1], offset=-1).unbind() + # distances = dist_mat[..., lower_indices[0], lower_indices[1]] # batch_dim x (links * (links - 1) / 2) + + # select only distances between pairs of specified links + distances = dist_mat[..., self.idxs_links_distance_matrix_tuple[0], self.idxs_links_distance_matrix_tuple[1]] + + return distances + + def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor + margin = kwargs.get('margin', self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + distances = self.compute_embodiment_signed_distances(q, link_pos, **kwargs) + any_self_collision = torch.any(distances < margin, dim=-1) + return any_self_collision + + +def reshape_q(q): + q_orig_shape = q.shape + if len(q_orig_shape) == 2: + h = 1 + b, d = q_orig_shape + elif len(q_orig_shape) == 3: + b, h, d = q_orig_shape + q = einops.rearrange(q, "b h d -> (b h) d") + else: + raise NotImplementedError + return q, q_orig_shape, b, h, d + + +# class CollisionSelfFieldWrapperSTORM(EmbodimentDistanceFieldBase): +# +# def __init__(self, robot, weights_fname, n_joints, *args, **kwargs): +# super().__init__(robot, *args, collision_margins=0., interpolate_link_pos=False, **kwargs) +# from storm_kit.geom.nn_model.robot_self_collision import RobotSelfCollisionNet +# self.robot_self_collision_net = RobotSelfCollisionNet(n_joints) +# self.robot_self_collision_net.load_weights(weights_fname, self.tensor_args) +# +# def compute_costs_impl(self, q, link_pos, **kwargs): +# embodiment_cost = self.compute_embodiment_cost(q, link_pos, **kwargs) +# return embodiment_cost +# +# def compute_embodiment_rbf_distances(self, *args, **kwargs): # position tensor +# raise NotImplementedError +# +# def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# return sdf_self_collision +# +# def compute_embodiment_collision(self, q, link_pos, **kwargs): # position tensor +# q, q_orig_shape, b, h, d = reshape_q(q) +# +# # multiply by -1, because according to the paper (page 6, https://arxiv.org/pdf/2104.13542.pdf) +# # "Distance is positive when two links are penetrating and negative when not colliding" +# sdf_self_collision = -1. * self.robot_self_collision_net.compute_signed_distance(q, with_grad=True) +# +# if len(q_orig_shape) == 3: +# sdf_self_collision = einops.rearrange(sdf_self_collision, "(b h) 1 -> b h", b=b, h=h) +# +# any_self_collision = sdf_self_collision < -0.05 # trained on 0.02 +# return any_self_collision + + +class CollisionObjectBase(EmbodimentDistanceFieldBase): + + def __init__(self, *args, link_margins_for_object_collision_checking_tensor=None, **kwargs): + super().__init__(*args, collision_margins=link_margins_for_object_collision_checking_tensor, **kwargs) + + def compute_embodiment_rbf_distances(self, link_pos, **kwargs): # position tensor + raise NotImplementedError + margin = kwargs.get('margin', self.margin) + rbf_distance = torch.exp(torch.square(self.object_signed_distances(link_pos, **kwargs)) / (-margin ** 2 * 2)) + return rbf_distance + + def compute_embodiment_signed_distances(self, q, link_pos, **kwargs): + return self.object_signed_distances(link_pos, **kwargs) + + def compute_embodiment_collision(self, q, link_pos, **kwargs): + # position tensor + margin = kwargs.get('margin', self.collision_margins + self.cutoff_margin) + link_pos = link_pos[..., self.link_idxs_for_collision_checking, :] + signed_distances = self.object_signed_distances(link_pos, **kwargs) + collisions = signed_distances < margin + # reduce over points (dim -1) and over objects (dim -2) + any_collision = torch.any(torch.any(collisions, dim=-1), dim=-1) + return any_collision + + @abstractmethod + def object_signed_distances(self, *args, **kwargs): + raise NotImplementedError + + +class CollisionObjectDistanceField(CollisionObjectBase): + + def __init__(self, + *args, + df_obj_list_fn=None, + **kwargs): + super().__init__(*args, **kwargs) + self.df_obj_list_fn = df_obj_list_fn + + def object_signed_distances(self, link_pos, **kwargs): + if self.df_obj_list_fn is None: + return torch.inf + df_obj_list = self.df_obj_list_fn() + link_dim = link_pos.shape[:-1] + link_pos = link_pos.reshape(-1, link_pos.shape[-1]) # flatten batch_dim and links + dfs = [] + for df in df_obj_list: + dfs.append(df.compute_signed_distance(link_pos).view(link_dim)) # df() returns batch_dim x links + return torch.stack(dfs, dim=-2) # batch_dim x num_sdfs x links + + +class CollisionWorkspaceBoundariesDistanceField(CollisionObjectBase): + + def __init__(self, *args, ws_bounds_min=None, ws_bounds_max=None, **kwargs): + super().__init__(*args, **kwargs) + self.ws_min = ws_bounds_min + self.ws_max = ws_bounds_max + + def object_signed_distances(self, link_pos, **kwargs): + signed_distances_bounds_min = link_pos - self.ws_min + signed_distances_bounds_min = torch.sign(signed_distances_bounds_min) * torch.abs(signed_distances_bounds_min) + signed_distances_bounds_max = self.ws_max - link_pos + signed_distances_bounds_max = torch.sign(signed_distances_bounds_max) * torch.abs(signed_distances_bounds_max) + signed_distances_bounds = torch.cat((signed_distances_bounds_min, signed_distances_bounds_max), dim=-1) + return signed_distances_bounds.transpose(-2, -1) # batch_dim x num_sdfs x links + + +class EESE3DistanceField(DistanceField): + + def __init__(self, target_H, w_pos=1., w_rot=1., square=True, **kwargs): + super().__init__(**kwargs) + self.target_H = target_H + self.square = square + self.w_pos = w_pos + self.w_rot = w_rot + + def update_target(self, target_H): + self.target_H = target_H + + def compute_distance(self, link_tensor): + # homogeneous transformation link_tensor # batch x num_links x 4 x 4 + # -1: get EE as last link # TODO - get EE from its name id + return SE3_distance(link_tensor[..., -1, :, :], self.target_H, w_pos=self.w_pos, w_rot=self.w_rot) + + def compute_costs_impl(self, q, link_tensor, **kwargs): # position tensor + dist = self.compute_distance(link_tensor).squeeze() + if self.square: + dist = torch.square(dist) + return dist + + def zero_grad(self): + raise NotImplementedError + +if __name__ == '__main__': + raise NotImplementedError + import time + mesh_file = 'models/chair.obj' + mesh = MeshDistanceField(mesh_file) + bounds = np.array(mesh.mesh.bounds) + print(np.linalg.norm(bounds[1] - bounds[0])) + print(mesh.mesh.centroid) + points = torch.rand(100, 3) + link_tensor = torch.rand(100, 10, 4, 4) + start = time.time() + distances = mesh.compute_distance(points) + costs = mesh.compute_cost(link_tensor) + print(time.time() - start) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/seed.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/seed.py new file mode 100644 index 0000000000000000000000000000000000000000..00e64763d2182e7cfe341f3009d6ab866bad958d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/seed.py @@ -0,0 +1,21 @@ +import random + +import numpy as np +import torch + + +def fix_random_seed(seed): + random.seed(seed) + + try: + np.random.seed(seed) + except NameError: + pass + + try: + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + # https://pytorch.org/docs/stable/notes/randomness.html#cuda-convolution-benchmarking + torch.backends.cudnn.benchmark = False + except NameError: + pass diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py new file mode 100644 index 0000000000000000000000000000000000000000..85c113b5c1e0b7665b596f7b9d448ba993aa7f68 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_timer.py @@ -0,0 +1,109 @@ +# Adapted from https://github.com/brouberol/contexttimer +from time import perf_counter + +import torch + + +class TimerCUDA(object): + """ A timer as a context manager + + Wraps around a timer. A custom timer can be passed + to the constructor. The default timer is timeit.default_timer. + + Note that the latter measures wall clock time, not CPU time! + On Unix systems, it corresponds to time.time. + On Windows systems, it corresponds to time.clock. + + Keyword arguments: + output -- if True, print output after exiting context. + if callable, pass output to callable. + format -- str.format string to be used for output; default "took {} seconds" + prefix -- string to prepend (plus a space) to output + For convenience, if you only specify this, output defaults to True. + """ + + def __init__(self, timer=perf_counter, + output=None, fmt="took {:.3f} seconds", prefix="", + use_cuda_events=False + ): + self.timer = timer + self.output = output + self.fmt = fmt + self.prefix = prefix + self.start_time = None + self.end = None + + self.sync_cuda = True if torch.cuda.is_available() else False + if use_cuda_events: + assert self.sync_cuda, "CUDA must be available when using CUDA events" + self.use_cuda_events = use_cuda_events + self.factor_cuda_events = 1./1000. # transform to seconds + self.start_event = None + self.end_event = None + + def __call__(self): + """ Return the current time """ + if self.use_cuda_events: + self.end_event.record() + torch.cuda.synchronize() + return self.start_event.elapsed_time(self.end_event) * self.factor_cuda_events + else: + if self.sync_cuda: + torch.cuda.synchronize() + return self.timer() + + def __enter__(self): + """ Set the start time """ + if self.use_cuda_events: + self.start_event = torch.cuda.Event(enable_timing=True) + self.end_event = torch.cuda.Event(enable_timing=True) + self.start_event.record() + else: + self.start_time = self() + + self.start = self() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """ Set the end time """ + self.end = self() + + if self.prefix and self.output is None: + self.output = True + + if self.output: + output = " ".join([self.prefix, self.fmt.format(self.elapsed)]) + if callable(self.output): + self.output(output) + else: + print(output) + + def __str__(self): + return f'{self.elapsed:.3f}' + + @property + def elapsed(self): + """ Return the current elapsed time since start + + If the `elapsed` property is called in the context manager scope, + the elapsed time between start and property access is returned. + However, if it is accessed outside of the context manager scope, + it returns the elapsed time between entering and exiting the scope. + + The `elapsed` property can thus be accessed at different points within + the context manager scope, to time different parts of the block. + + """ + if self.end is None: + # if elapsed is called in the context manager scope + if self.use_cuda_events: + self() + else: + return self() - self.start + else: + # if elapsed is called out of the context manager scope + if self.use_cuda_events: + return self.end + else: + return self.end - self.start + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..3891d22e237848540d2c6ad1ed0cd9cc3772577b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/torch_utils/torch_utils.py @@ -0,0 +1,163 @@ +import collections +import collections.abc +import random +from typing import List + +import numpy as np +import torch + + +def get_torch_device(device='cuda'): + if 'cuda' in device and torch.cuda.is_available(): + device = 'cuda' + elif 'mps' in device: + device = 'mps' + else: + device = 'cpu' + return torch.device(device) + + +DEFAULT_TENSOR_ARGS = {'device': get_torch_device('cuda'), 'dtype': torch.float32} + +def dict_to_device(ob, device): + if isinstance(ob, collections.abc.Mapping): + return {k: dict_to_device(v, device) for k, v in ob.items()} + else: + return ob.to(device) + + +def to_numpy(x, dtype=np.float32, clone=False): + if torch.is_tensor(x): + x = x.detach().cpu().numpy().astype(dtype) + return x + if isinstance(x, np.ndarray): + return x.astype(dtype, copy=clone) + return np.array(x).astype(dtype) + + +def to_torch(x, device='cpu', dtype=torch.float, requires_grad=False, clone=False): + if torch.is_tensor(x): + if clone: + x = x.clone() + return x.to(device=device, dtype=dtype) + return torch.tensor(x, dtype=dtype, device=device, requires_grad=requires_grad) + + +def to_torch_2d_min(variable): + if isinstance(variable, np.ndarray) or isinstance(variable, List): + tensor_var = to_torch(variable) + else: + tensor_var = variable + if len(tensor_var.shape) == 1: + return tensor_var.unsqueeze(0) + else: + return tensor_var + + +def freeze_torch_model_params(model): + for param in model.parameters(): + param.requires_grad = False + # If the model is frozen we do not save it again, since the parameters did not change + model.is_frozen = True + + +def batch_cov(points): + B, N, D = points.size() + mean = points.mean(dim=1).unsqueeze(1) + diffs = (points - mean).reshape(B * N, D) + prods = torch.bmm(diffs.unsqueeze(2), diffs.unsqueeze(1)).reshape(B, N, D, D) + bcov = prods.sum(dim=1) + if N > 1: + bcov = bcov / (N - 1) # Unbiased estimate + else: + bcov = bcov / N + return bcov # (B, D, D) + + +def batch_trace(covs): + return covs.diagonal(offset=0, dim1=-1, dim2=-2).sum(-1) + + +@torch.jit.script +def tensor_linspace_v1(start: torch.Tensor, end: torch.Tensor, steps: int = 10): + # https://github.com/zhaobozb/layout2im/blob/master/models/bilinear.py#L246 + """ + Vectorized version of torch.linspace. + Inputs: + - start: Tensor of any shape + - end: Tensor of the same shape as start + - steps: Integer + Returns: + - out: Tensor of shape start.size() + (steps,), such that + out.select(-1, 0) == start, out.select(-1, -1) == end, + and the other elements of out linearly interpolate between + start and end. + """ + assert start.size() == end.size() + view_size = start.size() + (1,) + w_size = (1,) * start.dim() + (steps,) + out_size = start.size() + (steps,) + + start_w = torch.linspace(1, 0, steps=steps).to(start) + start_w = start_w.view(w_size).expand(out_size) + end_w = torch.linspace(0, 1, steps=steps).to(start) + end_w = end_w.view(w_size).expand(out_size) + + start = start.contiguous().view(view_size).expand(out_size) + end = end.contiguous().view(view_size).expand(out_size) + + out = start_w * start + end_w * end + return out + + +@torch.jit.script +def torch_linspace_v2(start: torch.Tensor, stop: torch.Tensor, num: int): + """ + Creates a tensor of shape [num, *start.shape] whose values are evenly spaced from start to end, inclusive. + Replicates but the multi-dimensional bahaviour of numpy.linspace in PyTorch. + """ + # create a tensor of 'num' steps from 0 to 1 + steps = torch.arange(num, dtype=start.dtype, device=start.device) / (num - 1) + + # reshape the 'steps' tensor to [-1, *([1]*start.ndim)] to allow for broadcastings + # - using 'steps.reshape([-1, *([1]*start.ndim)])' would be nice here but torchscript + # "cannot statically infer the expected size of a list in this contex", hence the code below + for i in range(start.ndim): + steps = steps.unsqueeze(-1) + + # the output starts at 'start' and increments until 'stop' in each dimension + out = start[None] + steps * (stop - start)[None] + + return out + + +# @torch.jit.script +def batched_weighted_dot_prod(x: torch.Tensor, M: torch.Tensor, y: torch.Tensor, with_einsum: bool = False): + """ + Computes batched version of weighted dot product (distance) x.T @ M @ x + """ + assert x.ndim >= 2 + if with_einsum: + My = M.unsqueeze(0) @ y + r = torch.einsum('...ij,...ij->...j', x, My) + else: + r = x.transpose(-2, -1) @ M.unsqueeze(0) @ x + r = r.diagonal(dim1=-2, dim2=-1) + return r + + +def is_positive_semi_definite(mat): + # checks if mat is a positive semi-definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real >= 0).all()) + +def is_positive_definite(mat): + # checks if mat is a positive definite matrix + return bool((mat == mat.T).all() and (torch.linalg.eigvals(mat).real > 0).all()) + + +def torch_intersect_1d(a, b): + a_cat_b, counts = torch.cat([a, b]).unique(return_counts=True) + intersection = a_cat_b[torch.where(counts.gt(1))] + return intersection + + diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/metrics.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..e1dd3da7086f3d6b6e5c3ca6e602bd13a8ee5de3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/metrics.py @@ -0,0 +1,65 @@ +import numpy as np +import torch + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def compute_path_length(trajs, robot): + assert trajs.ndim == 3 # batch, horizon, state_dim + trajs_pos = robot.get_position(trajs) + return compute_path_length_from_pos(trajs_pos) + + +def compute_path_length_from_pos(trajs_pos): + assert trajs_pos.ndim == 3 + path_length = torch.linalg.norm(torch.diff(trajs_pos, dim=-2), dim=-1).sum(-1) + return path_length + +def compute_variance_waypoints(trajs, robot): + assert trajs.ndim == 3 # batch, horizon, state_dim + trajs_pos = robot.get_position(trajs) + parwise_distance_between_points_waypoints = torch.cdist(trajs_pos, trajs_pos, p=2) + + sum_var_waypoints = 0. + for via_points in trajs_pos.permute(1, 0, 2): # horizon, batch, position + parwise_distance_between_points_via_point = torch.cdist(via_points, via_points, p=2) + distances = torch.triu(parwise_distance_between_points_via_point, diagonal=1).view(-1) + sum_var_waypoints += torch.var(distances) + return sum_var_waypoints + + +def compute_smoothness(trajs, robot, trajs_vel=None): + if trajs_vel is None: + assert trajs.ndim == 3 + trajs_vel = robot.get_velocity(trajs) + else: + assert trajs_vel.ndim == 3 + smoothness = torch.linalg.norm(torch.diff(trajs_vel, dim=-2), dim=-1) + smoothness = smoothness.sum(-1) # sum over trajectory horizon + return smoothness + + +def compute_average_acceleration(trajs, robot, trajs_vel=None): + if trajs_vel is None: + assert trajs.ndim == 3 + trajs_vel = robot.get_velocity(trajs) + else: + assert trajs_vel.ndim == 3 + + return compute_average_acceleration_from_pos_vel(trajs, trajs_vel) + + +def compute_average_acceleration_from_pos_vel(trajs_pos, trajs_vel): + assert trajs_pos.ndim == 3 + assert trajs_vel.ndim == 3 + + # Compute the change in velocity (acceleration). + accelerations = torch.diff(trajs_vel, dim=-2) + + # Compute the norm of the accelerations (magnitude of acceleration) + acceleration_magnitudes = torch.linalg.norm(accelerations, dim=-1) + + # Average the acceleration magnitudes over the trajectory horizon (time steps) + average_acceleration = acceleration_magnitudes.mean(-1) + + return average_acceleration diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0be9c3cc2654dc0f303294886883ef4cca3e5d92 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/trajectory/utils.py @@ -0,0 +1,100 @@ +import numpy as np +import torch +from scipy import interpolate + +from torch_robotics.torch_utils.torch_utils import to_torch, to_numpy + + +def smoothen_trajectory(traj_pos, n_support_points=30, dt=0.02, set_average_velocity=True, zero_velocity=False, + tensor_args=None): + """ + Smoothen a trajectory by interpolating it with a cubic spline and setting the average velocity. + """ + assert not (set_average_velocity and zero_velocity), "Either sets the average velocity or zero velocity" + traj_pos = to_numpy(traj_pos) + try: + # bc_type='clamped' for zero velocities at start and finish + spline_pos = interpolate.make_interp_spline(np.linspace(0, 1, traj_pos.shape[0]), traj_pos, k=3, + bc_type='clamped') + spline_vel = spline_pos.derivative(1) + except: + # Trajectory is too short to interpolate, so add last position again and interpolate + traj_pos = np.vstack((traj_pos, traj_pos[-1] + np.random.normal(0, 0.01))) + return smoothen_trajectory(traj_pos, n_support_points=n_support_points, dt=dt, + set_average_velocity=set_average_velocity, zero_velocity=zero_velocity, + tensor_args=tensor_args) + + pos = spline_pos(np.linspace(0, 1, n_support_points)) + vel = np.zeros_like(pos) + if zero_velocity: + pass + elif set_average_velocity: + avg_vel = (traj_pos[1] - traj_pos[0]) / (n_support_points * dt) + vel[1:-1, :] = avg_vel + else: + vel = spline_vel(np.linspace(0, 1, n_support_points)) + + return to_torch(pos, **tensor_args), to_torch(vel, **tensor_args) + + +def smoothen_trajectory_with_bc(traj_pos, grad_start, grad_goal, bc_weight=0.2, n_support_points=30, dt=0.02, + set_average_velocity=True, zero_velocity=False, tensor_args=None): + traj_pos = to_numpy(traj_pos).T # Shape is (q_pos_dim, H) + adjusted_points = np.copy(traj_pos) + if not grad_start is None and not grad_goal is None: + grad_start = to_numpy(grad_start) + grad_goal = to_numpy(grad_goal) + print(traj_pos.shape, grad_start.shape, grad_goal.shape) + # Adjust the control points based on derivative conditions + adjusted_points[:, 0] += grad_start * bc_weight # scale to adjust sensitivity + adjusted_points[:, -1] -= grad_goal * bc_weight # scale to adjust sensitivity + # Add the original start and end points back in. + adjusted_points = np.hstack((traj_pos[:, [0]], adjusted_points, traj_pos[:, [-1]])) + + # Create the spline + tck, u = interpolate.splprep([adjusted_points[i] for i in range(len(adjusted_points))], s=0.1) + + # Evaluate the spline + u_fine = np.linspace(0, 1, n_support_points) + x_fine, y_fine = interpolate.splev(u_fine, tck) + + pos = np.stack((x_fine, y_fine), axis=0) + vel = np.zeros_like(pos) + if zero_velocity: + pass + elif set_average_velocity: + avg_vel = (traj_pos[:, 1] - traj_pos[:, 0]) / (n_support_points * dt) + vel[:, 1:-1] = avg_vel[:, None] + else: + vel = interpolate.splev(u_fine, tck, der=1) + return to_torch(pos, **tensor_args).T, to_torch(vel, **tensor_args).T # Shape is (H, q_pos_dim) + + +def interpolate_traj_via_points(trajs, num_interpolation=10): + # Interpolates a trajectory linearly between waypoints + H, D = trajs.shape[-2:] + if num_interpolation > 0: + assert trajs.ndim > 1 + traj_dim = trajs.shape + alpha = torch.linspace(0, 1, num_interpolation + 2).type_as(trajs)[1:num_interpolation + 1] + alpha = alpha.view((1,) * len(traj_dim[:-1]) + (-1, 1)) + interpolated_trajs = trajs[..., 0:traj_dim[-2] - 1, None, :] * alpha + \ + trajs[..., 1:traj_dim[-2], None, :] * (1 - alpha) + interpolated_trajs = interpolated_trajs.view(traj_dim[:-2] + (-1, D)) + else: + interpolated_trajs = trajs + return interpolated_trajs + + +def finite_difference_vector(x, dt=1., method='forward'): + # finite differences with zero padding at the borders + diff_vector = torch.zeros_like(x) + if method == 'forward': + diff_vector[..., :-1, :] = torch.diff(x, dim=-2) / dt + elif method == 'backward': + diff_vector[..., 1:, :] = (x[..., 1:, :] - x[..., :-1, :]) / dt + elif method == 'central': + diff_vector[..., 1:-1, :] = (x[..., 2:, :] - x[..., :-2, :]) / (2 * dt) + else: + raise NotImplementedError + return diff_vector diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..fa45f055ff93f243b59b2ba59fce3248921f22c4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/ensemble_planning_visualizer.py @@ -0,0 +1,367 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation + +from torch_robotics.torch_utils.torch_utils import to_numpy +from torch_robotics.tasks.tasks_ensemble import PlanningTaskEnsemble +from .planning_visualizer import create_fig_and_axes +import matplotlib.collections as mcoll + + +class PlanningVisualizerEnsemble: + + def __init__(self, + task_ensmeble: PlanningTaskEnsemble, + planner=None): + self.task_ensmeble = task_ensmeble + self.env = self.task_ensmeble.env + self.robot = self.task_ensmeble.tasks[0].robot + self.planner = planner + + self.colors = {'collision': 'black', 'free': 'orange'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, + return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), **kwargs) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + print(qs.shape) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else + self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H - 1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color] * len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, + goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps[ + 'free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S - 1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip( + [(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # # Start and goal + # if pos_start_state is not None: + # ax[0].scatter(0, pos_start_state[i], color='green') + # if vel_start_state is not None: + # ax[1].scatter(0, vel_start_state[i], color='green') + # if pos_goal_state is not None: + # ax[0].scatter(H - 1, pos_goal_state[i], color='purple') + # if vel_goal_state is not None: + # ax[1].scatter(H - 1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S - 1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames - 1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + # ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=90) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py new file mode 100644 index 0000000000000000000000000000000000000000..7082e12bc293dff252db3f84182d49dc6e945e7b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/planning_visualizer.py @@ -0,0 +1,397 @@ +import os + +import numpy as np +import torch +from matplotlib import pyplot as plt +from matplotlib.animation import FuncAnimation +import matplotlib.collections as mcoll + +# Project includes. +from torch_robotics.torch_utils.torch_utils import to_numpy +from smd.plotting.base import remove_borders, remove_axes_labels_ticks + +def create_fig_and_axes(dim=2): + fig = plt.figure(layout='tight') + if dim == 3: + ax = fig.add_subplot(projection='3d') + else: + ax = fig.add_subplot() + + return fig, ax + + +class PlanningVisualizer: + + def __init__(self, task=None, planner=None): + self.task = task + self.env = self.task.env + self.robot = self.task.robot + self.planner = planner + + # self.colors = {'collision': 'black', 'free': 'cornsilk'} + self.colors = {'collision': 'black', 'free': 'lightcyan'} + self.colors_robot = {'collision': 'black', 'free': 'darkorange'} + self.cmaps = {'collision': 'Greys', 'free': 'Oranges'} + self.cmaps_robot = {'collision': 'Greys', 'free': 'YlOrRd'} + + def render_robot_trajectories(self, fig=None, ax=None, render_planner=False, trajs=None, traj_best=None, + show_robot_in_image=False, constraints_l=None, **kwargs): + if fig is None or ax is None: + fig, ax = create_fig_and_axes(dim=self.env.dim) + + if render_planner: + self.planner.render(ax) + self.env.render(ax) + if trajs is not None: + _, trajs_coll_idxs, _, trajs_free_idxs, _ = self.task.get_trajs_collision_and_free(trajs, return_indices=True) + kwargs['colors'] = [] + for i in range(len(trajs_coll_idxs) + len(trajs_free_idxs)): + kwargs['colors'].append(self.colors['collision'] if i in trajs_coll_idxs else self.colors['free']) + self.robot.render_trajectories(ax, trajs=trajs, constraints_l=constraints_l, **kwargs) + if traj_best is not None: + kwargs['colors'] = ['blue'] + self.robot.render_trajectories(ax, trajs=traj_best.unsqueeze(0), constraints_l=constraints_l, **kwargs) + + # Add the robot with tail at the third-way time step. + if show_robot_in_image: + t_mid = trajs.shape[1] // 5 + qs = trajs[:, t_mid, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + tail_length = min(5, t_mid) + for di in range(tail_length): + q_tail.append(trajs[:, t_mid - di, :].squeeze()) + kwargs['q_tail'] = q_tail + # Random color from colormap for the robot and tail. From cmap tab20(do c optionally). + color = plt.cm.tab20(np.random.randint(0, 20)) + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + return fig, ax + + def animate_robot_trajectories( + self, trajs=None, start_state=None, goal_state=None, + plot_trajs=False, + n_frames=10, + constraints=None, + **kwargs + ): + if trajs is None: + return + + assert trajs.ndim == 3 + B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_selection = trajs[:, idxs, :] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs, start_state=start_state, goal_state=goal_state, **kwargs + ) + else: + self.env.render(ax) + + # TODO - implement batched version + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for i, q in enumerate(qs): + self.robot.render( + ax, q=q, + color=self.colors_robot['collision'] if self.task.compute_collision(q, margin=0.0) else self.colors_robot['free'], + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def animate_multi_robot_trajectories( + self, trajs_l=None, start_state_l=None, goal_state_l=None, + plot_trajs=False, + n_frames=10, + constraints=None, + colors=None, + **kwargs + ): + assert len(colors) == len(trajs_l) + + assert trajs_l[0].ndim == 3 + B, H, D = trajs_l[0].shape + + idxs = np.round(np.linspace(0, H - 1, n_frames)).astype(int) + trajs_l_selection = [] + for trajs in trajs_l: + trajs_selection = trajs[:, idxs, :] + trajs_l_selection.append(trajs_selection) + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + """ + Draw all the robots at step i for all trajectories. + Also add a robot configuration at any constraints. + """ + print(idxs[i], "/", H, end="\r") + ax.clear() + ax.set_title(f"step: {idxs[i]}/{H-1}") + if plot_trajs: + for trajs, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + self.render_robot_trajectories( + fig=fig, + ax=ax, + trajs=trajs, + start_state=start_state, + goal_state=goal_state, + colors=[color]*len(trajs), + **kwargs + ) + else: + self.env.render(ax) + + for trajs_selection, color, start_state, goal_state in zip(trajs_l_selection, colors, start_state_l, goal_state_l): + qs = trajs_selection[:, i, :] # batch, q_dim + if qs.ndim == 1: + qs = qs.unsqueeze(0) # interface (batch, q_dim) + for q in qs: + q_tail = [] + for di in range(5): + q_tail.append(trajs_selection[:, i - di, :].squeeze()) + kwargs['q_tail'] = q_tail + self.robot.render( + ax, q=q, + color=color, + arrow_length=0.1, arrow_alpha=0.5, arrow_linewidth=1., + cmap=self.cmaps['collision'] if self.task.compute_collision(q, margin=0.0) else self.cmaps['free'], + **kwargs + ) + + # if start_state is not None: + # self.robot.render(ax, start_state, color='green', cmap='Greens') + # if goal_state is not None: + # self.robot.render(ax, goal_state, color='purple', cmap='Purples') + # Also add any constraints to the plot, if those exist and are active at this step i. + step = idxs[i] + if constraints is not None: + for constraint in constraints: + # For Constraint object passed. + if constraint.get_t_range()[0] <= step <= constraint.get_t_range()[1]: + self.robot.render(ax, constraint.get_q(), color='red', cmap='Reds') + # For list of CostCosntaint objects passed. + # if constraint.traj_range[0] <= step <= constraint.traj_range[1]: + # self.robot.render(ax, constraint.q, color='red', cmap='Reds') + + # Remove the axes, frame, and title. TEST TEST TEST. + remove_borders(ax) + remove_axes_labels_ticks(ax) + ax.set_title('') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + + def animate_opt_iters_robots( + self, trajs=None, traj_best=None, start_state=None, goal_state=None, + n_frames=10, + **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, ax = create_fig_and_axes(dim=self.env.dim) + + def animate_fn(i): + ax.clear() + ax.set_title(f"iter: {idxs[i]}/{S-1}") + self.render_robot_trajectories( + fig=fig, ax=ax, trajs=trajs_selection[i], + traj_best=traj_best if i == n_frames - 1 else None, + start_state=start_state, goal_state=goal_state, **kwargs + ) + if start_state is not None: + self.robot.render(ax, start_state, color='green', cmap='Greens') + if goal_state is not None: + self.robot.render(ax, goal_state, color='purple', cmap='Purples') + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + def plot_joint_space_state_trajectories( + self, + fig=None, axs=None, + trajs=None, + traj_best=None, + pos_start_state=None, pos_goal_state=None, + vel_start_state=None, vel_goal_state=None, + set_joint_limits=True, + **kwargs + ): + if trajs is None: + return + trajs_np = to_numpy(trajs) + + assert trajs_np.ndim == 3 + B, H, D = trajs_np.shape + + # Separate trajectories in collision and free (not in collision) + trajs_coll, trajs_free = self.task.get_trajs_collision_and_free(trajs) + + trajs_coll_pos_np = to_numpy([]) + trajs_coll_vel_np = to_numpy([]) + if trajs_coll is not None: + trajs_coll_pos_np = to_numpy(self.robot.get_position(trajs_coll)) + trajs_coll_vel_np = to_numpy(self.robot.get_velocity(trajs_coll)) + + trajs_free_pos_np = to_numpy([]) + trajs_free_vel_np = to_numpy([]) + if trajs_free is not None: + trajs_free_pos_np = to_numpy(self.robot.get_position(trajs_free)) + trajs_free_vel_np = to_numpy(self.robot.get_velocity(trajs_free)) + + if pos_start_state is not None: + pos_start_state = to_numpy(pos_start_state) + if vel_start_state is not None: + vel_start_state = to_numpy(vel_start_state) + if pos_goal_state is not None: + pos_goal_state = to_numpy(pos_goal_state) + if vel_goal_state is not None: + vel_goal_state = to_numpy(vel_goal_state) + + if fig is None or axs is None: + fig, axs = plt.subplots(self.robot.q_dim, 2, squeeze=False) + axs[0, 0].set_title('Position') + axs[0, 1].set_title('Velocity') + axs[-1, 0].set_xlabel('Timesteps') + axs[-1, 1].set_xlabel('Timesteps') + timesteps = np.arange(H).reshape(1, -1) + for i, ax in enumerate(axs): + for trajs_filtered, color in zip([(trajs_coll_pos_np, trajs_coll_vel_np), (trajs_free_pos_np, trajs_free_vel_np)], + ['black', 'orange']): + # Positions and velocities + for j, trajs_filtered_ in enumerate(trajs_filtered): + if trajs_filtered_.size > 0: + timesteps_ = np.repeat(timesteps, trajs_filtered_.shape[0], axis=0) + plot_multiline(ax[j], timesteps_, trajs_filtered_[..., i], color=color, **kwargs) + + if traj_best is not None: + traj_best_pos = self.robot.get_position(traj_best) + traj_best_vel = self.robot.get_velocity(traj_best) + traj_best_pos_np = to_numpy(traj_best_pos) + traj_best_vel_np = to_numpy(traj_best_vel) + plot_multiline(ax[0], timesteps, traj_best_pos_np[..., i].reshape(1, -1), color='blue', **kwargs) + plot_multiline(ax[1], timesteps, traj_best_vel_np[..., i].reshape(1, -1), color='blue', **kwargs) + + # Start and goal + if pos_start_state is not None: + ax[0].scatter(0, pos_start_state[i], color='green') + if vel_start_state is not None: + ax[1].scatter(0, vel_start_state[i], color='green') + if pos_goal_state is not None: + ax[0].scatter(H-1, pos_goal_state[i], color='purple') + if vel_goal_state is not None: + ax[1].scatter(H-1, vel_goal_state[i], color='purple') + # Y label + ax[0].set_ylabel(f'q_{i}') + # Set limits + if set_joint_limits: + ax[0].set_ylim(self.robot.q_min_np[i], self.robot.q_max_np[i]) + # ax[1].set_ylim(self.robot.q_vel_min_np[i], self.robot.q_vel_max_np[i]) + + return fig, axs + + def animate_opt_iters_joint_space_state( + self, trajs=None, traj_best=None, n_frames=10, **kwargs + ): + # trajs: steps, batch, horizon, q_dim + if trajs is None: + return + + assert trajs.ndim == 4 + S, B, H, D = trajs.shape + + idxs = np.round(np.linspace(0, S - 1, n_frames)).astype(int) + trajs_selection = trajs[idxs] + + fig, axs = self.plot_joint_space_state_trajectories(trajs=trajs_selection[0], **kwargs) + + def animate_fn(i): + [ax.clear() for ax in axs.ravel()] + fig.suptitle(f"iter: {idxs[i]}/{S-1}") + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], **kwargs + ) + if i == n_frames -1 and traj_best is not None: + self.plot_joint_space_state_trajectories( + fig=fig, axs=axs, + trajs=trajs_selection[i], + traj_best=traj_best, **kwargs + ) + + create_animation_video(fig, animate_fn, n_frames=n_frames, **kwargs) + + +def create_animation_video(fig, animate_fn, anim_time=5, n_frames=100, video_filepath='video.gif', **kwargs): + str_start = "Creating animation" + # if ".gif" in video_filepath: + # video_filepath = video_filepath.replace(".gif", ".gif") + print(f'{str_start}...') + ani = FuncAnimation( + fig, + animate_fn, + frames=n_frames, + interval=anim_time * 1000 / n_frames, + repeat=False + ) + print(f'...finished {str_start}') + + str_start = "Saving video..." + print(f'{str_start}...') + # ani.save(os.path.join(video_filepath), fps=max(1, int(n_frames / anim_time)), dpi=100) + print(f'...finished {str_start}') + + +def plot_multiline(ax, X, Y, color='blue', linestyle='solid', **kwargs): + segments = np.stack((X, Y), axis=-1) + line_segments = mcoll.LineCollection(segments, colors=[color] * len(segments), linestyle=linestyle) + ax.add_collection(line_segments) + points = np.reshape(segments, (-1, 2)) + ax.scatter(points[:, 0], points[:, 1], color=color, s=2 ** 2) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..eaae21e271ddf8302631a2ea2e63f52d9bb83218 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/deps/torch_robotics/torch_robotics/visualizers/plot_utils.py @@ -0,0 +1,32 @@ +from copy import copy + +import torch + +from torch_robotics.torch_utils.torch_utils import to_numpy + + +def plot_coordinate_frame(ax, frame, arrow_length=0.1, arrow_alpha=1.0, arrow_linewidth=1.0, tensor_args=None): + target_pos_np = to_numpy(frame.translation).squeeze() + frame_target_no_trans = copy(frame) + frame_target_no_trans.set_translation(torch.zeros_like(frame.translation)) + + x_basis = torch.tensor([1, 0, 0], **tensor_args) + y_basis = torch.tensor([0, 1, 0], **tensor_args) + z_basis = torch.tensor([0, 0, 1], **tensor_args) + + x_axis_target = to_numpy(frame_target_no_trans.transform_point(x_basis).squeeze()) + y_axis_target = to_numpy(frame_target_no_trans.transform_point(y_basis).squeeze()) + z_axis_target = to_numpy(frame_target_no_trans.transform_point(z_basis).squeeze()) + + # x-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + x_axis_target[0], x_axis_target[1], x_axis_target[2], + length=arrow_length, normalize=True, color='red', alpha=arrow_alpha, linewidth=arrow_linewidth) + # y-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + y_axis_target[0], y_axis_target[1], y_axis_target[2], + length=arrow_length, normalize=True, color='green', alpha=arrow_alpha, linewidth=arrow_linewidth) + # z-axis + ax.quiver(target_pos_np[0], target_pos_np[1], target_pos_np[2], + z_axis_target[0], z_axis_target[1], z_axis_target[2], + length=arrow_length, normalize=True, color='blue', alpha=arrow_alpha, linewidth=arrow_linewidth) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/is_collision.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/is_collision.py new file mode 100644 index 0000000000000000000000000000000000000000..834d2e73359ae009eccbe24057e567a1bfcaca16 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/is_collision.py @@ -0,0 +1,97 @@ +import pickle +import numpy as np +import os + + +def check_paths_ok(paths, obs_data, robot_data, robot_radius=0.05, threshold=1e-3): + """Check path validity against obstacles, other robots, and start/mid anchors. + + Args: + paths: ndarray shape (R, T, 2) — R robots, T timesteps, (x,y). + obs_data: iterable of obstacles, each like (center, radius). + robot_data: per-robot start/mid references. Accepts one of: + - list/tuple where robot_data[i][0] is start (x,y), robot_data[i][1] is mid (x,y) + - ndarray with shape (R, 2, 2) or (2, R, 2) + robot_radius: radius of each robot. + threshold: numerical tolerance for comparisons. + + Returns: + True if all constraints satisfied, else False. + """ + paths = np.asarray(paths, dtype=float) + if paths.ndim != 3 or paths.shape[-1] != 2: + raise ValueError("paths must have shape (R, T, 2)") + + num_robots, num_steps, _ = paths.shape + + # 1) robot vs obstacle (vectorized over time and robots) + if len(obs_data) > 0: + for i in range(num_robots): + for j in range(len(obs_data)): + for t in range(num_steps): + if (paths[i, t, 0] - obs_data[j][0][0])**2 + (paths[i, t, 1] - obs_data[j][0][1])**2 < (robot_radius + obs_data[j][1])**2 - threshold: + return False + + + # 2) robot vs robot (pairwise distances at each timestep) + if num_robots > 1: + for i in range(num_robots): + for j in range(i+1, num_robots): + for t in range(num_steps): + if (paths[i, t, 0] - paths[j, t, 0])**2 + (paths[i, t, 1] - paths[j, t, 1])**2 < (2.0 * robot_radius)**2 - threshold: + return False + + + # # 3) velocity limit + # if np.any(np.diff(paths, axis=1) > 0.1): + # return False + + return True + + +res_base_dir = 'scripts/inference/results_test' +res_sub_dirs = [d for d in os.listdir(res_base_dir) if os.path.isdir(os.path.join(res_base_dir, d))] +res_sub_dirs = sorted(res_sub_dirs) + +res_dirs = [] +for res_sub_dir in res_sub_dirs: + tmp = [res_base_dir + '/' + res_sub_dir + '/' + d + '/instance_name___EnvEmptyNoWait2DRobotCompositeNinePlanarDiskRandom/num_agents___9/planner___SMDComposite/single_agent_planner___SMDEnsemble/0/' for d in os.listdir(res_base_dir + '/' + res_sub_dir) if os.path.isdir(os.path.join(res_base_dir + '/' + res_sub_dir, d))] + tmp = sorted(tmp) + res_dirs.extend(tmp) + +map_info_dirs = [] +paths_dirs = [] +for res_dir in res_dirs: + map_info_dirs.append(res_dir + 'map_info.pkl') + paths_dirs.append(res_dir + 'paths.npy') + +map_folder = 'instances_data/' + +cnt_success = 0 +for map_info_dir, paths_dir in zip(map_info_dirs, paths_dirs): + map_info_data = pickle.load(open(map_info_dir, 'rb')) + + map_file_path = map_folder + map_info_data['map_name'] + '.pkl' + map_idx = map_info_data['instance_idx'] + + paths_data = np.load(paths_dir) + map_file = pickle.load(open(map_file_path, 'rb')) + + + if paths_data.shape[0] != 64 or paths_data.shape[1] != 64 or paths_data.shape[2] != 36: + raise ValueError("paths_data shape is not correct") + path_data = paths_data[0,:,:18].reshape(64, 9, 2).swapaxes(0, 1) + + + map_data = map_file[map_idx][2] + obs_data = map_data[0] + robot_data = map_data[1] + is_feasible = check_paths_ok(path_data, obs_data, robot_data) + + if not is_feasible: + print(f"Collision detected for {map_idx}") + else: + print(f"Feasible path for {map_idx}") + cnt_success += 1 +print(f"Success rate: {cnt_success / len(map_info_dirs)}") + \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/requirements.txt b/applications/multi_robot/robotarium/robotarium_baselines/SMD/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..806ece3f988755fb3ef96cde78ef7ed0a844efca --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/requirements.txt @@ -0,0 +1,132 @@ +absl-py==2.3.0 +annotated-types==0.7.0 +asttokens==3.0.0 +backcall==0.2.0 +beautifulsoup4==4.13.4 +cachetools==5.5.2 +certifi==2022.12.7 +charset-normalizer==2.1.1 +cholespy==2.0.0 +click==8.1.8 +comm==0.2.2 +ConfigArgParse==1.7.1 +contourpy==1.1.1 +cycler==0.12.1 +debugpy==1.8.14 +decorator==4.4.2 +einops==0.6.1 +eval_type_backport==0.2.2 +executing==2.2.0 +filelock==3.13.1 +fonttools==4.57.0 +freetype-py==2.5.1 +fsspec==2024.6.1 +gdown==5.2.0 +gitdb==4.0.12 +GitPython==3.1.44 +google-auth==2.40.3 +google-auth-oauthlib==1.0.0 +grpcio==1.70.0 +hf-xet==1.1.3 +huggingface-hub==0.33.0 +Hydra==2.5 +idna==3.4 +imageio==2.35.1 +imageio-ffmpeg==0.5.1 +importlib_metadata==8.5.0 +importlib_resources==6.4.5 +ipykernel==6.29.5 +ipython==8.12.3 +jedi==0.19.2 +Jinja2==3.1.4 +joblib==1.4.2 +jupyter_client==8.6.3 +jupyter_core==5.8.1 +kiwisolver==1.4.7 +lxml==5.4.0 +Markdown==3.7 +MarkupSafe==2.1.5 +matplotlib==3.7.5 +matplotlib-inline==0.1.7 +mesh-to-sdf==0.0.15 +moviepy==1.0.3 +mpmath==1.3.0 +narwhals==1.42.1 +nest-asyncio==1.6.0 +networkx==2.2 +numpy==1.24.1 +oauthlib==3.2.2 +opencv-python==4.10.0.84 +packaging==25.0 +pandas==2.0.3 +parso==0.8.4 +pexpect==4.9.0 +pickleshare==0.7.5 +pillow==10.2.0 +platformdirs==4.3.6 +plotly==6.1.2 +ply==3.11 +proglog==0.1.12 +prompt_toolkit==3.0.51 +protobuf==5.29.5 +psutil==7.0.0 +ptyprocess==0.7.0 +pure_eval==0.2.3 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycollada==0.6 +pydantic==2.10.6 +pydantic_core==2.27.2 +pygame==2.6.1 +pyglet==2.1.6 +Pygments==2.19.2 +Pyomo==6.8.2 +PyOpenGL==3.1.0 +pyparsing==3.1.4 +pyrender==0.1.45 +PySocks==1.7.1 +python-dateutil==2.9.0.post0 +pytz==2025.2 +PyWavelets==1.4.1 +PyYAML==6.0.2 +pyzmq==27.0.0 +regex==2024.11.6 +requests==2.28.1 +requests-oauthlib==2.0.0 +rsa==4.9.1 +safetensors==0.5.3 +scikit-image==0.19.3 +scikit-learn==1.3.2 +scikit-video==1.1.11 +scipy==1.10.1 +seaborn==0.13.2 +sentence-transformers==3.2.1 +sentry-sdk==2.30.0 +setproctitle==1.3.6 +shapely==2.0.7 +six==1.17.0 +smmap==5.0.2 +soupsieve==2.7 +stack-data==0.6.3 +sympy==1.13.3 +tabulate==0.9.0 +tensorboard==2.14.0 +tensorboard-data-server==0.7.2 +threadpoolctl==3.5.0 +tifffile==2023.7.10 +tokenizers==0.20.3 +tornado==6.4.2 +tqdm==4.43.0 +traitlets==5.14.3 +transformers==4.46.3 +trimesh==4.6.12 +triton==2.1.0 +typing_extensions==4.12.2 +tzdata==2025.2 +urdf_parser_py==0.0.4 +urdfpy==0.0.22 +urllib3==1.26.13 +wandb==0.20.1 +wcwidth==0.2.13 +Werkzeug==3.0.6 +zipp==3.20.2 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/__init__.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/inference_multi_agent.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/inference_multi_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..48c67770da865969f33b8413021a8b1c610b13ca --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/inference_multi_agent.py @@ -0,0 +1,337 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +# from torch_robotics.isaac_gym_envs.motion_planning_envs import PandaMotionPlanningIsaacGymEnv, MotionPlanningController + +import os +import pickle +from datetime import datetime +import time +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List +import concurrent.futures + +# Project imports. +from experiment_launcher import single_experiment_yaml, run_experiment +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory, CostConstraint +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import guide_gradient_steps, ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from torch_robotics.robots import * +from torch_robotics.torch_utils.seed import fix_random_seed +from torch_robotics.torch_utils.torch_timer import TimerCUDA +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.trajectory.metrics import compute_smoothness, compute_path_length, compute_variance_waypoints, \ + compute_average_acceleration, compute_average_acceleration_from_pos_vel, compute_path_length_from_pos +from torch_robotics.trajectory.utils import interpolate_traj_via_points +from torch_robotics.visualizers.planning_visualizer import PlanningVisualizer +from torch_robotics.robots.robot_planar_disk import RobotPlanarDisk +from smd.planners.multi_agent import CBS, PrioritizedPlanning, SMDComposite +from smd.planners.single_agent import SMD, SMDEnsemble +from smd.common.constraints import MultiPointConstraint, VertexConstraint, EdgeConstraint +from smd.common.conflicts import VertexConflict, PointConflict, EdgeConflict +from smd.common.trajectory_utils import smooth_trajs, densify_trajs +from smd.common import compute_collision_intensity, is_multi_agent_start_goal_states_valid, global_pad_paths, \ + get_start_goal_pos_circle, get_state_pos_column, get_start_goal_pos_boundary, get_start_goal_pos_random_in_env +from smd.common.pretty_print import * +from smd.config.smd_params import SMDParams as params +from smd.common.experiments import MultiAgentPlanningSingleTrialConfig, get_result_dir_from_trial_config +from torch_robotics.environments import * + +allow_ops_in_compiled_graph() + +TRAINED_MODELS_DIR = '../../data_trained_models/' +device = 'cuda' +device = get_torch_device(device) +tensor_args = {'device': device, 'dtype': torch.float32} + + +def run_multi_agent_trial(test_config: MultiAgentPlanningSingleTrialConfig): + # ============================ + # Start time per agent. + # ============================ + start_time_l = [i * test_config.stagger_start_time_dt for i in range(test_config.num_agents)] + + # ============================ + # Arguments for the high/low level planner. + # ============================ + low_level_planner_model_args = { + 'planner_alg': 'smd', + 'use_guide_on_extra_objects_only': params.use_guide_on_extra_objects_only, + 'n_samples': params.n_samples, + 'n_local_inference_noising_steps': params.n_local_inference_noising_steps, + 'n_local_inference_denoising_steps': params.n_local_inference_denoising_steps, + 'start_guide_steps_fraction': params.start_guide_steps_fraction, + 'n_guide_steps': params.n_guide_steps, + 'n_diffusion_steps_without_noise': params.n_diffusion_steps_without_noise, + 'weight_grad_cost_collision': params.weight_grad_cost_collision, + 'weight_grad_cost_smoothness': params.weight_grad_cost_smoothness, + 'weight_grad_cost_constraints': params.weight_grad_cost_constraints, + 'weight_grad_cost_soft_constraints': params.weight_grad_cost_soft_constraints, + 'factor_num_interpolated_points_for_collision': params.factor_num_interpolated_points_for_collision, + 'trajectory_duration': params.trajectory_duration, + 'device': params.device, + 'debug': params.debug, + 'seed': params.seed, + 'results_dir': params.results_dir, + 'trained_models_dir': TRAINED_MODELS_DIR, + 'instance_idx': test_config.instance_idx, + 'map_name': test_config.map_name, + 'init_traj4proj': test_config.init_traj4proj, + 'proj_params': test_config.proj_params + } + high_level_planner_model_args = { + 'is_xcbs': True if test_config.multi_agent_planner_class in ["XECBS", "XCBS"] else False, + 'is_ecbs': True if test_config.multi_agent_planner_class in ["ECBS", "XECBS"] else False, + 'start_time_l': start_time_l, + 'runtime_limit': test_config.runtime_limit, + 'conflict_type_to_constraint_types': {PointConflict: {MultiPointConstraint}}, + 'instance_idx': test_config.instance_idx, + 'map_name': test_config.map_name, + 'init_traj4proj': test_config.init_traj4proj, + 'proj_params': test_config.proj_params + } + + # ============================ + # Create a results directory. + # ============================ + results_dir = get_result_dir_from_trial_config(test_config, test_config.time_str, test_config.trial_number) + os.makedirs(results_dir, exist_ok=True) + num_agents = test_config.num_agents + + # ============================ + # Get planning problem. + # ============================ + # If want to get random starts and goals, then must do that after creating the reference task and robot. + start_l = test_config.start_state_pos_l + goal_l = test_config.goal_state_pos_l + global_model_ids = test_config.global_model_ids + agent_skeleton_l = test_config.agent_skeleton_l + + # ============================ + # Transforms and model tiles setup. + # ============================ + # Create a reference planner from which we'll use the task and robot as the reference on in CBS. + # Those are used for collision checking and visualization. This has a skeleton of all tiles. + reference_agent_skeleton = [[r, c] for r in range(len(global_model_ids)) + for c in range(len(global_model_ids[0]))] + + # ============================ + # Transforms from tiles to global frame. + # ============================ + tile_width = 2.0 + tile_height = 2.0 + global_model_transforms = [[torch.tensor([x * tile_width, -y * tile_height], **tensor_args) + for x in range(len(global_model_ids[0]))] for y in range(len(global_model_ids))] + + # ============================ + # Parse the single agent planner class name. + # ============================ + if test_config.single_agent_planner_class == "SMD": + low_level_planner_class = SMD + elif test_config.single_agent_planner_class == "SMDEnsemble": + low_level_planner_class = SMDEnsemble + else: + raise ValueError(f'Unknown single agent planner class: {test_config.single_agent_planner_class}') + + # ============================ + # Create reference agent planner. + # ============================ + # And for the reference skeleton. + reference_task = None + reference_robot = None + + # ============================ + # Run trial. + # ============================ + exp_name = f'smd_single_trial' + + # Transform starts and goals to the global frame. Right now they are in the local tile frames. + start_l = [start_l[i] + global_model_transforms[agent_skeleton_l[i][0][0]][agent_skeleton_l[i][0][1]] + for i in range(num_agents)] + goal_l = [goal_l[i] + global_model_transforms[agent_skeleton_l[i][-1][0]][agent_skeleton_l[i][-1][1]] + for i in range(num_agents)] + + # ============================ + # Create global transforms for each agent's skeleton. + # ============================ + # Each agent has a dict entry. Each entry is a dict with the skeleton steps (0, 1, 2, ...), mapping to the + # model transform. + agent_model_transforms_l = [] + agent_model_ids_l = [] + for agent_id in range(num_agents): + agent_model_transforms = {} + agent_model_ids = {} + for skeleton_step in range(len(agent_skeleton_l[agent_id])): + skeleton_model_coord = agent_skeleton_l[agent_id][skeleton_step] + agent_model_transforms[skeleton_step] = global_model_transforms[skeleton_model_coord[0]][ + skeleton_model_coord[1]] + agent_model_ids[skeleton_step] = global_model_ids[skeleton_model_coord[0]][skeleton_model_coord[1]] + agent_model_transforms_l.append(agent_model_transforms) + agent_model_ids_l.append(agent_model_ids) + # Change the dict of the model ids to a list sorted by the skeleton steps. + agent_model_ids_l = [[agent_model_ids_l[i][j] for j in range(len(agent_model_ids_l[i]))] for i in + range(num_agents)] + + # ============================ + # Create the low level planners. + # ============================ + planners_creation_start_time = time.time() + low_level_planner_l = [] + if test_config.multi_agent_planner_class != "SMDComposite": + for i in range(num_agents): + low_level_planner_model_args_i = low_level_planner_model_args.copy() + low_level_planner_model_args_i['start_state_pos'] = start_l[i] + low_level_planner_model_args_i['goal_state_pos'] = goal_l[i] + low_level_planner_model_args_i['model_ids'] = agent_model_ids_l[i] + low_level_planner_model_args_i['transforms'] = agent_model_transforms_l[i] + if test_config.single_agent_planner_class == "SMD": + # Set the model_id to the first one. + low_level_planner_model_args_i['model_id'] = agent_model_ids_l[i][0] + + low_level_planner_l.append(low_level_planner_class(**low_level_planner_model_args_i)) + print('Planners creation time:', time.time() - planners_creation_start_time) + print("\n\n\n\n") + + # ============================ + # Create the multi agent planner. + # ============================ + if test_config.multi_agent_planner_class == "SMDComposite": + assert len(global_model_ids) == 1 + assert len(global_model_ids[0]) == 1 + model_id = global_model_ids[0][0] + multi_agent_planner_class = SMDComposite + high_level_planner_model_args['model_id'] = model_id + high_level_planner_model_args['results_dir'] = results_dir + + else: + raise ValueError(f'Unknown multi agent planner class: {test_config.multi_agent_planner_class}') + planner = multi_agent_planner_class(low_level_planner_l, + start_l, + goal_l, + reference_task=reference_task, + reference_robot=reference_robot, + **high_level_planner_model_args) + # ============================ + # Plan. + # ============================ + startt = time.time() + whole_trajs = planner.plan(runtime_limit=test_config.runtime_limit) + planning_time = time.time() - startt + # Print planning times. + print(GREEN, 'Planning times:', planning_time, RESET) + + # ============================ + # Save outputs. + # ============================ + results_dir_uri = f'file://{os.path.abspath(results_dir)}' + print('Results dir:', results_dir_uri) + paths_np = whole_trajs.cpu().numpy() + with open(os.path.join(results_dir, 'paths.npy'), 'wb') as f: + np.save(f, paths_np) + # save map info + res_map_name = test_config.map_name + res_instance_idx = test_config.instance_idx + with open(os.path.join(results_dir, 'map_info.pkl'), 'wb') as f: + pickle.dump({'map_name': res_map_name, 'instance_idx': res_instance_idx}, f) + + + +if __name__ == '__main__': + test_config_single_tile = MultiAgentPlanningSingleTrialConfig() + test_config_single_tile.num_agents = 3 + test_config_single_tile.instance_name = "test" + test_config_single_tile.multi_agent_planner_class = "XECBS" # Or "ECBS" or "XCBS" or "CBS" or "PP". + test_config_single_tile.single_agent_planner_class = "SMDEnsemble" # Or "SMD" + test_config_single_tile.stagger_start_time_dt = 0 + test_config_single_tile.runtime_limit = 60 * 3 # 3 minutes. + test_config_single_tile.time_str = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + test_config_single_tile.render_animation = True # Change the `densify_trajs` call above to create nicer animations. + + example_type = "single_tile" + # example_type = "multi_tile" + # ============================ + # Single tile. + # ============================ + if example_type == "single_tile": + # Choose the model to use. A model is for a map/robot combination. + # test_config_single_tile.global_model_ids = [['EnvEmpty2D-RobotPlanarDisk']] + test_config_single_tile.global_model_ids = [['EnvEmptyNoWait2D-RobotPlanarDisk']] + # test_config_single_tile.global_model_ids = [['EnvConveyor2D-RobotPlanarDisk']] + # test_config_single_tile.global_model_ids = [['EnvHighways2D-RobotPlanarDisk']] + # test_config_single_tile.global_model_ids = [['EnvDropRegion2D-RobotPlanarDisk']] + + # Choose starts and goals. + test_config_single_tile.agent_skeleton_l = [[[0, 0]]] * test_config_single_tile.num_agents + torch.random.manual_seed(10) + test_config_single_tile.start_state_pos_l, test_config_single_tile.goal_state_pos_l = \ + get_start_goal_pos_circle(test_config_single_tile.num_agents, 0.8) + # Another option is to get random starts and goals. + # get_start_goal_pos_random_in_env(test_config_single_tile.num_agents, + # EnvDropRegion2D, + # tensor_args, + # margin=0.2, + # obstacle_margin=0.11) + # A third option is to get starts and goals in a "boundary" formation. + # get_start_goal_pos_boundary(test_config_single_tile.num_agents, 0.85) + # And a final option is to hard-code starts and goals. + # (torch.tensor([[-0.8, 0], [0.8, -0]], **tensor_args), + # torch.tensor([[0.8, -0], [-0.8, 0]], **tensor_args)) + print("Starts:", test_config_single_tile.start_state_pos_l) + print("Goals:", test_config_single_tile.goal_state_pos_l) + + run_multi_agent_trial(test_config_single_tile) + print(GREEN, 'OK.', RESET) + + # ============================ + # Multiple tiles example. + # ============================ + if example_type == "multi_tile": + test_config_multiple_tiles = test_config_single_tile + test_config_multiple_tiles.num_agents = 4 + test_config_multiple_tiles.stagger_start_time_dt = 5 + test_config_multiple_tiles.global_model_ids = \ + [['EnvEmptyNoWait2D-RobotPlanarDisk', 'EnvEmptyNoWait2D-RobotPlanarDisk']] + + test_config_multiple_tiles.agent_skeleton_l = [[[0, 0], [0, 1]], + [[0, 1], [0, 0]], + [[0, 0], [0, 1]], + [[0, 1], [0, 0]]] + test_config_multiple_tiles.start_state_pos_l, test_config_multiple_tiles.goal_state_pos_l = \ + (torch.tensor([[0, 0.8], [0, 0.3], [0, -0.3], [0, -0.8]], **tensor_args), + torch.tensor([[0, -0.8], [0, -0.3], [0, 0.3], [0, 0.8]], **tensor_args)) + print(test_config_multiple_tiles.start_state_pos_l) + test_config_multiple_tiles.multi_agent_planner_class = "XECBS" + run_multi_agent_trial(test_config_multiple_tiles) + print(GREEN, 'OK.', RESET) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_multi_agent_experiment.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_multi_agent_experiment.py new file mode 100644 index 0000000000000000000000000000000000000000..6c4912404179d46fa292075b8bcf77fddafec4ad --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_multi_agent_experiment.py @@ -0,0 +1,52 @@ +""" +MIT License + +Copyright (c) 2024 Yorai Shaoul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from smd.common.experiments.experiment_utils import * +from inference_multi_agent import run_multi_agent_trial + + +def run_multi_agent_experiment(experiment_config: MultiAgentPlanningExperimentConfig): + # Run the multi-agent planning experiment. + startt = time.time() + # Create the experiment config. + experiment_config.time_str = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + # Get single trial configs from the experiment config. + single_trial_configs = experiment_config.get_single_trial_configs_from_experiment_config() + # So let's run sequentially. + for single_trial_config in single_trial_configs: + print(single_trial_config) + try: + run_multi_agent_trial(single_trial_config) + except Exception as e: + print("Error in run_multi_agent_experiment: ", e) + # Save to a file. + with open(f"error_{experiment_config.time_str}.txt", "a") as f: + f.write(str(e)) + f.write("This is for single_trial_config: ") + f.write(str(single_trial_config)) + f.write("\n") + continue + + # Print the runtime. + print("Runtime: ", time.time() - startt) + print("Run: OK.") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_smd_composite_experiment.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_smd_composite_experiment.py new file mode 100644 index 0000000000000000000000000000000000000000..4c5a51c8b2aad531f6b6e5dd3a774dee6bc31415 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/inference/launch_smd_composite_experiment.py @@ -0,0 +1,126 @@ +""" +MIT License + +Copyright (c) 2025 Jinhao Liang. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# Standard imports. +import argparse +import os +import pickle +from datetime import datetime +import time +from math import ceil +from pathlib import Path + +import einops +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +from einops._torch_specific import allow_ops_in_compiled_graph # requires einops>=0.6.1 +from typing import Tuple, List +import concurrent.futures +import multiprocessing as mp + + +# Project includes. +from smd.common.experiments import MultiAgentPlanningExperimentConfig +from smd.common.experiments.experiment_utils import * +from smd.config.smd_params import SMDParams as params +from inference_multi_agent import run_multi_agent_trial +from launch_multi_agent_experiment import run_multi_agent_experiment + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Run multi-agent planning experiments.") + parser.add_argument("--start_index", type=int, default=0, help="The starting index for samples.") + parser.add_argument("--end_index", type=int, default=1, help="The ending index for samples (exclusive).") + parser.add_argument("--save_path", type=str, default="results_test", help="The base directory to save results.") + parser.add_argument("--agents_max_speeds", type=float, default=0.05, help="Max speed per agent for projection.") + parser.add_argument("--rho", type=float, default=5.0, help="Initial ALM rho.") + parser.add_argument("--rho_factor", type=float, default=1.05, help="Multiplicative factor to increase rho each ALM iter.") + parser.add_argument("--alm_iteration", type=int, default=100, help="Max ALM iterations.") + parser.add_argument("--tolerance", type=float, default=1e-3, help="Convergence tolerance for ALM.") + parser.add_argument("--projection_step", type=int, nargs='+', default=[15, 5], help="Projection steps used during sampling.") + parser.add_argument("--runtime_limit", type=int, default=100000, help="Runtime limit for the experiment.") + parser.add_argument("--map_name", type=str, default='instances_simple', help="Runtime limit for the experiment.") + parser.add_argument("--experiment_instance_names", type=str, nargs='+', + default=[ + "EnvEmptyNoWait2DRobotCompositeThreePlanarDiskRandom", + # "EnvEmptyNoWait2DRobotCompositeSixPlanarDiskRandom", + # "EnvEmptyNoWait2DRobotCompositeNinePlanarDiskRandom" + ], + help="List of experiment instance names to run.") + + + args = parser.parse_args() + + proj_params = { + 'agents_max_speeds': args.agents_max_speeds, + 'rho': args.rho, + 'rho_factor': args.rho_factor, + 'alm_iteration': args.alm_iteration, + 'tolerance': args.tolerance, + 'projection_step': args.projection_step, + } + + stagger_start_time_dt = 0 + runtime_limit = args.runtime_limit + num_trials_per_combination = 1 + render_animation = True + + + + for sample_idx in range(args.start_index, args.end_index): + for instance_name in args.experiment_instance_names: + experiment_config = MultiAgentPlanningExperimentConfig() + + experiment_config.instance_idx = sample_idx + experiment_config.map_name = args.map_name + experiment_config.res_base_dir = args.save_path + + experiment_config.num_agents_l = [] + if "two" in instance_name.lower(): + experiment_config.num_agents_l = [2] + elif "three" in instance_name.lower(): + experiment_config.num_agents_l = [3] + init_traj4proj = pickle.load(open(f'../../init4proj_data/{map_name}_init4proj_agent_3.pkl', 'rb')) + elif "six" in instance_name.lower(): + experiment_config.num_agents_l = [6] + init_traj4proj = pickle.load(open(f'../../init4proj_data/{map_name}_init4proj_agent_6.pkl', 'rb')) + elif "nine" in instance_name.lower(): + experiment_config.num_agents_l = [9] + init_traj4proj = pickle.load(open(f'../../init4proj_data/{map_name}_init4proj_agent_9.pkl', 'rb')) + + + + experiment_config.instance_name = instance_name + experiment_config.init_traj4proj = init_traj4proj[sample_idx] + experiment_config.proj_params = proj_params + + experiment_config.stagger_start_time_dt = stagger_start_time_dt + experiment_config.multi_agent_planner_class_l = ["SMDComposite"] + experiment_config.single_agent_planner_class = "SMDEnsemble" + experiment_config.runtime_limit = runtime_limit + experiment_config.num_trials_per_combination = num_trials_per_combination + experiment_config.render_animation = render_animation + + run_multi_agent_experiment(experiment_config) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_certified_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_certified_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..8c2ffc4d73e2e8759d7f2f1a1c760aba78d5d263 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_certified_instances.py @@ -0,0 +1,108 @@ +"""Build ONE oracle-certified instance set shared by every method. + +Repairs two defects at once: + 1. Each runner sampled its own start/goal pairs, so methods solved DIFFERENT + problems -- unpaired, and contrary to this project's own protocol + (results/baselines_eval/RESULTS.md: "All methods plan the SAME held-out + instances (instances.npz)"). + 2. Nothing checked an instance was solvable, so SR=0 was ambiguous between + "the planner failed" and "no solution existed". + +Instances come from export_instances.py (which uses build_composite_reference + +get_test_instances -- the multi-agent path; rolling our own sampler gets the +single-agent robot and yields one point instead of N). OBS_SCALE is read at +env-construction time, so each map is exported in its own subprocess. +""" +import os, sys, json, argparse, subprocess, tempfile +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +# shelf 1.6 -> 1.2: at 1.6 only 14% of the workspace is free and the sampler cannot +# place 9+ disks at all ("Could not find a collision free configuration"). 1.2 admits +# 20/20 at N=3 and N=9. dense stays at its published 1.8; its ~5% admit rate is handled +# by over-sampling rather than by moving the geometry. +OBS = {'empty':1.0,'basic':2.2,'dense':1.8,'corridor':1.5,'room':1.45, + 'shelf':1.2,'corridorbay':1.15,'crossroom':1.1} +RR = {'empty':0.1} +VMAX_MED = {'empty':0.692,'basic':0.692,'dense':0.692,'room':0.783, + 'corridor':1.46,'corridorbay':1.46,'shelf':1.13,'crossroom':1.13} +SIZES=[3,6,9,12,15,18,21] + +def export_map(mp, sizes, n_inst, tmpdir): + env=dict(os.environ) + env.update(OBS_SCALE=str(OBS[mp]), ROBOT_R=str(RR.get(mp,0.05)), + SMD_N_TEST=str(n_inst), CUDA_VISIBLE_DEVICES='') + out=os.path.join(tmpdir,f'{mp}.npz') + tags=','.join(f'{mp}hard-{N}' for N in sizes) + r=subprocess.run([sys.executable, os.path.join(HERE,'export_instances.py'), + '--tags',tags,'--out',out], env=env, capture_output=True, text=True, cwd=HERE) + if not os.path.exists(out): + print(f" {mp}: export FAILED\n{(r.stderr or r.stdout)[-400:]}"); return None + return out + +def certify(mp, N, starts, goals, radius): + env=dict(os.environ); env.update(OBS_SCALE=str(OBS[mp]), ROBOT_R=str(RR.get(mp,0.05)), + CUDA_VISIBLE_DEVICES='') + code=f''' +import sys, json, numpy as np, torch +sys.path.insert(0,{HERE!r}) +from scenarios import get_scenario +import generate_via_pp as gp, feasibility_oracle as FO +S=np.array({starts.tolist()!r}); G=np.array({goals.tolist()!r}) +cfg=get_scenario("{mp}hard-{N}") +env_,robot,task=gp.build_reference(cfg,{{"device":"cpu","dtype":torch.float32}}) +out=[] +for i in range(S.shape[0]): + v=FO.certify(env_, {radius}, S[i], G[i], {VMAX_MED[mp]}, 5.0, 64) + out.append([bool(v["certified"]), v["status"]]) +print("J"+json.dumps(out)) +''' + r=subprocess.run([sys.executable,'-c',code],env=env,capture_output=True,text=True) + if 'J[' not in r.stdout: return None + return json.loads(r.stdout.split('J',1)[1] if r.stdout.strip().startswith('J') else + r.stdout[r.stdout.index('J['):][1:]) + +if __name__=='__main__': + ap=argparse.ArgumentParser() + ap.add_argument('--maps',default=','.join(OBS)); ap.add_argument('--sizes',default=','.join(map(str,SIZES))) + ap.add_argument('--instances',type=int,default=20) + ap.add_argument('--out',default=os.path.join(os.environ.get('RB_RUNS', '.'), 'instances_hard.npz')) + a=ap.parse_args(); sizes=[int(x) for x in a.sizes.split(',')] + blob={}; meta=[] + with tempfile.TemporaryDirectory() as td: + for mp in a.maps.split(','): + p=export_map(mp,sizes,a.instances,td) + if not p: continue + z=np.load(p,allow_pickle=True) + mts={m['tag']:m for m in z['meta']} + for N in sizes: + tag=f'{mp}hard-{N}' + if f'{tag}/starts' not in z.files: print(f" {tag}: absent"); continue + S=z[f'{tag}/starts'].astype(np.float32); G=z[f'{tag}/goals'].astype(np.float32) + rad=float(mts[tag]['radius']) + c=certify(mp,N,S,G,rad) + if c is None: cert=[True]*S.shape[0]; stat=['unverified']*S.shape[0] + else: stat=[x[1] for x in c]; cert=[x[1]!='infeasible' for x in c] + # ADMIT everything except provably-infeasible (layer 1: goal unreachable + # or beyond the v_max*duration budget). 'unverified' means only that the + # oracle's layer-2 prioritised search -- which is INCOMPLETE and degrades + # with agent count -- found no plan; excluding those would keep only the + # instances the oracle happens to solve, which correlates with easiness and + # would inflate every method's score. Measured: room N=3 1/4 certified, + # N=12 0/4, with ZERO infeasible. + k=f'{mp}/N{N}' + blob[k+'/starts']=S; blob[k+'/goals']=G + blob[k+'/certified']=np.array(cert,dtype=bool) + meta.append(dict(map=mp,N=N,n=int(S.shape[0]),admitted=int(sum(cert)), + certified=int(sum(1 for x in stat if x=='certified')), + infeasible=int(sum(1 for x in stat if x=='infeasible')), + radius=rad,status=stat)) + nc=sum(1 for x in stat if x=='certified'); ni=sum(1 for x in stat if x=='infeasible') + print(f" {mp:>12} N={N:<3} admitted {sum(cert):>2}/{S.shape[0]:<2} " + f"(certified {nc}, infeasible {ni})") + os.makedirs(os.path.dirname(a.out),exist_ok=True) + np.savez(a.out,meta=np.array(json.dumps(meta),dtype=object),**blob) + t=sum(m['n'] for m in meta); adm=sum(m['admitted'] for m in meta) + c=sum(m['certified'] for m in meta); inf=sum(m['infeasible'] for m in meta) + print(f"\nwrote {a.out}: {len(meta)} cells, {adm}/{t} admitted " + f"({c} oracle-certified, {inf} excluded as infeasible)") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_clean_datasets.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_clean_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..c578a2be4637a61b55c06e45fd3e7ac46b959d5d --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_clean_datasets.py @@ -0,0 +1,131 @@ +""" +Builds `clean_` training sets containing ONLY trajectories that pass the +same feasibility check used at evaluation time (evaluate_smd_all.check_paths_feasible_batch: +2*robot_radius inter-robot contact distance + real obstacle/wall SDF). + +Why: measured 2026-08-12, the demonstrations SMD actually trains on are majority-invalid -- +basic-6 35.3% collision-free, room-6 33.9%, shelf-6 45.1%. The PP/CBS demonstrations we +generated are clean (97.5% / 98.0% on basic-6), but merge_cbs_datasets.py concatenates them +with the 668 dirty originals, which outnumber them 668:390, so the merged set lands at ~58% +clean and most of the training signal is spent imitating collisions. That is the most likely +reason the CBS pilot, the 500k-step run and the merged datasets all plateaued identically. + +Trajectories are filtered individually *within* each task dir's trajs-free.pt (each file holds +several); a task dir contributing zero clean trajectories is dropped entirely. Sources are +pooled (original + pp + cbs) so the clean set is as large as possible. + +Usage: + python build_clean_datasets.py [ ...] + python build_clean_datasets.py --all +""" +import os +import sys +import glob +import shutil +import argparse + +import torch +import yaml + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) +sys.path.insert(0, os.path.dirname(__file__)) +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +from scenarios import SCENARIOS, get_scenario +from evaluate_smd_all import check_paths_feasible_batch +from smd.trainer import get_dataset + +DATA_DIR = os.path.join(REPO_ROOT, 'data_trajectories') +SOURCE_PREFIXES = ['', 'pp_', 'cbs_'] + + +def _task_dirs(subdir): + d = os.path.join(DATA_DIR, subdir) + if not os.path.isdir(d): + return [] + return sorted([x for x in glob.glob(os.path.join(d, '*')) if os.path.isdir(x)]) + + +def run_scenario(cfg): + tag = cfg['tag'] + num_agents = cfg['num_agents'] + out_subdir = f"clean_{cfg['dataset_subdir']}" + out_dir = os.path.join(DATA_DIR, out_subdir) + + # Reference env/robot -- must match the training-time construction exactly so the + # feasibility verdict here is the same one evaluation will apply later. + tensor_args = {'device': 'cpu', 'dtype': torch.float32} + train_subset, _, _, _ = get_dataset( + dataset_class='TrajectoryDataset', + include_velocity=True, + dataset_subdir=cfg['dataset_subdir'], + batch_size=4, + results_dir=os.path.join('/tmp', f'_clean_{tag}'), + save_indices=False, + tensor_args=tensor_args, + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + instance_idx=0, + map_name=cfg['map_name'], + ) + ds = train_subset.dataset + env, robot = ds.env, ds.robot + + if os.path.isdir(out_dir): + shutil.rmtree(out_dir) + os.makedirs(out_dir, exist_ok=True) + + idx = 0 + per_source = {} + for pref in SOURCE_PREFIXES: + src_subdir = pref + cfg['dataset_subdir'] + kept = total = 0 + for d in _task_dirs(src_subdir): + p = os.path.join(d, 'trajs-free.pt') + if not os.path.exists(p): + continue + trajs = torch.load(p, map_location='cpu') # (n, H, 4*num_agents) + pos = trajs[..., :2 * num_agents].reshape(trajs.shape[0], trajs.shape[1], num_agents, 2) + mask = check_paths_feasible_batch(pos.float(), robot.radius, env) + total += mask.numel() + if not bool(mask.any()): + continue # no clean trajectory here + clean = trajs[mask] + kept += int(mask.sum()) + + task_dir = os.path.join(out_dir, str(idx)) + os.makedirs(task_dir, exist_ok=True) + torch.save(clean, os.path.join(task_dir, 'trajs-free.pt')) + for fname in ('args.yaml', 'metadata.yaml'): + src_f = os.path.join(d, fname) + if os.path.exists(src_f): + shutil.copy(src_f, os.path.join(task_dir, fname)) + meta_f = os.path.join(task_dir, 'metadata.yaml') + if os.path.exists(meta_f): + with open(meta_f) as f: + meta = yaml.safe_load(f) or {} + meta['num_trajectories_free'] = int(mask.sum()) + with open(meta_f, 'w') as f: + yaml.dump(meta, f) + idx += 1 + per_source[pref or 'original'] = (kept, total) + + kept_all = sum(k for k, _ in per_source.values()) + total_all = sum(t for _, t in per_source.values()) + parts = " ".join(f"{n}={k}/{t}" for n, (k, t) in per_source.items()) + print(f"[{tag}] {parts}") + print(f"[{tag}] -> {out_subdir}: {kept_all}/{total_all} trajectories kept " + f"({kept_all / max(total_all, 1):.1%}) across {idx} task dirs") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tags', nargs='*', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + tags = [c['tag'] for c in SCENARIOS] if args.all else args.tags + if not tags: + parser.error("provide scenario tag(s) or --all") + for t in tags: + run_scenario(get_scenario(t)) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_robotarium_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_robotarium_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..6a6443743531fe0c4bcaa4887c8be0e0725a69b7 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_robotarium_instances.py @@ -0,0 +1,104 @@ +"""Robotarium instance sets: antipodal swaps on a 0.80 m circle. + +The testbed's own benchmark problem, not our random draws: agent i starts at angle +theta + 2*pi*i/N on a circle of radius 0.80 m and must reach the antipode. Every +straight-line path crosses the centre, so it is the hardest case for inter-robot +coupling, and an instance is fully determined by the rotation theta -- a sample of +instances is a sample of rotations. + +Admission, in the order a rejected instance would otherwise fail: + * neighbours on the circle at least ROBOTARIUM_SEPARATION apart (the testbed plans + at 0.25 m; its hardware collision threshold is 0.185 m), + * every endpoint clear of obstacles by 2.1 * robot radius, the planners' own + validator margin (mmd/common/multi_agent_utils.is_multi_agent_start_goal_states_valid), + * layer-1 feasibility at the evaluated v_max, same oracle the hard suite uses. + +Geometry is read from scenarios.py, never typed here: a set built at a scale the port +is not evaluated at is exactly the defect that cost the main suite two map-columns. +""" +import os, sys, json, argparse +import numpy as np, torch + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +CIRCLE_R = 0.80 # m, the testbed's start/goal circle +ROBOTARIUM_SEPARATION = 0.25 # m, planned separation (hardware threshold is 0.185) +ENDPOINT_CLEARANCE = 2.1 # x robot radius, the planners' validator margin + +# The same 64-waypoint trajectory executed over the testbed's 30 s instead of the +# harness's 5 s is six times slower on hardware, so the testbed's 0.08 m/s planned peak +# is 0.48 harness units and its 0.2 m/s hard cap is 1.20. +RBT_VMAX = {'planned': 0.48, 'mid': 0.72, 'cap': 1.20} + + +def build(mp, N, want, vmax, seed=0, max_rot=4000): + from scenarios import get_scenario + import generate_via_pp as gp, feasibility_oracle as FO + ta = {'device': 'cpu', 'dtype': torch.float32} + cfg = get_scenario(f'{mp}rbt-{N}') + env_, robot, _ = gp.build_reference(cfg, ta) + r = float(robot.radius) + + def sdf(q): + with torch.no_grad(): + return env_.compute_sdf(torch.as_tensor(q, dtype=torch.float32).reshape(1, -1, 2)).reshape(-1) + + gap = 2 * CIRCLE_R * np.sin(np.pi / N) # neighbour spacing on the circle + if gap < ROBOTARIUM_SEPARATION: + return dict(starts=[], goals=[], status=[], rotations=[], radius=r, gap=float(gap), + reason=f'N={N} puts neighbours {gap:.3f} m apart, below the ' + f'{ROBOTARIUM_SEPARATION} m planned separation') + rng = np.random.default_rng(seed) + S, G, ST, TH = [], [], [], [] + tried = obsfail = 0 + while len(S) < want and tried < max_rot: + tried += 1 + th = float(rng.uniform(0, 2 * np.pi / N)) # rotations beyond 2pi/N repeat the set + ang = th + 2 * np.pi * np.arange(N) / N + s = np.stack([CIRCLE_R * np.cos(ang), CIRCLE_R * np.sin(ang)], 1).astype(np.float32) + g = -s # antipode + if float(min(sdf(s).min(), sdf(g).min())) <= ENDPOINT_CLEARANCE * r: + obsfail += 1 + continue + v = FO.certify(env_, r, s, g, vmax, 5.0, 64, run_layer2=False) + if v['status'] == 'infeasible': + continue + S.append(s.tolist()); G.append(g.tolist()); ST.append(v['status']); TH.append(th) + return dict(starts=S, goals=G, status=ST, rotations=TH, radius=r, gap=float(gap), + tried=tried, obsfail=obsfail) + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--maps', default='corridor,corridorbay,crossroom,room,shelf') + ap.add_argument('--sizes', default='3') + ap.add_argument('--want', type=int, default=5) + ap.add_argument('--regime', default='planned', choices=list(RBT_VMAX)) + ap.add_argument('--out', default=os.path.join(os.environ.get('RB_RUNS', '.'), 'instances_robotarium.npz')) + a = ap.parse_args() + + from scenarios import SCENARIOS + byname = {c['scenario']: c for c in SCENARIOS} + blob, meta = {}, [] + for mp in a.maps.split(','): + cfg = byname[mp + 'rbt'] + os.environ['OBS_SCALE'] = str(cfg['obs_scale']) + os.environ['ROBOT_R'] = str(cfg['robot_r']) + for N in [int(x) for x in a.sizes.split(',')]: + d = build(mp, N, a.want, RBT_VMAX[a.regime]) + k = f'{mp}rbt/N{N}' + if not d['starts']: + print(f" {mp:>12} N={N:<3} NONE {d.get('reason', '')}") + continue + blob[f'{k}/starts'] = np.asarray(d['starts'], np.float32) + blob[f'{k}/goals'] = np.asarray(d['goals'], np.float32) + meta.append(dict(key=k, n=len(d['starts']), gap=d['gap'], radius=d['radius'], + obs_scale=cfg['obs_scale'], vmax=RBT_VMAX[a.regime], + tried=d['tried'], obstacle_rejects=d['obsfail'])) + print(f" {mp:>12} N={N:<3} {len(d['starts'])}/{a.want} rotations " + f"(tried {d['tried']}, obstacle rejects {d['obsfail']}), " + f"gap {d['gap']:.3f} m, r {d['radius']}") + blob['meta'] = json.dumps(meta) + np.savez(a.out, **blob) + print(f"wrote {a.out}: {len(meta)} cells") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_shared_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_shared_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..aad03040eab429c97fab55452dc05a961697676f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/build_shared_instances.py @@ -0,0 +1,96 @@ +"""Build the ONE start/goal set every method plans, drawn in the evaluated env. + +Supersedes build_certified_instances.py, which had three defects: + * its own OBS/ROBOT_R tables, free to drift from scenarios.py (shelf sat at 1.2 + there while scenarios.py said 1.6) -- scales are now read from scenarios.py; + * it took whatever export_instances.py produced (exactly 20) and then filtered, + so any map admitting under 100% could never reach 20 usable instances; this + draws until 20 are admitted; and + * it admitted against the MEDIUM v_max, while the tables also report a slow + column whose travel budget is up to 18% smaller. Admission now uses the SLOW + v_max, so one instance set is valid for all three velocity columns. + +Admission = not layer-1 'infeasible' (goal unreachable on the free-space grid, or +geodesic longer than v_max*duration). Clearance is SDF > robot_radius, the same +test check_paths_feasible_batch applies; obstacle_cutoff_margin is a planner cost +term and certifying against it rejected instances the scorer accepts. +""" +import os, sys, json, subprocess, argparse +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from scenarios import HARD_OBS_SCALE, HARD_ROBOT_R # single source of truth +from evaluate_smd_C import VELOCITY_VMAX + +SLOW = {m: v[0] for m, v in VELOCITY_VMAX.items()} # tightest travel budget +SIZES = [3, 6, 9, 12, 15, 18, 21] +MAPS = ['empty','basic','dense','corridor','room','shelf','corridorbay','crossroom'] + +WORKER = r''' +import os, sys, json +import numpy as np, torch +sys.path.insert(0, %(here)r) +import sample_admitted_instances as SAI +mp, want, vmax = %(mp)r, %(want)d, %(vmax)f +out = {} +for N in %(sizes)r: + try: + d = SAI.run(mp, N, want, vmax, %(draws)d) + except Exception as e: + print(f" {mp:>12} N={N:<3} FAILED {type(e).__name__}: {e}", flush=True); continue + out[str(N)] = d + print(f" {mp:>12} N={N:<3} admitted {len(d['starts'])}/{want} " + f"in {d['draws']} draws (place-fail {d['placefail']})", flush=True) +json.dump(out, open(%(tf)r, 'w')) +''' + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--maps', default=','.join(MAPS)) + ap.add_argument('--sizes', default=','.join(map(str, SIZES))) + ap.add_argument('--want', type=int, default=20) + ap.add_argument('--max-draws', type=int, default=20000) + ap.add_argument('--scratch', default='/' + '17351827-0327-40c6-bf09-172d6047c948/scratchpad') + ap.add_argument('--out', default=os.path.join(os.environ.get('RB_RUNS', '.'), 'instances_shared.npz')) + a = ap.parse_args() + maps = a.maps.split(','); sizes = [int(x) for x in a.sizes.split(',')] + os.makedirs(a.scratch, exist_ok=True) + + procs = [] + for mp in maps: + tf = os.path.join(a.scratch, f'shared_{mp}.json') + code = WORKER % dict(here=HERE, mp=mp, want=a.want, vmax=SLOW[mp], + sizes=sizes, draws=a.max_draws, tf=tf) + env = dict(os.environ) + env.update(OBS_SCALE=str(HARD_OBS_SCALE[mp]), + ROBOT_R=str(HARD_ROBOT_R.get(mp, 0.05)), + CUDA_VISIBLE_DEVICES='', OMP_NUM_THREADS='2') + lg = open(os.path.join(a.scratch, f'shared_{mp}.log'), 'w') + procs.append((mp, tf, lg, + subprocess.Popen([sys.executable, '-u', '-c', code], env=env, + stdout=lg, stderr=subprocess.STDOUT, cwd=HERE))) + blob, meta = {}, [] + for mp, tf, lg, p in procs: + p.wait(); lg.close() + if not os.path.exists(tf): + print(f" {mp}: produced nothing -- see {lg.name}"); continue + for N, d in json.load(open(tf)).items(): + S = np.asarray(d['starts'], dtype=np.float32) + G = np.asarray(d['goals'], dtype=np.float32) + k = f'{mp}/N{N}' + blob[k+'/starts'] = S; blob[k+'/goals'] = G + blob[k+'/certified'] = np.array([s == 'certified' for s in d['status']], bool) + meta.append(dict(map=mp, N=int(N), n=int(S.shape[0]), radius=d['radius'], + obs_scale=HARD_OBS_SCALE[mp], vmax_admit=SLOW[mp], + draws=d['draws'], status=d['status'])) + os.makedirs(os.path.dirname(a.out), exist_ok=True) + np.savez(a.out, meta=np.array(json.dumps(meta), dtype=object), **blob) + n = sum(m['n'] for m in meta) + short = [f"{m['map']}/N{m['N']}={m['n']}" for m in meta if m['n'] < a.want] + print(f"\nwrote {a.out}: {len(meta)} cells, {n} instances") + print(" short of target: " + (', '.join(short) if short else "none")) + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_smd_paper_table.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_smd_paper_table.py new file mode 100644 index 0000000000000000000000000000000000000000..c100e8b6a27c8e5dc3b6f9ebef9e9820dbde6458 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_smd_paper_table.py @@ -0,0 +1,228 @@ +""" +Once all 12 target cells (Empty/Highways/Conveyor/Drop-Region x {3,6,9}) have +eval_results.json for the merged SMD models, this: + 1. Prints a per-cell table matching the paper's tab:app-mmd format. + 2. Computes the summary-row numbers for tab:multirobot's SMD row. + 3. Patches /paper/text_new.tex directly: + - inserts a new tab:app-smd table (mirroring tab:app-mmd) right after it + - replaces the SMD row + caption note in tab:multirobot + +Only touches SMD numbers -- never the paper's own-method (Algorithms 1-2) +rows, per instruction. + +Usage: + python compile_smd_paper_table.py # print + patch if all 12 done + python compile_smd_paper_table.py --dry-run # print only, don't touch the .tex +""" +import argparse +import json +import os +import re + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +MODELS_DIR = os.path.join(REPO_ROOT, 'data_trained_models') +# The authoritative paper is the Overleaf clone, NOT paper/text_new.tex +# (that one is a local staging draft). Override with --paper-tex. +PAPER_TEX = '/actual_paper/MP_main.tex' + +# (scenario prefix, display name) -- in the order the paper's tab:app-mmd lists them +CELLS = [ + ('empty', 'Empty'), + ('room', 'Highways'), + ('corridor', 'Conveyor'), + ('shelf', 'Drop-Region'), +] +NS = [3, 6, 9] + +ROBOT_CLASS_BY_N = {3: 'RobotCompositeThreePlanarDisk', 6: 'RobotCompositeSixPlanarDisk', + 9: 'RobotCompositeNinePlanarDisk'} +ENV_ID_BY_SCENARIO = {'empty': 'EnvEmptyNoWait2D', 'room': 'EnvHighways2D', + 'corridor': 'EnvConveyor2D', 'shelf': 'EnvDropRegion2D'} + + +def _subdir(scenario, n): + env_id = ENV_ID_BY_SCENARIO[scenario] + robot_id = ROBOT_CLASS_BY_N[n] + if scenario == 'empty' and n == 3: + return f'{env_id}-{robot_id}' + return f'{scenario}_{env_id}-{robot_id}' + + +def model_dir_for(scenario, n): + return os.path.join(MODELS_DIR, f'merged_{_subdir(scenario, n)}') + + +def n_demos_for(scenario, n): + """Size of the merged training set this cell's model was trained on. + Reported alongside the results because it varies substantially across + cells (the harder geometries yield fewer collision-free demonstrations + per unit of generation time), and the weakest cells are also the + smallest-data ones -- without this column the two effects are + indistinguishable to a reader.""" + d = os.path.join(REPO_ROOT, 'data_trajectories', f'merged_{_subdir(scenario, n)}') + if not os.path.isdir(d): + return None + return len([x for x in os.listdir(d) if os.path.isdir(os.path.join(d, x))]) + + +def load_all(): + rows = [] + missing = [] + for scenario, display in CELLS: + for n in NS: + d = model_dir_for(scenario, n) + p = os.path.join(d, 'eval_results.json') + if not os.path.exists(p): + missing.append((display, n, p)) + continue + with open(p) as f: + data = json.load(f) + rows.append(dict(env=display, n=n, success_rate=data['success_rate'], + any_of_n=data.get('any_of_n_success_rate'), + sample_feas=data.get('sample_feasibility_rate'), + path_length=data['avg_path_length_over_success'], + n_demos=n_demos_for(scenario, n))) + return rows, missing + + +def make_percell_table(rows): + lines = [] + lines.append(r'\begin{table}[h]') + lines.append(r'\caption{Multi-robot reproduction, per cell. SMD (augmented-Lagrangian ' + r'projection), $n=10$ instances per cell with $16$ sampled trajectories ' + r'each. Reproduced end to end (data generation, training, evaluation) ' + "under this paper's own protocol, not copied from the SMD paper. Note " + r'the smaller instance count than the MMD sweep in ' + r"Table~\ref{tab:app-mmd} ($n=100$): SMD's reference evaluation harness " + r'fixes $10$ test instances per cell, so success rates here are quantised ' + r'to $10\%$ and carry correspondingly wide confidence intervals.}') + lines.append(r'\label{tab:app-smd}') + lines.append(r'\begin{center}') + lines.append(r'\footnotesize') + lines.append(r'\begin{tabular}{lccccc}') + lines.append(r'\toprule') + lines.append(r'Env. & $N_a$ & Train demos & Success & Any-of-16 & Path length \\') + lines.append(r'\midrule') + for r in rows: + pl = '---' if (r['path_length'] != r['path_length']) else f"{r['path_length']:.3f}" + anyn = '---' if r['any_of_n'] is None else f"{r['any_of_n']*100:.0f}\\%" + nd = '---' if r['n_demos'] is None else str(r['n_demos']) + lines.append(f"{r['env']} & {r['n']} & {nd} & {r['success_rate']*100:.0f}\\% & " + f"{anyn} & {pl} \\\\") + lines.append(r'\bottomrule') + lines.append(r'\end{tabular}') + lines.append(r'\end{center}') + lines.append(r'\end{table}') + lines.append('') + lines.append(r'Planning time and per-waypoint collision counts are not reported for SMD: ' + "the reference implementation's evaluation harness does not instrument " + r'either, and re-instrumenting it was out of scope for this reproduction ' + r'pass.') + lines.append('') + lines.append(r'\textbf{These numbers should not be read as an upper bound on SMD.} Two ' + r'confounds run in the same direction. First, training-set size is not held ' + r'constant across cells: the demonstration generator yields far fewer ' + r'collision-free trajectories on the tighter geometries, and the weakest ' + r'cells here are also the smallest-data ones (the two $0\%$ Conveyor cells ' + r'were trained on $240$ and $146$ demonstrations against $\approx390$ ' + r'elsewhere), so low success and low data cannot be separated from these ' + "runs alone. Second, we did not tune SMD's augmented-Lagrangian " + r'hyperparameters per cell, whereas the original authors report a ' + r'sensitivity sweep over the penalty scaling $\zeta$. The comparison we draw ' + r'in \S\ref{sec:exp-multirobot} is therefore against SMD \emph{as ' + r'reproduced under a fixed, untuned, equal-effort protocol}, not against ' + r'SMD at its published best, for which \citet{liang2025smd} report ' + r'success rates near $100\%$ on the corresponding maps.') + return '\n'.join(lines) + + +def make_summary_row(rows): + n_cells = len(rows) + n_full_success = sum(1 for r in rows if r['success_rate'] >= 0.999) + # Cells with 0% success have NaN path length (mean over an empty set of + # successful runs) -- must filter, or min/max propagate NaN into the paper. + lengths = [r['path_length'] for r in rows + if r['path_length'] == r['path_length']] # drops NaN + span = f"{min(lengths):.3f}--{max(lengths):.3f}" if lengths else '---' + mean_success = sum(r['success_rate'] for r in rows) / max(n_cells, 1) + return (f"SMD & {mean_success*100:.0f}\\% mean " + f"({n_full_success}/{n_cells} cells at 100\\%) & {span} & --- & " + f"tolerance-bounded \\\\") + + +def patch_tex(percell_table, summary_row): + with open(PAPER_TEX) as f: + text = f.read() + + if 'tab:app-smd' in text: + print('tab:app-smd already present in text_new.tex -- not inserting a duplicate. ' + 'Remove the existing block manually if you want to regenerate it.') + else: + anchor = (r'Reported collision counts are evaluated at the discretized waypoints each' + '\n' "method's own conflict search operates on, not continuously; they are" + '\n' r'therefore not comparable to the continuous certificate of' + '\n' r'\S\ref{sec:alg1}.') + if anchor not in text: + raise RuntimeError('Could not find the anchor paragraph after tab:app-mmd -- ' + 'the file may have changed. Aborting patch (nothing written).') + text = text.replace(anchor, anchor + '\n\n' + percell_table, 1) + + # Replace the SMD row in tab:multirobot. Matches the line starting "SMD &" up to + # the row-ending "\\" (the row is a known single line in the current draft). + smd_row_pattern = re.compile(r'^SMD & .*\\\\$', re.MULTILINE) + if not smd_row_pattern.search(text): + raise RuntimeError('Could not find the existing "SMD & ..." row in tab:multirobot -- ' + 'aborting patch (nothing written).') + # NOTE: pass a *function* as the replacement, not the string itself. + # re.sub processes backslash escapes in a string replacement, so the + # LaTeX row terminator "\\" would silently become a single "\" and break + # the table. A function replacement is inserted verbatim. + text = smd_row_pattern.sub(lambda _m: summary_row, text, count=1) + + # Update the caption note that says the SMD sweep was partial. + text = text.replace( + "MMD is\ncomplete across all cells, SMD's sweep was partial at draft time.", + "MMD and SMD are\nboth complete across all 12 cells (per-cell numbers in " + "Appendix~\\ref{app:additional},\nTables~\\ref{tab:app-mmd} and \\ref{tab:app-smd})." + ) + + with open(PAPER_TEX, 'w') as f: + f.write(text) + print(f'Patched {PAPER_TEX}') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--paper-tex', default=None, + help='override the target .tex path') + args = parser.parse_args() + + global PAPER_TEX + if args.paper_tex: + PAPER_TEX = args.paper_tex + + rows, missing = load_all() + print(f'{len(rows)}/12 cells have eval results.') + if missing: + print('Missing:') + for display, n, p in missing: + print(f' {display} x{n} (expected at {p})') + + if rows: + print('\n--- per-cell table ---') + percell_table = make_percell_table(rows) + print(percell_table) + + if len(rows) == 12: + summary_row = make_summary_row(rows) + print('\n--- tab:multirobot summary row ---') + print(summary_row) + if not args.dry_run: + patch_tex(percell_table, summary_row) + else: + print('\nNot all 12 cells are ready yet -- not patching the paper.') + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_tables_3_6.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_tables_3_6.py new file mode 100644 index 0000000000000000000000000000000000000000..f3940b30e465e643e33e0f4c1b1631960d27e1d3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/compile_tables_3_6.py @@ -0,0 +1,131 @@ +""" +Compiles whatever cells exist into SMD Appendix C Table 3-6 layout +(S / L / A / C per method), printing both a readable table and LaTeX. + +Reads, per cell: + eval_results.json -> the SMD ("Ours" in their paper) column + eval_results_dm.json -> the DM column (same weights, projection disabled) + +Columns we cannot fill are printed as "--" rather than guessed: + EECBS -- no implementation exists in these repos (see run_tables_3_6.sh). + MPD -- the local MPD reproduction is single-robot; not wired up here. + MMD -- lives in a separate harness (evaluate_mmd_all.py); merged in only + where an mmd_eval_results.json happens to exist next to the cell. +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from scenarios import SCENARIOS, get_scenario # noqa: E402 + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +MODELS = os.path.join(REPO_ROOT, 'data_trained_models') + +# Table 3 = random maps, Table 4 = practical maps, Table 6 = Shaoul maps. +# This repo's corridor/room/shelf stand in for Conveyor/Highways/Drop-Region, +# so Tables 4 and 6 collapse onto the same environments here; that overlap is +# stated rather than duplicated. +GROUPS = [ + ('Table 3 -- random maps', [ + ('Empty', ['empty-3', 'empty-6', 'empty-9']), + ('Basic', ['basic-3', 'basic-6', 'basic-9']), + ('Dense', ['dense-3', 'dense-6', 'dense-9']), + ]), + ('Tables 4/6 -- practical maps (this repo\'s stand-in geometry)', [ + ('Conveyor/Corridor', ['corridor-2', 'corridor-3', 'corridor-6', 'corridor-9']), + ('Highways/Room', ['room-3', 'room-6', 'room-9']), + ('Drop-Region/Shelf', ['shelf-3', 'shelf-6', 'shelf-9']), + ]), +] + + +def load(tag, suffix=''): + try: + mid = get_scenario(tag)['model_id'] + except KeyError: + return None + p = os.path.join(MODELS, f'merged_{mid}', f'eval_results{suffix}.json') + if not os.path.exists(p): + return None + try: + with open(p) as f: + return json.load(f) + except Exception: + return None + + +def fmt(d, key, nd=4): + if d is None or d.get(key) is None: + return '--' + v = d[key] + if v != v: # NaN + return '--' + return f'{v:.{nd}f}' + + +def main(): + print('=' * 78) + print('SMD arXiv:2502.03607 Appendix C, Tables 3-6 -- our reproduction') + print('=' * 78) + print('S = success rate, L = path length, A = mean |accel|, C = collision ratio') + print('EECBS and MPD columns are unavailable -- see run_tables_3_6.sh header.') + print() + + n_smd = n_dm = 0 + for gname, rows in GROUPS: + print() + print(gname) + print('-' * 78) + print(f"{'Map':<20}{'N':>3} {'metric':<8}{'DM':>10}{'SMD(Ours)':>12}") + print('-' * 78) + for label, tags in rows: + for tag in tags: + na = tag.rsplit('-', 1)[1] + smd = load(tag) + dm = load(tag, '_dm') + if smd: + n_smd += 1 + if dm: + n_dm += 1 + if smd is None and dm is None: + print(f'{label:<20}{na:>3} {"(no results yet)":<8}') + continue + for key, mkey, nd in [('S', 'success_rate', 4), + ('L', 'avg_path_length_over_success', 4), + ('A', 'metric_A', 4), + ('C', 'metric_C', 4)]: + lab = f'{label:<20}{na:>3} ' if key == 'S' else ' ' * 25 + print(f'{lab}{key:<8}{fmt(dm, mkey, nd):>10}{fmt(smd, mkey, nd):>12}') + print() + print('=' * 78) + print(f'coverage: SMD {n_smd} cells, DM {n_dm} cells') + + # The headline diagnostic: projection on vs off, same weights. + print() + print('Projection ablation (SMD = projection ON, DM = projection OFF):') + print('-' * 78) + worse = same = better = 0 + for cfg in SCENARIOS: + tag = cfg['tag'] + smd, dm = load(tag), load(tag, '_dm') + if not (smd and dm): + continue + a, b = smd['success_rate'], dm['success_rate'] + flag = '' + if b > a + 1e-9: + worse += 1 + flag = ' <-- projection HURTS' + elif abs(a - b) < 1e-9: + same += 1 + else: + better += 1 + print(f' {tag:<14} SMD {a:5.2f} DM {b:5.2f}{flag}') + print(f'\n projection helps: {better} cells | no change: {same} | hurts: {worse}') + if worse > better: + print('\n *** Projection is net-harmful in this reproduction. Treat the SMD') + print(' numbers as a property of THIS configuration, not of the method. ***') + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/derive_velocity_regimes.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/derive_velocity_regimes.py new file mode 100644 index 0000000000000000000000000000000000000000..16db0152841161fc2335ba6509193efd7f1a4f6c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/derive_velocity_regimes.py @@ -0,0 +1,55 @@ +"""SUPERSEDED -- do not use to set velocity limits. + +Kept only as the record of a rejected approach. Deriving v_max from our own +demonstrations is circular, and on corridorbay the 90th percentile gave v_max 0.378, +whose reach over the 5s horizon is 1.89 vs a workspace diagonal of 2.83 -- robots +could not cross the map, so failures would measure the cap, not the planner. +Maps without a paper counterpart now inherit their closest reference map's triple; +see VELOCITY_VMAX in evaluate_smd_C.py. + +Still useful for INSPECTING a dataset's speed distribution. +Derive test-time velocity limits for maps DiRecT Tables 7-10 have no counterpart for. + +The paper sets v_max per map from the data. Where it had no reference value (PushT, +Appendix D.4) it used "test-time velocity limits corresponding to the 90% quantile in +the original work". We apply that same rule to corridorbay and crossroom: + + Low = 90th-percentile per-step speed over the map's single-agent demonstrations + Med = 1.21 x Low the Low:Med:High ratio the four reference maps share + High = 1.44 x Low (Highways 1.00:1.21:1.36, Conveyor 1.00:1.21:1.45, + Drop-Region 1.00:1.22:1.44; Empty is nearly flat and + is excluded from the ratio as an outlier) + +Reports v_max in the paper's units (workspace/second), i.e. per-step speed / dt. +""" +import os, sys, glob, torch, numpy as np + +TRAJECTORY_DURATION = 5.0 +RATIO = (1.00, 1.21, 1.44) + + +def derive(dataset_dir, n_support_points=64, q=90.0): + dt = TRAJECTORY_DURATION / n_support_points + steps = [] + for f in sorted(glob.glob(os.path.join(dataset_dir, '*', 'trajs-free.pt'))): + t = torch.load(f, weights_only=False) + pos = t[..., :2].detach().cpu().numpy() # (n_traj, H, 2) + d = np.linalg.norm(np.diff(pos, axis=1), axis=-1) # per-step displacement + steps.append(d.ravel()) + if not steps: + raise SystemExit(f"no trajs-free.pt under {dataset_dir}") + steps = np.concatenate(steps) + low_step = float(np.percentile(steps, q)) + print(f" {len(steps):,} per-step displacements median {np.median(steps):.5f} " + f"p{q:.0f} {low_step:.5f}") + trip_step = tuple(low_step * r for r in RATIO) + trip_vmax = tuple(v / dt for v in trip_step) + print(f" per-step : {trip_step[0]:.5f} / {trip_step[1]:.5f} / {trip_step[2]:.5f}") + print(f" v_max : {trip_vmax[0]:.3f} / {trip_vmax[1]:.3f} / {trip_vmax[2]:.3f}") + return trip_vmax + + +if __name__ == '__main__': + for d in sys.argv[1:]: + print(f"\n{os.path.basename(d)}") + derive(d) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_mmd_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_mmd_all.py new file mode 100644 index 0000000000000000000000000000000000000000..0d2799f05947e34cd66e9f4f9ebde06a910e847f --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_mmd_all.py @@ -0,0 +1,219 @@ +""" +Evaluates MMD (Shaoul et al. 2025, arXiv:2410.03072) as reproduced by this repo's +own CBS/XECBS implementation combined with our trained single-agent SMD diffusion +planners -- on the SAME held-out test instances and feasibility criterion as our +composite-SMD evaluation (evaluate_smd_all.py / evaluate_smd_pp_all.py / +evaluate_smd_merged_all.py). + +This needs no new training: MMD's whole idea is that multi-robot capability comes +from combining a *single-robot* diffusion planner with search-based coordination +(CBS-family constraint placement) at inference time, not from training a joint/ +composite model. We already trained the 6 single-agent models +(train_singleagent_all.py) and the repo already has a full CBS implementation +(smd/planners/multi_agent/cbs.py, unmodified). + +Purpose: a calibration control. If MMD also falls well short of ITS OWN paper's +reported numbers under our infrastructure (same reduced n_samples, same test +instances, same tolerances), that points to environment/tolerance/sampling-budget +factors common to both methods rather than something SMD-specific -- versus if +MMD matches its paper closely while SMD doesn't, that's evidence of an SMD-specific +gap in our retraining pipeline. + +We use XECBS (is_xcbs=True, is_ecbs=True), matching this repo's own example script's +(scripts/inference/inference_multi_agent.py) default/showcased variant -- the MMD +paper describes five CBS-family variants and doesn't specify in the SMD paper which +one its baseline numbers came from. + +Usage: + python evaluate_mmd_all.py + python evaluate_mmd_all.py --all +""" +import os +import time +import sys +import json +import argparse + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask +from smd.planners.multi_agent import CBS +from smd.common.constraints import MultiPointConstraint +from smd.common.conflicts import PointConflict +from torch_robotics.torch_utils.torch_utils import get_torch_device + +from scenarios import SCENARIOS, get_scenario +from generate_singleagent_data import SCENARIOS as SA_SCENARIOS, ROBOT_ID as SA_ROBOT_ID +from generate_via_pp import get_shared, make_light_smd, build_reference, SCENARIO_TO_SA_KEY +import evaluate_smd_all as _esa +from evaluate_smd_all import get_test_instances, check_paths_feasible_batch +# n per cell: tab:app-mmd claims n=100 while the shared default is 10. Set +# MMD_N_TEST=100 to reproduce the larger sweep; leaving it unset keeps the default. +_esa.N_TEST_INSTANCES = int(os.environ.get('MMD_N_TEST', _esa.N_TEST_INSTANCES)) + +TRAJECTORY_DURATION = 5.0 +N_SAMPLES = 16 # matches our own SMD evaluation's sample budget for a fair comparison +CBS_RUNTIME_LIMIT = 180 # seconds, per test instance high-level search +INSTANCE_IDX = 0 + + +def build_composite_reference(cfg, tensor_args): + """A genuinely composite-robot (q_dim=2*num_agents) env/robot/task, needed only + for get_test_instances (matching evaluate_smd_all.py's convention of sampling/ + reading instances through the trained composite model's own task) and for the + final feasibility recheck. build_reference() (imported from generate_via_pp.py) + intentionally uses the *single*-agent robot instead -- correct for CBS's own + reference_robot/reference_task (pairwise conflict checks need one robot's radius, + not a 2N-dim composite configuration), but wrong for sampling/reshaping + multi-agent test instances.""" + env_class = getattr(environments, cfg['env_id'] + 'ExtraObjects') + env = env_class(tensor_args=tensor_args, instance_idx=INSTANCE_IDX, map_name=cfg['map_name']) + robot_class = getattr(robots, cfg['robot_id']) + robot = robot_class(tensor_args=tensor_args) + task = PlanningTask(env=env, robot=robot, tensor_args=tensor_args, obstacle_cutoff_margin=0.05) + return env, robot, task + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + num_agents = cfg['num_agents'] + + out_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"mmd_{cfg['model_id']}") + os.makedirs(out_dir, exist_ok=True) + # write to a distinct file: eval_results.json is already cited in the paper + out_path = os.path.join(out_dir, 'eval_results_metrics.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] mmd eval_results_metrics.json already exists, skipping") + return json.load(open(out_path)) + + sa_key = SCENARIO_TO_SA_KEY[cfg['scenario']] + sa_cfg = SA_SCENARIOS[sa_key] + model_id = f'singleagent_{sa_key}_{sa_cfg["env_id"]}-{SA_ROBOT_ID}' + scratch_dir = os.path.join(REPO_ROOT, 'logs_mmd_scratch') + os.makedirs(scratch_dir, exist_ok=True) + + # Composite-robot reference (q_dim=2*num_agents), used only to sample/read the + # same held-out test instances as the composite-SMD evaluations and for the + # final feasibility recheck. + env_comp, robot_comp, task_comp = build_composite_reference(cfg, tensor_args) + test_instances_raw = get_test_instances(cfg, task_comp, tensor_args) + + # Single-agent reference, used for CBS's own pairwise conflict checking (needs + # one robot's radius/geometry, not a composite configuration) and for the + # single-agent SMD planners. + env_sa, robot_sa, task_sa = build_reference(cfg, tensor_args) + + shared = None + results = [] + + for instance_idx in range(10): + try: + start_pos, goal_pos = test_instances_raw[instance_idx] + starts_t = [torch.tensor(start_pos[i], **tensor_args) for i in range(num_agents)] + goals_t = [torch.tensor(goal_pos[i], **tensor_args) for i in range(num_agents)] + + if shared is None: + shared = get_shared(model_id, scratch_dir, starts_t[0], goals_t[0]) + + low_level_planner_l = [make_light_smd(shared, starts_t[i], goals_t[i]) for i in range(num_agents)] + for p in low_level_planner_l: + p.num_samples = N_SAMPLES + + cbs = CBS( + low_level_planner_l, starts_t, goals_t, + is_xcbs=True, is_ecbs=True, + conflict_type_to_constraint_types={PointConflict: {MultiPointConstraint}}, + reference_robot=robot_sa, reference_task=task_sa, + results_dir=scratch_dir, + ) + print(f"\n==== [{cfg['tag']}] mmd test instance {instance_idx} ====") + _t0 = time.perf_counter() + paths_l, num_ct_expansions, success_status, num_conflicts = cbs.plan(runtime_limit=CBS_RUNTIME_LIMIT) + wall_s = float(time.perf_counter() - _t0) + + pos = torch.stack([p[:, :2] for p in paths_l], dim=1).unsqueeze(0) # (1, H, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos, robot_comp.radius, env_comp) + feasible = bool(feasible_mask[0].item()) + + pos_np = pos[0].detach().cpu().numpy() # (H, num_agents, 2) + diffs = np.diff(pos_np, axis=0) + plen = float(np.linalg.norm(diffs, axis=-1).sum(axis=0).mean()) + + _H = pos_np.shape[0] + _dt = TRAJECTORY_DURATION / _H + _acc = np.diff(pos_np, n=2, axis=0) / (_dt ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + + # C: proportion of robots that experience a collision (SMD's definition), + # against the same criterion check_paths_feasible_batch applies. + _r = float(robot_comp.radius) + # reuse `pos`, which is already on the env's device -- building a fresh + # CPU tensor here mismatches a CUDA env and raises at runtime. + _sdf = env_comp.compute_sdf(pos[0].reshape(-1, 2) + ).reshape(_H, -1).detach().cpu().numpy() + _hit = (_sdf <= _r).any(axis=0) # per robot, obstacle + _d = np.linalg.norm(pos_np[:, :, None, :] - pos_np[:, None, :, :], axis=-1) + _eye = np.eye(_d.shape[1], dtype=bool) + _pair = (_d < 2 * _r) & ~_eye[None] + _hit = _hit | _pair.any(axis=0).any(axis=1) # per robot, inter-agent + metric_C = float(_hit.mean()) + + print(f"instance {instance_idx}: cbs_status={success_status} geometric_feasible={feasible} " + f"avg_path_length={plen:.4f} A={metric_A:.4f} C={metric_C:.4f} " + f"wall={wall_s:.2f}s ct_expansions={num_ct_expansions}") + + results.append(dict( + instance_idx=instance_idx, + cbs_success_status=str(success_status), + feasible=feasible, + avg_path_length=plen, + metric_A=metric_A, + metric_C=metric_C, + wall_s=wall_s, + ct_expansions=int(num_ct_expansions), + )) + except Exception as e: + print(f"[{cfg['tag']}] instance {instance_idx} raised {type(e).__name__}: {e}") + results.append(dict(instance_idx=instance_idx, cbs_success_status='EXCEPTION', + feasible=False, avg_path_length=None, ct_expansions=None)) + + success_rate = sum(r['feasible'] for r in results) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + + summary = dict( + tag=cfg['tag'], method='MMD (XECBS + single-agent SMD)', results=results, + success_rate=success_rate, avg_path_length_over_success=avg_len, + metric_A=float(np.mean([r['metric_A'] for r in results if r.get('metric_A') is not None])) if any(r.get('metric_A') is not None for r in results) else None, + metric_C=float(np.mean([r['metric_C'] for r in results if r.get('metric_C') is not None])) if any(r.get('metric_C') is not None for r in results) else None, + wall_s_median=float(np.median([r['wall_s'] for r in results if r.get('wall_s') is not None])) if any(r.get('wall_s') is not None for r in results) else None, + wall_s_mean=float(np.mean([r['wall_s'] for r in results if r.get('wall_s') is not None])) if any(r.get('wall_s') is not None for r in results) else None, + wall_s_sd=float(np.std([r['wall_s'] for r in results if r.get('wall_s') is not None], ddof=1)) if sum(1 for r in results if r.get('wall_s') is not None)>1 else None, + ) + with open(out_path, 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] MMD SUCCESS RATE: {success_rate:.2f} | AVG PATH LEN: {avg_len:.4f}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd.py new file mode 100644 index 0000000000000000000000000000000000000000..995b2f01ee651ca995c073c0c2c7c732b619147e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd.py @@ -0,0 +1,250 @@ +""" +Run SMD inference (diffusion sampling + the paper's projected-sampling / +augmented-Lagrangian mechanism, via smd.projection.projection.apply_projection_alm +which itself calls IPOPT through Pyomo) on held-out test instances, and +report the same success-rate / collision / path-length metrics as the +official is_collision.py script. + +This reuses the exact dataset/model-loading and guide-construction code path +from smd.planners.multi_agent.smd_composite.SMDComposite, but drives it +directly (no init4proj_data/*.pkl or instances-file-driven experiment +launcher plumbing) so it can run on our from-scratch instances_data pkl and +from-scratch-trained checkpoint. +""" +import os +import sys +import pickle +import json +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.config.smd_params import SMDParams as params +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params + + +MODEL_ID = 'EnvEmptyNoWait2D-RobotCompositeThreePlanarDisk' +MAP_NAME = 'empty_map' +NUM_AGENTS = 3 +N_TEST_INSTANCES = 10 # matches the paper's "10 test cases per configuration" +ROBOT_RADIUS = 0.05 + +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) + +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + # start_pos, goal_pos: (num_agents, 2) numpy arrays + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) # (horizon, 2) + init[j] = traj + return init + + +def check_paths_ok(paths, obs_data, robot_radius=ROBOT_RADIUS, threshold=1e-3): + # paths: (num_agents, horizon, 2) + num_agents, horizon, _ = paths.shape + for i in range(num_agents): + for j in range(len(obs_data)): + for t in range(horizon): + if (paths[i, t, 0] - obs_data[j][0][0]) ** 2 + (paths[i, t, 1] - obs_data[j][0][1]) ** 2 < \ + (robot_radius + obs_data[j][1]) ** 2 - threshold: + return False + for i in range(num_agents): + for j in range(i + 1, num_agents): + for t in range(horizon): + if (paths[i, t, 0] - paths[j, t, 0]) ** 2 + (paths[i, t, 1] - paths[j, t, 1]) ** 2 < \ + (2.0 * robot_radius) ** 2 - threshold: + return False + return True + + +def path_length(paths): + # paths: (num_agents, horizon, 2) -> average total path length per agent + diffs = np.diff(paths, axis=1) + seg_lens = np.linalg.norm(diffs, axis=-1) # (num_agents, horizon - 1) + return seg_lens.sum(axis=1).mean() + + +def main(): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', MODEL_ID) + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=0, + map_name=MAP_NAME, + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + env = dataset.env + robot = dataset.robot + task = dataset.task + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + # Guide (same construction as SMDComposite) + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + # Test instances + with open(os.path.join(REPO_ROOT, 'instances_data', f'{MAP_NAME}.pkl'), 'rb') as f: + instances = pickle.load(f) + + results = [] + for instance_idx in range(min(N_TEST_INSTANCES, len(instances))): + obs_info, agents_info = instances[instance_idx][0] # bucket 0 == 3 agents + start_pos = np.stack([a[0] for a in agents_info], axis=0) # (3, 2) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, NUM_AGENTS, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== Test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # positions only, reshape to (n_samples, horizon, num_agents, 2) -> pick sample 0 + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() # (n_samples, horizon, 6) + sample0 = pos[0].reshape(n_support_points, NUM_AGENTS, 2).transpose(1, 0, 2) # (agents, horizon, 2) + + feasible = check_paths_ok(sample0, obs_info) + plen = path_length(sample0) + + # generous metric: fraction of the n_samples that are individually feasible + n_feasible_samples = 0 + for s in range(pos.shape[0]): + p = pos[s].reshape(n_support_points, NUM_AGENTS, 2).transpose(1, 0, 2) + if check_paths_ok(p, obs_info): + n_feasible_samples += 1 + + print(f"instance {instance_idx}: sample0_feasible={feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=bool(feasible), + avg_path_length=float(plen), + n_feasible_samples=int(n_feasible_samples), + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + avg_len_over_success = np.mean([r['avg_path_length'] for r in results if r['sample0_feasible']]) \ + if any(r['sample0_feasible'] for r in results) else float('nan') + + print("\n==================== SUMMARY ====================") + print(f"Instances evaluated: {len(results)}") + print(f"Success rate (sample-0 collision-free): {success_rate:.2f}") + print(f"Avg path length per robot (over successes): {avg_len_over_success:.4f}") + + out_path = os.path.join(model_dir, 'eval_results.json') + with open(out_path, 'w') as f: + json.dump(dict(results=results, success_rate=success_rate, + avg_path_length_over_success=avg_len_over_success), f, indent=2) + print(f"Saved detailed results to {out_path}") + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_C.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_C.py new file mode 100644 index 0000000000000000000000000000000000000000..fde28a2958b1948cce322ec4bc2014017ee144ee --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_C.py @@ -0,0 +1,653 @@ +""" +SMD evaluation with SMD's per-ROBOT collision ratio C, and PER-INSTANCE CHECKPOINTING. + +Two differences from evaluate_smd_boxproj.py: + 1. metric_C_robot: the fraction of robots that experience a collision -- SMD's own + definition (Liang et al. 2025, Sec. 5). The existing metric_C is a per-SAMPLE + infeasibility rate, a different quantity, and is kept alongside it. + 2. Every finished instance is appended to /eval_partial_C.json immediately. + On restart the completed instances are reloaded and skipped, so this can be + stopped at any point (Ctrl-C, kill, reboot) and resumed without losing work -- + which matters because a single 6- or 9-robot cell runs for hours. + +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import time +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch +from torch_robotics.environments.env_base import EnvBase + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = int(__import__('os').environ.get('SMD_N_TEST', '10')) +# Instance-level sharding. Worker k handles instances where idx % NSHARDS == SHARD, so +# 180 (cell, instance) jobs spread over all cores instead of one worker owning a whole +# cell. Wall-clock then tracks the slowest INSTANCE (~20 min) rather than the slowest +# CELL (195 min measured for shelf-3). Unset -> SHARD=0, NSHARDS=1 = original behaviour. +SHARD = int(os.environ.get('SMD_SHARD', '0')) +NSHARDS = int(os.environ.get('SMD_NSHARDS', '1')) +INSTANCE_IDX = 0 +N_SAMPLES = int(os.environ.get('SMD_N_SAMPLES', '16')) # candidates per instance; rbt port runs 64 (paper-scale, cf. evaluate_smd_n64.py) +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +# SETTING namespaces the output files so a difficulty sweep (several radii / obstacle +# scales for the SAME cell) does not overwrite itself. Empty default = unchanged names, +# so every result produced before this existed is still found and still valid. +SETTING = os.environ.get('SETTING', '') +_SET_SFX = ('_' + SETTING) if SETTING else '' +# BOX_COVER_CELL <= 0 selects the RELEASED projection: box covering off, so box +# environments yield num_obs = 0 exactly as the authors' code does. This reproduces +# their behaviour rather than simulating it -- the covering was always opt-in. +_RELEASED = BOX_COVER_CELL <= 0 + +# Test-time velocity limits, one triple per map, as DiRecT Tables 7-10 sweep them. +# Those tables quote v_max in workspace-units/second; our projection constrains the +# PER-STEP displacement (projection.py:168 bounds ||p_i - p_{i+1}||), so the two differ +# by dt = TRAJECTORY_DURATION / n_support_points = 5.0/64 = 0.078125. +# speed_per_step = v_max * dt (our legacy 0.05 == v_max 0.640) +# Maps are matched to the paper by ENV CLASS, which is what actually sets the geometry. +# empty / basic / dense DEPART from DiRecT's published triple (0.675, 0.692, 0.703). +# That triple spans 4% end to end, so its three columns are one measurement repeated, and +# on these three maps 26/30/29 cells sat pinned at SR 1.00 with the limit inert (dense +# responded in 1 of 8 discriminating cells). The replacement is measured, not chosen: the +# new High is the OLD Low, so every column is at least as hard as the hardest old one, and +# the new Low is the feasibility floor -- the tightest limit at which the oracle still +# admits all 20 instances at all seven team sizes (bisected per instance on +# geodesic <= v_max * duration). Medium is the geometric mean, which reproduces DiRecT's +# own ~x1.2 spacing on their obstacle maps. Going lower makes instances unsolvable rather +# than harder. The other five maps keep their published triples, where the limit already +# discriminates (shelf 22/23, room 8/9). +VELOCITY_VMAX = { + 'empty': (0.483, 0.571, 0.675), # Table 7 Empty EnvEmptyNoWait2D + 'basic': (0.477, 0.567, 0.675), # same env class as Empty + 'dense': (0.565, 0.617, 0.675), # same env class as Empty + 'room': (0.647, 0.783, 0.878), # Table 8 Highways EnvHighways2D + 'corridor': (1.21, 1.46, 1.76 ), # Table 9 Conveyor EnvConveyor2D + 'shelf': (0.928, 1.13, 1.34 ), # Table 10 Drop-Region EnvDropRegion2D + # corridorbay / crossroom are new maps with no paper counterpart, so they INHERIT the + # triple of their closest reference map by geometry type: + # corridorbay -> Conveyor : both are narrow single-file channels + # crossroom -> Drop-Region : chosen by TRAVEL BUDGET, not by looks. It resembles + # Highways (open room, central divider), but its paths wind around the + # plus-shaped arms and run far longer (p95 2.76 vs Highways' 2.05). + # On Highways' triple that is 85% of the v_max*duration budget -- only + # 15% headroom, so failures would be velocity-limited rather than + # planner-limited. Drop-Region's triple puts it at 59%, in line with + # every other map (29-67%). + # NOT derived from our own demonstrations. That was tried and is wrong twice over: + # it is circular (the limit would come from the trajectories under test), and the + # 90th-percentile of our gentler demos gave v_max 0.378, whose reach over the 5s + # horizon is 1.89 -- only 67% of the workspace diagonal (2.83). Robots could not cross + # the map at all, so failures would measure the velocity cap, not the planner. Every + # value below clears the diagonal, as all four paper triples do. + 'corridorbay': (1.21, 1.46, 1.76 ), # as Conveyor + 'crossroom': (0.928, 1.13, 1.34 ), # as Drop-Region (see note above) +} +VEL_REGIME = os.environ.get('SMD_VEL_REGIME', '').lower() # '', low, medium, high, tight +# 'tight' = the 75th-percentile per-step speed measured on each map's own +# demonstrations. Used only to test whether the cap is WIRED THROUGH at all: the +# paper's published caps sit above most of SMD's speed distribution, so a null +# result under them is ambiguous between 'loose' and 'not applied'. +VELOCITY_TIGHT = {'empty':0.369,'basic':0.337,'dense':0.318,'corridor':0.300, + 'room':0.288,'shelf':0.285,'corridorbay':0.302,'crossroom':0.282} +_REGIME_IDX = {'low': 0, 'medium': 1, 'high': 2, 'tight': 1} + +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=(None if _RELEASED else BOX_COVER_CELL), + # Per-waypoint active set for the obstacle constraints. Unset = dense model, exactly + # as before. The ALM loop still checks feasibility densely, so this cannot silently + # drop a constraint that matters. + **({'obs_prune_margin': float(os.environ['OBS_PRUNE_MARGIN'])} + if os.environ.get('OBS_PRUNE_MARGIN') else {}), +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + # The shared instance set, when one is requested, replaces both paths below so SMD + # plans the SAME problems as the other nine methods. Without it SMD is the one + # method whose rows are not paired with the rest: it reads instances_data/.pkl + # for the maps with per-instance obstacles -- whose endpoints belong to instance i's + # obstacle layout while the env is built once at INSTANCE_IDX=0, so they are scored + # against geometry they were never drawn in (RUN_NOTE 3.2) -- and samples fresh ones + # for the rest. Inert unless SHARED_INSTANCES_NPZ and SHARED_INSTANCES_KEY are set. + import sys as _sys + if os.path.join(os.environ['MRMP_ROOT'], 'data_prep') not in _sys.path: + _sys.path.insert(0, os.path.join(os.environ['MRMP_ROOT'], 'data_prep')) + import shared_instances as _SI + _got = _SI.load() + if _got is not None: + _S, _G = _got + if _S.shape[1] != num_agents: + raise ValueError(f'shared set has {_S.shape[1]} agents, cfg wants {num_agents}') + _k = min(N_TEST_INSTANCES, _S.shape[0]) + print(f"[shared-instances] {os.environ['SHARED_INSTANCES_KEY']}: {_k} fixed " + f"instances (SMD's own sampler bypassed)", flush=True) + return [(np.asarray(_S[i], dtype=np.float64), np.asarray(_G[i], dtype=np.float64)) + for i in range(_k)] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + _vel_sfx = f'_vel{VEL_REGIME}' if VEL_REGIME else '' + out_name = ('eval_results_dm.json' if NO_PROJECTION + else f'eval_results_C_{"released" if _RELEASED else str(BOX_COVER_CELL).replace(".","")}{_SET_SFX}{_vel_sfx}.json') + out_path = os.path.join(model_dir, out_name) + # A results file is only reusable if it was produced on the SAME instance set. The + # skip below was unconditional, so a rerun against the shared instances silently kept + # results computed on SMD's own privately-sampled ones and reported "PILOT PASSED" + # having evaluated nothing. The instance-set key is recorded alongside the results + # and a mismatch forces a fresh evaluation. + _inst_key = os.environ.get('SHARED_INSTANCES_KEY') or 'smd-own-sampler' + if os.path.exists(out_path): + try: + _prev = json.load(open(out_path)).get('instance_set') + except Exception: + _prev = None + if _prev != _inst_key: + print(f"[{cfg['tag']}] {out_name} was computed on instance set " + f"{_prev!r}, this run is {_inst_key!r} -- re-evaluating") + os.remove(out_path) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + if VEL_REGIME: + _base = cfg.get('base_scenario', cfg['scenario']) + _tri = VELOCITY_VMAX.get(_base) + if _tri is None: + raise SystemExit(f"no velocity triple for map '{_base}' -- run derive_velocity_regimes.py") + if VEL_REGIME == 'tight': + _vmax = VELOCITY_TIGHT[_base] + else: + _vmax = _tri[_REGIME_IDX[VEL_REGIME]] + PROJ_PARAMS['agents_max_speeds'] = _vmax * dt + print(f"[{cfg['tag']}] velocity regime {VEL_REGIME}: v_max={_vmax} " + f"-> per-step {_vmax*dt:.5f} (legacy default was 0.05 = v_max 0.640)") + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + _sfx = '' if NSHARDS == 1 else f'_s{SHARD}of{NSHARDS}' + part_path = os.path.join(model_dir, f'eval_partial_C{_SET_SFX}{_sfx}.json') + done = {} + if os.path.exists(part_path): + try: + done = {int(r['instance_idx']): r for r in json.load(open(part_path))} + print(f"[{cfg['tag']}] resuming: {len(done)}/{len(test_instances)} instances already done") + except Exception: + done = {} + + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + if NSHARDS > 1 and (instance_idx % NSHARDS) != SHARD: + continue # belongs to another worker + if instance_idx in done: + results.append(done[instance_idx]) + print(f"[{cfg['tag']}] instance {instance_idx}: cached, skipping") + continue + # Seed PER INSTANCE, not per process. The sampler is stochastic, so with a + # single process-level seed instance i sees a different RNG state depending on + # how many instances preceded it in THIS worker -- which makes the result depend + # on the shard layout (measured: metric_A 0.388 sharded vs 0.377 unsharded). + # Seeding by instance index makes every run reproducible and sharding-invariant, + # which an A/B over difficulty settings requires. + fix_random_seed(EVAL_SEED + 1000 * instance_idx) + + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + # Figure-3/Table-1 style "Time (s)": wall-clock of sampling + ALM projection for + # ONE instance. cuda.synchronize or the timer stops before the GPU work does. + if torch.cuda.is_available(): torch.cuda.synchronize() + _t_start = time.perf_counter() + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + if torch.cuda.is_available(): torch.cuda.synchronize() + plan_time_s = float(time.perf_counter() - _t_start) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + # GOAL-REACHING. check_paths_feasible_batch is collision-only, and scoring on that + # alone flatters any method whose trajectories never arrive: results/baselines_eval + # measured PCD at SR 1.00 / SOLVED 0.00 on empty-3, with a best goal error of + # 0.0515 and median 0.1137 across every guider x projector combination. SMD pins + # the goal through hard_conds so the two nearly coincide for it, but the baselines + # must be scored on the same footing, so report both. Tolerances 0.05 (robot + # radius) and 0.10 (2r), as results/baselines_eval/RESULTS.md does -- PCD's best + # sits within 0.002 of the tighter one, so a single threshold would silently + # decide the verdict. + _endpos = pos_t[:, -1, :, :].detach().cpu().numpy() # (N_SAMPLES, A, 2) + _gerr = np.abs(_endpos - goal_pos[None, :, :]).max(axis=(1, 2)) # per sample + goal_err_sample0 = float(_gerr[0]) + goal_err_best = float(_gerr.min()) + _feas = feasible_mask.detach().cpu().numpy().astype(bool) + solved_005 = bool(_feas[0] and _gerr[0] <= 0.05) + solved_010 = bool(_feas[0] and _gerr[0] <= 0.10) + any_solved_005 = bool(((_gerr <= 0.05) & _feas).any()) + any_solved_010 = bool(((_gerr <= 0.10) & _feas).any()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + # C as SMD defines it: fraction of ROBOTS experiencing a collision, on sample 0 + # (the same sample A is computed on). Distinct from metric_C above, which is the + # per-sample infeasibility rate our harness has always recorded. + _p0 = pos_t[0].detach().cpu().numpy() # (H, num_agents, 2) + _rr = float(robot.radius) + _sdf0 = env.compute_sdf(pos_t[0].reshape(-1, 2)).reshape( + _p0.shape[0], num_agents).detach().cpu().numpy() + # Use the SAME tolerance as check_paths_feasible_batch (threshold=1e-3), or C + # contradicts S in the same row: a zero-tolerance test flags grazing contact that + # S counts as feasible, producing rows like S=100% with C=0.24. + _th = 1e-3 + _hit = (_sdf0 <= _rr - _th).any(axis=0) + _d2 = ((_p0[:, :, None, :] - _p0[:, None, :, :]) ** 2).sum(-1) + _eye = np.eye(num_agents, dtype=bool) + _hit = _hit | ((_d2 < (2 * _rr) ** 2 - _th) & ~_eye[None]).any(axis=0).any(axis=1) + metric_C_robot = float(_hit.mean()) + + # "Coll" (DiRecT Tables 7-10): MEAN NUMBER OF COLLISIONS, not a rate -- the count + # of violated (timestep, agent)-obstacle and (timestep, pair)-agent incidences, + # averaged over the N sampled trajectories. Same 1e-3 tolerance as S and C_robot, + # so the three cannot contradict each other on one row. + _pos_all = pos_t.detach().cpu().numpy() # (N, H, A, 2) + _sdf_all = env.compute_sdf(pos_t.reshape(-1, 2)).reshape( + N_SAMPLES, n_support_points, num_agents).detach().cpu().numpy() + _obs_hits = (_sdf_all <= _rr - _th).sum(axis=(1, 2)) # per sample + _dd = ((_pos_all[:, :, :, None, :] - _pos_all[:, :, None, :, :]) ** 2).sum(-1) + _pair = np.triu(np.ones((num_agents, num_agents), dtype=bool), k=1) + _agent_hits = ((_dd < (2 * _rr) ** 2 - _th) & _pair[None, None]).sum(axis=(1, 2, 3)) + metric_coll = float((_obs_hits + _agent_hits).mean()) + + # Data Adherence (DiRecT Fig. 3, third column): environment-specific task + # success. Every map defines it differently, so it lives on the env class. + # EnvBase's DEFAULT calls plt.show() -- a blocking GUI call inside this loop -- + # and returns -inf, which would silently poison the column. EnvDropRegion2D and + # EnvEmptyNoWait2D do NOT override it, so call it only where it is real. + _has_adh = type(env).compute_traj_data_adherence is not EnvBase.compute_traj_data_adherence + if _has_adh: + _adh = [] + for _a in range(num_agents): + try: + _v = env.compute_traj_data_adherence(pos_t[0][:, _a, :]) + _v = float(_v.item() if torch.is_tensor(_v) else _v) + if np.isfinite(_v): _adh.append(_v) + except Exception as _e: + print(f" adherence failed on agent {_a}: {type(_e).__name__}") + data_adherence = float(np.mean(_adh)) if _adh else None + else: + data_adherence = None # not defined for this env class -- reported as n/a + + results.append(dict( + goal_err_sample0=goal_err_sample0, + goal_err_best=goal_err_best, + solved_005=solved_005, solved_010=solved_010, + any_solved_005=any_solved_005, any_solved_010=any_solved_010, + metric_coll=metric_coll, + plan_time_s=plan_time_s, + data_adherence=data_adherence, + metric_C_robot=metric_C_robot, + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + with open(part_path, 'w') as _pf: # checkpoint AFTER the append, so a stop + json.dump(results, _pf) # here loses nothing already computed + + if not results: + # A shard can legitimately own no instance (NSHARDS > n_instances). That is not + # an error -- it just has nothing to summarise. + print(f"[shard {SHARD}/{NSHARDS}] owns no instance of {cfg['tag']}; nothing to do") + return None + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + def _ms(key): + v = [r[key] for r in results if r.get(key) is not None] + if not v: return (None, None) + return (float(np.mean(v)), float(np.std(v))) + _A_m,_A_s = _ms('metric_A'); _C_m,_C_s = _ms('metric_C') + _Cr_m,_Cr_s = _ms('metric_C_robot'); _T_m,_T_s = _ms('plan_time_s') + _D_m,_D_s = _ms('data_adherence'); _L_m,_L_s = _ms('avg_path_length') + _Co_m,_Co_s = _ms('metric_coll') + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + # mean +/- std for every per-instance quantity (DiRecT Tables 1/2 report both) + plan_time_s_mean=_T_m, plan_time_s_std=_T_s, + metric_coll_mean=_Co_m, metric_coll_std=_Co_s, + # SR is collision-only; SOLVED additionally requires reaching the goal. + solved_rate_005=sum(r['solved_005'] for r in results) / len(results), + solved_rate_010=sum(r['solved_010'] for r in results) / len(results), + any_solved_rate_005=sum(r['any_solved_005'] for r in results) / len(results), + any_solved_rate_010=sum(r['any_solved_010'] for r in results) / len(results), + goal_err_mean=float(np.mean([r['goal_err_sample0'] for r in results])), + goal_err_best_mean=float(np.mean([r['goal_err_best'] for r in results])), + data_adherence_mean=_D_m, data_adherence_std=_D_s, + constraint_safety=(1.0 - _C_m) if _C_m is not None else None, + metric_A_std=_A_s, metric_C_std=_C_s, metric_C_robot_std=_Cr_s, + avg_path_length_mean=_L_m, avg_path_length_std=_L_s, + n_instances_measured=len(results), + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + metric_C_robot=float(np.mean([r['metric_C_robot'] for r in results + if r.get('metric_C_robot') is not None])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + # which start/goal set these numbers describe, so a later run cannot reuse them + # against different instances (see the skip guard above) + instance_set=os.environ.get('SHARED_INSTANCES_KEY') or 'smd-own-sampler', + ) + if NSHARDS > 1: + # A shard has seen only its slice, so it must NOT write the cell summary or + # delete its partial -- merge_shards_C.py assembles the cell from all shards. + print(f"[{cfg['tag']}] shard {SHARD}/{NSHARDS}: {len(results)} instances -> " + f"{os.path.basename(part_path)} (run merge_shards_C.py to assemble)") + return summary + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + if os.path.exists(part_path): + os.remove(part_path) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py new file mode 100644 index 0000000000000000000000000000000000000000..26966fc9d9929ffa5589ec4135b2d07533a4ad32 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py @@ -0,0 +1,373 @@ +""" +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = int(__import__('os').environ.get('SMD_N_TEST', '10')) +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + # The shipped instances_data pkl only carries start/goal sets for 3, 6 and 9 agents + # (BUCKET_BY_N). Above that there is no stored instance, so fall back to sampling + # fresh collision-free start/goal pairs in the SAME environment -- exactly what the + # fixed-geometry branch below already does. The obstacles still come from the pkl via + # instance_idx, so only the endpoints are new. + # Basic/Dense DELIBERATELY excluded from the stored-instance path. + # + # The pkl holds 20 instances and each carries its OWN obstacle layout (verified: + # instance 0 != instance 1, 10 obstacles each). But the environment is built once at + # INSTANCE_IDX = 0 -- and so was the TRAINING data (generate_via_pp.py, train_smd.py + # both pin INSTANCE_IDX = 0). Taking start/goal from instances[i>0] therefore scored + # endpoints generated against a different obstacle layout than the one evaluated: + # measured, only 73% of Basic and 70% of Dense start points were collision-free in + # the env being used, i.e. ~30% of robots began inside an obstacle. + # + # Rebuilding the env per instance would fix the mismatch but put every test out of + # the model's training distribution. Sampling fresh endpoints inside the SAME env 0 + # keeps the model in-distribution and matches what corridor/room/shelf already do. + # + # Empty keeps the stored path: it has no obstacles, so no mismatch is possible + # (measured 100% of its stored endpoints free at every obstacle scale). + if cfg.get('base_scenario', cfg['scenario']) == 'empty' and num_agents in BUCKET_BY_N: + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + # Place robots one at a time, accepting a point ONLY if the task's own + # collision checker accepts it, and a full configuration only if the planners' + # own gate accepts it. Three earlier attempts failed because each used a + # DIFFERENT, looser criterion than the planners: + # 1. rejection-sampling a whole 2N vector -- never succeeds past ~12 robots; + # 2. env.compute_sdf with a hand-picked r*1.2 margin -- "Start states are in + # collision with the world" from every planner; + # 3. pairwise 2.2r -- below the hardcoded 0.15 the validator requires. + # Using the real checkers removes the guesswork. + from generate_via_pp import build_reference + from smd.common.multi_agent_utils import is_multi_agent_start_goal_states_valid + fix_random_seed(EVAL_SEED) + _env1, _robot1, _task1 = build_reference(cfg, tensor_args) # single-agent ref + pair_min = max(0.16, 2.2 * float(getattr(_robot1, 'radius', 0.05))) + + def _free(p): + try: + return not bool(_task1.compute_collision(p.reshape(1, 1, 2)).item()) + except Exception: + return float(_env1.compute_sdf(p).min()) > float(_robot1.radius) * 1.5 + + def _place(n_pts): + pts = [] + for _ in range(30000): + if len(pts) == n_pts: + return pts + p = torch.rand(1, 2, **tensor_args) * 1.8 - 0.9 + if not _free(p): + continue + if any(float(torch.linalg.norm(p - q)) < pair_min for q in pts): + continue + pts.append(p) + return None + + out = [] + for _ in range(N_TEST_INSTANCES): + for _attempt in range(40): # resample the whole configuration + s_pts = _place(num_agents) + g_pts = _place(num_agents) + if s_pts is None or g_pts is None: + continue + sl = [p.reshape(2) for p in s_pts] + gl = [p.reshape(2) for p in g_pts] + try: + if not is_multi_agent_start_goal_states_valid(_robot1, _task1, sl, gl): + continue + except Exception: + pass # validator unavailable -> accept + out.append((torch.stack(sl).cpu().numpy().reshape(num_agents, 2), + torch.stack(gl).cpu().numpy().reshape(num_agents, 2))) + break + else: + print(f" [warn] no valid {num_agents}-robot configuration found; skipping") + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', cfg['model_id']) + out_path = os.path.join(model_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, 'eval_results.json'), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py.presampler b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py.presampler new file mode 100644 index 0000000000000000000000000000000000000000..12be59af11a7c00195686de15412492b18d7f8a9 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_all.py.presampler @@ -0,0 +1,315 @@ +""" +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + # The shipped instances_data pkl only carries start/goal sets for 3, 6 and 9 agents + # (BUCKET_BY_N). Above that there is no stored instance, so fall back to sampling + # fresh collision-free start/goal pairs in the SAME environment -- exactly what the + # fixed-geometry branch below already does. The obstacles still come from the pkl via + # instance_idx, so only the endpoints are new. + if (cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty') and num_agents in BUCKET_BY_N: + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', cfg['model_id']) + out_path = os.path.join(model_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, 'eval_results.json'), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_boxproj.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_boxproj.py new file mode 100644 index 0000000000000000000000000000000000000000..fba12205f8554cdaa94b04af5b6f1b68f577eb3b --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_boxproj.py @@ -0,0 +1,367 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=BOX_COVER_CELL, +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + out_name = ('eval_results_dm.json' if NO_PROJECTION + else f'eval_results_boxproj_{str(BOX_COVER_CELL).replace(".","")}.json') + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_cbsonly.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_cbsonly.py new file mode 100644 index 0000000000000000000000000000000000000000..f5bf414ab2340f0f9ce8aa345066f86e858c64a5 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_cbsonly.py @@ -0,0 +1,367 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=BOX_COVER_CELL, +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"cbsonly_{cfg['model_id']}") + out_name = ('eval_results_dm.json' if NO_PROJECTION + else f'eval_results_boxproj_{str(BOX_COVER_CELL).replace(".","")}.json') + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_clean_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_clean_all.py new file mode 100644 index 0000000000000000000000000000000000000000..c8afd29115800ab5a5534dcb8088a611c85e6bd3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_clean_all.py @@ -0,0 +1,357 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_clean_all.py's +clean_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"clean_{cfg['model_id']}") + out_name = 'eval_results_dm.json' if NO_PROJECTION else 'eval_results.json' + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_merged_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_merged_all.py new file mode 100644 index 0000000000000000000000000000000000000000..757804ec99bfba2734427b4350dbd8e16cf244d4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_merged_all.py @@ -0,0 +1,357 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + out_name = 'eval_results_dm.json' if NO_PROJECTION else 'eval_results.json' + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_mergedcbs_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_mergedcbs_all.py new file mode 100644 index 0000000000000000000000000000000000000000..7a663642cdcfc190912a46df3d072baa4e33c099 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_mergedcbs_all.py @@ -0,0 +1,329 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"mergedcbs_{cfg['model_id']}") + out_path = os.path.join(model_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] mergedcbs eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, 'eval_results.json'), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--tags', nargs='+', default=None) + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tags: + for t in args.tags: + run_scenario(get_scenario(t)) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag, --tags ..., or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_n64.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_n64.py new file mode 100644 index 0000000000000000000000000000000000000000..6895e50c210f8c2cad3029ef97a215e59d725d26 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_n64.py @@ -0,0 +1,329 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 64 # restored to paper-scale sample budget (was reduced to 16 for speed elsewhere) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + # Load the already-trained merged-data checkpoint (unchanged), write results + # to a separate location -- pure eval-time change (n_samples 16->64 only). + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + out_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}_n64") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] n64 eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(out_path, 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_perinstance.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_perinstance.py new file mode 100644 index 0000000000000000000000000000000000000000..723f5e8430aa47b8921e8d43c21303e558f6f839 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_perinstance.py @@ -0,0 +1,425 @@ +""" +PER-INSTANCE-ENVIRONMENT evaluator. + +The shared evaluator builds ONE environment from instance_idx=0 and then draws start/goal +pairs from instances 0..9. But instances_data/.pkl stores a DIFFERENT obstacle layout +per instance (env_empty_nowait_2d_extra_objects.py:23 reads loaded_set[instance_idx][0][0]), +so from instance 1 onward robots are asked to start or finish INSIDE an obstacle: +basic-6 has 1-4 of 12 endpoints buried, dense-3 up to 5 of 6. Success needs every robot +collision-free, so ~9/10 of the basic/dense test set is unsolvable by construction and the +score is capped near 0.10 -- exactly the ceiling every method hit (SMD, MMD, ours). +empty is unaffected (no obstacles); room/shelf/corridor take the fixed-geometry branch and +sample fresh start/goals, so they were never affected. + +This evaluator rebuilds the dataset/env/task/guide per test instance with instance_idx=i so +that instance i's start/goals meet instance i's obstacles. Trajectory files (and hence +normalisation statistics) do not depend on instance_idx, so the model is unchanged. + +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=BOX_COVER_CELL, +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def _build_for_instance(cfg, args, tensor_args, inst_idx): + """dataset/env/task/guide for ONE test instance's own obstacle layout.""" + train_subset, _, _, _ = get_dataset( + dataset_class='TrajectoryDataset', use_extra_objects=True, + obstacle_cutoff_margin=0.01, **args, tensor_args=tensor_args, + instance_idx=inst_idx, map_name=cfg['map_name'], + ) + ds = train_subset.dataset + ds.robot.dt = TRAJECTORY_DURATION / ds.n_support_points + return ds + + +def _make_guide(ds, tensor_args): + n = ds.n_support_points + dt = TRAJECTORY_DURATION / n + cost_l, w_l = [], [] + for cf in ds.task.get_collision_fields(): + cost_l.append(CostCollision(ds.robot, n, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + w_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_l.append(CostGPTrajectory(ds.robot, n, dt, sigma_gp=1.0, tensor_args=tensor_args)) + w_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + comp = CostComposite(ds.robot, n, cost_l, weights_cost_l=w_l, tensor_args=tensor_args) + return GuideManagerTrajectoriesWithVelocity( + ds, comp, clip_grad=True, interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n * 1.5), tensor_args=tensor_args) + + +def _startgoal(cfg, inst_idx): + """start/goal for THIS instance (same pkl indexing the env used).""" + import pickle as _pk + p = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(p, 'rb') as f: + inst = _pk.load(f) + ag = inst[inst_idx][BUCKET_BY_N[cfg['num_agents']]][1] + return (np.stack([a[0] for a in ag]), np.stack([a[1] for a in ag])) + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + out_name = f'eval_results_perinst_{str(BOX_COVER_CELL).replace(".","")}.json' + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + # instance i's start/goal MUST be paired with instance i's obstacles + n_inst = N_TEST_INSTANCES + test_instances = [_startgoal(cfg, i) for i in range(n_inst)] + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + # rebuild env/task/guide for THIS instance's obstacle layout + dataset = _build_for_instance(cfg, args, tensor_args, instance_idx) + robot, task, env = dataset.robot, dataset.task, dataset.env + guide = _make_guide(dataset, tensor_args) + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_pp_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_pp_all.py new file mode 100644 index 0000000000000000000000000000000000000000..f2a49941c087965829228d3f447a2bf134360ea8 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_pp_all.py @@ -0,0 +1,317 @@ +""" +Evaluates the composite SMD models trained on PP-generated demonstrations +(train_smd_pp_all.py's pp_-prefixed checkpoints) instead of the original +from-scratch synthetic-generator-trained models. Identical methodology to +evaluate_smd_all.py (same test instances, same feasibility criterion) -- +only the checkpoint/dataset-stats source changes -- so results are directly +comparable to both the paper and the original reproduction's eval_results.json. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"pp_{cfg['model_id']}") + out_path = os.path.join(model_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] pp eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, 'eval_results.json'), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_terminal_proj.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_terminal_proj.py new file mode 100644 index 0000000000000000000000000000000000000000..fc6e04663cb5ad2199718f255ec5a5422cbdfe95 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_terminal_proj.py @@ -0,0 +1,336 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + # Terminal-only projection (DiRecT-style): the sampling loop in + # diffusion_model_base.py always projects at i==0 (the final clean sample) + # regardless of this list -- SMD's original [15, 5] additionally projects + # at two intermediate noisy denoising steps. DiRecT's paper diagnoses this + # as overconstraining the learned reverse dynamics and degrading sample + # quality; testing whether dropping the intermediate projections helps. + projection_step=[], +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + # Load the already-trained merged-data checkpoint (unchanged) but write results + # to a separate location -- this is a *pure eval-time* change (terminal-only + # projection, no intermediate-step projection), no retraining involved. + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}") + out_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"merged_{cfg['model_id']}_termproj") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, 'eval_results.json') + if os.path.exists(out_path): + print(f"[{cfg['tag']}] terminal-proj eval_results.json already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + results.append(dict( + instance_idx=instance_idx, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(out_path, 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide.py new file mode 100644 index 0000000000000000000000000000000000000000..e77b99c86c23df3fa07e5eeb11a36326a6644eeb --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide.py @@ -0,0 +1,367 @@ +""" +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=BOX_COVER_CELL, +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"wide_{cfg['model_id']}") + out_name = ('eval_results_dm.json' if NO_PROJECTION + else f'eval_results_boxproj_{str(BOX_COVER_CELL).replace(".","")}.json') + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + test_instances = get_test_instances(cfg, task, tensor_args) + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide_perinst.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide_perinst.py new file mode 100644 index 0000000000000000000000000000000000000000..96f846146bd5fcb3002fcbb4ad60fabd9c731a77 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/evaluate_smd_wide_perinst.py @@ -0,0 +1,425 @@ +""" +PER-INSTANCE-ENVIRONMENT evaluator. + +The shared evaluator builds ONE environment from instance_idx=0 and then draws start/goal +pairs from instances 0..9. But instances_data/.pkl stores a DIFFERENT obstacle layout +per instance (env_empty_nowait_2d_extra_objects.py:23 reads loaded_set[instance_idx][0][0]), +so from instance 1 onward robots are asked to start or finish INSIDE an obstacle: +basic-6 has 1-4 of 12 endpoints buried, dense-3 up to 5 of 6. Success needs every robot +collision-free, so ~9/10 of the basic/dense test set is unsolvable by construction and the +score is capped near 0.10 -- exactly the ceiling every method hit (SMD, MMD, ours). +empty is unaffected (no obstacles); room/shelf/corridor take the fixed-geometry branch and +sample fresh start/goals, so they were never affected. + +This evaluator rebuilds the dataset/env/task/guide per test instance with instance_idx=i so +that instance i's start/goals meet instance i's obstacles. Trajectory files (and hence +normalisation statistics) do not depend on instance_idx, so the model is unchanged. + +Evaluates the composite SMD models trained on the merged (original + PP) +dataset with the longer training budget (train_smd_merged_all.py's +merged_-prefixed checkpoints). Identical methodology to evaluate_smd_all.py +(same test instances, same feasibility criterion) -- only the checkpoint/ +dataset-stats source changes -- so results are directly comparable to the +paper and to both prior reproductions' eval_results.json. + +Also reports the any-of-N-samples-feasible success rate alongside the +original sample-0-only metric: checking only the arbitrary first sample in +the batch under-reports real capability relative to the more standard +"at least one of N generated samples is feasible" convention. + +Generalized version of evaluate_smd.py: runs SMD inference (diffusion +sampling + IPOPT/Pyomo-solved augmented-Lagrangian projection) for any +scenario defined in scenarios.py, and reports success rate / path length. + +Unlike the first evaluate_smd.py, feasibility is checked using the repo's +own task.get_trajs_collision_and_free (real self-collision + object/SDF +collision + workspace-boundary checking) instead of a hand-rolled +circles-only check -- necessary here since Corridor/Room/Shelf use box-shaped +wall geometry that a spheres-only checker would silently miss. + +For empty/basic/dense, test instances come from the instances_data/.pkl +files (start/goal only; the projection step itself needs actual obstacle +positions, read directly from the loaded environment). For corridor/room/ +shelf (fixed wall geometry, no per-instance obstacles), test instances are +fresh random collision-free start/goal pairs sampled at evaluation time. + +Usage: + python evaluate_smd_all.py + python evaluate_smd_all.py --all +""" +import os +import sys +import pickle +import json +import argparse +from math import ceil + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from smd.models import TemporalUnet, UNET_DIM_MULTS +from smd.models.diffusion_models.guides import GuideManagerTrajectoriesWithVelocity +from smd.models.diffusion_models.sample_functions import ddpm_sample_fn +from smd.trainer import get_dataset, get_model +from smd.utils.loading import load_params_from_yaml +from mp_baselines.planners.costs.cost_functions import CostCollision, CostComposite, CostGPTrajectory +from torch_robotics.torch_utils.torch_utils import get_torch_device, freeze_torch_model_params +from torch_robotics.torch_utils.seed import fix_random_seed + +from scenarios import SCENARIOS, get_scenario + +N_TEST_INSTANCES = 10 +INSTANCE_IDX = 0 +N_SAMPLES = 16 +# Identical to evaluate_smd_merged_all.py EXCEPT box_cover_cell, which switches on the +# box-obstacle covering in apply_projection_alm. Without it the projection sees num_obs=0 +# in Shelf/Room/Conveyor (their walls are box fields, and the released code only read +# fields[0] and required .radii), so it is blind to walls: it "fixes" inter-robot +# collisions by pushing robots straight through the shelves. That is the measured +# signature on Shelf -- raw model 0.90/0.70 at N=6/9, projection destroys it to 0.10/0.00. +# BOX_COVER_CELL is the covering resolution; smaller = tighter fit, more circles, slower. +BOX_COVER_CELL = float(os.environ.get('BOX_COVER_CELL', '0.125')) +PROJ_PARAMS = dict( + agents_max_speeds=0.05, + rho=5.0, + rho_factor=1.05, + alm_iteration=100, + tolerance=1e-3, + projection_step=[15, 5], + box_cover_cell=BOX_COVER_CELL, +) +WEIGHT_GRAD_COST_COLLISION = 5e-2 +WEIGHT_GRAD_COST_SMOOTHNESS = 1e-2 +START_GUIDE_STEPS_FRACTION = 0.5 +N_GUIDE_STEPS = 25 +N_DIFFUSION_STEPS_WITHOUT_NOISE = 5 +TRAJECTORY_DURATION = 5.0 +EVAL_SEED = 123 + +BUCKET_BY_N = {2: 0, 3: 0, 6: 1, 9: 2} + + +def check_paths_feasible_batch(pos, robot_radius, env, threshold=1e-3): + """ + Feasibility check matching the repo's own official is_collision.py + criterion exactly (2*robot_radius contact distance for inter-robot + pairs), rather than task.get_trajs_collision_and_free's composite + self-collision field, which turned out to use self_collision_margin_robot + = radius*4 as its hard occupancy threshold -- *twice* the paper's own + evaluation margin. That mismatch was silently marking genuinely feasible + (per the paper's own definition) trajectories as failures in any scenario + where robots achieve real but tighter clearance (0.1-0.2), which turned + out to matter a lot in cramped scenes (e.g. Room at 6-9 agents). + + Obstacle/wall collision is still checked against the real environment via + env.compute_sdf (correctly handles both sphere obstacles and box walls), + just with robot_radius as the margin instead of the stricter self-collision + field convention. + + pos: (B, H, num_agents, 2) tensor of positions. + Returns: (B,) bool tensor, True if that sample is fully collision-free. + """ + B, H, N, _ = pos.shape + + sdf = env.compute_sdf(pos.reshape(B, H * N, 2)).reshape(B, H, N) + obj_free = (sdf > robot_radius - threshold).reshape(B, -1).all(dim=1) + + diff = pos.unsqueeze(3) - pos.unsqueeze(2) # (B,H,N,N,2) + dist2 = (diff ** 2).sum(-1) # (B,H,N,N) + mask = ~torch.eye(N, dtype=torch.bool, device=pos.device) # ignore diagonal (self pairs) + min_required2 = (2 * robot_radius) ** 2 - threshold + pair_ok = (dist2 >= min_required2) | ~mask.unsqueeze(0).unsqueeze(0) + robot_free = pair_ok.reshape(B, -1).all(dim=1) + + return obj_free & robot_free + + +def straight_line_init_traj4proj(start_pos, goal_pos, num_agents, horizon): + init = {} + for j in range(num_agents): + traj = np.stack([ + start_pos[j] + (goal_pos[j] - start_pos[j]) * (i / (horizon - 1)) + for i in range(horizon) + ], axis=0) + init[j] = traj + return init + + +def get_test_instances(cfg, task, tensor_args): + """Returns a list of (start_pos (N,2) np array, goal_pos (N,2) np array).""" + num_agents = cfg['num_agents'] + if cfg['num_obstacles'] > 0 or cfg['scenario'] == 'empty': + pkl_path = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(pkl_path, 'rb') as f: + instances = pickle.load(f) + bucket = BUCKET_BY_N[num_agents] + out = [] + for i in range(min(N_TEST_INSTANCES, len(instances))): + _, agents_info = instances[i][bucket] + start_pos = np.stack([a[0] for a in agents_info], axis=0) + goal_pos = np.stack([a[1] for a in agents_info], axis=0) + out.append((start_pos, goal_pos)) + return out + else: + # Fixed wall geometry (corridor/room/shelf): sample fresh instances here. + fix_random_seed(EVAL_SEED) + out = [] + for _ in range(N_TEST_INSTANCES): + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > cfg['threshold_start_goal_pos']: + break + start_pos = s.detach().cpu().numpy().reshape(num_agents, 2) + goal_pos = g.detach().cpu().numpy().reshape(num_agents, 2) + out.append((start_pos, goal_pos)) + return out + + +# When True, projection is disabled entirely (proj_params=None), turning the +# composite model into the plain diffusion baseline that SMD's Appendix C +# calls "DM" -- same trained weights, no augmented-Lagrangian projection. +# Results go to eval_results_dm.json so the SMD numbers are never overwritten. +NO_PROJECTION = False + + +def _build_for_instance(cfg, args, tensor_args, inst_idx): + """dataset/env/task/guide for ONE test instance's own obstacle layout.""" + train_subset, _, _, _ = get_dataset( + dataset_class='TrajectoryDataset', use_extra_objects=True, + obstacle_cutoff_margin=0.01, **args, tensor_args=tensor_args, + instance_idx=inst_idx, map_name=cfg['map_name'], + ) + ds = train_subset.dataset + ds.robot.dt = TRAJECTORY_DURATION / ds.n_support_points + return ds + + +def _make_guide(ds, tensor_args): + n = ds.n_support_points + dt = TRAJECTORY_DURATION / n + cost_l, w_l = [], [] + for cf in ds.task.get_collision_fields(): + cost_l.append(CostCollision(ds.robot, n, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + w_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_l.append(CostGPTrajectory(ds.robot, n, dt, sigma_gp=1.0, tensor_args=tensor_args)) + w_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + comp = CostComposite(ds.robot, n, cost_l, weights_cost_l=w_l, tensor_args=tensor_args) + return GuideManagerTrajectoriesWithVelocity( + ds, comp, clip_grad=True, interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n * 1.5), tensor_args=tensor_args) + + +def _startgoal(cfg, inst_idx): + """start/goal for THIS instance (same pkl indexing the env used).""" + import pickle as _pk + p = os.path.join(REPO_ROOT, 'instances_data', f"{cfg['map_name']}.pkl") + with open(p, 'rb') as f: + inst = _pk.load(f) + ag = inst[inst_idx][BUCKET_BY_N[cfg['num_agents']]][1] + return (np.stack([a[0] for a in ag]), np.stack([a[1] for a in ag])) + + +def run_scenario(cfg): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + model_dir = os.path.join(REPO_ROOT, 'data_trained_models', f"wide_{cfg['model_id']}") + out_name = f'eval_results_perinst_{str(BOX_COVER_CELL).replace(".","")}.json' + out_path = os.path.join(model_dir, out_name) + if os.path.exists(out_path): + print(f"[{cfg['tag']}] {out_name} already exists, skipping") + return json.load(open(out_path)) + + args = load_params_from_yaml(os.path.join(model_dir, 'args.yaml')) + + train_subset, train_dataloader, val_subset, val_dataloader = get_dataset( + dataset_class='TrajectoryDataset', + use_extra_objects=True, + obstacle_cutoff_margin=0.01, + **args, + tensor_args=tensor_args, + instance_idx=INSTANCE_IDX, + map_name=cfg['map_name'], + ) + dataset = train_subset.dataset + n_support_points = dataset.n_support_points + robot = dataset.robot + task = dataset.task + env = dataset.env + dt = TRAJECTORY_DURATION / n_support_points + robot.dt = dt + + diffusion_configs = dict( + variance_schedule=args['variance_schedule'], + n_diffusion_steps=args['n_diffusion_steps'], + predict_epsilon=args['predict_epsilon'], + ) + unet_configs = dict( + state_dim=dataset.state_dim, + n_support_points=dataset.n_support_points, + unet_input_dim=args['unet_input_dim'], + dim_mults=UNET_DIM_MULTS[args['unet_dim_mults_option']], + ) + diffusion_model = get_model( + model_class=args['diffusion_model_class'], + model=TemporalUnet(**unet_configs), + tensor_args=tensor_args, + **diffusion_configs, + **unet_configs, + ) + ckpt_name = 'ema_model_current_state_dict.pth' if args['use_ema'] else 'model_current_state_dict.pth' + diffusion_model.load_state_dict( + torch.load(os.path.join(model_dir, 'checkpoints', ckpt_name), map_location=tensor_args['device']) + ) + diffusion_model.eval() + freeze_torch_model_params(diffusion_model) + model = diffusion_model + model.warmup(horizon=n_support_points, device=device) + + collision_fields = task.get_collision_fields() + cost_collision_l = [] + weights_grad_cost_l = [] + for cf in collision_fields: + cost_collision_l.append(CostCollision(robot, n_support_points, field=cf, sigma_coll=1.0, tensor_args=tensor_args)) + weights_grad_cost_l.append(WEIGHT_GRAD_COST_COLLISION) + cost_smoothness_l = [CostGPTrajectory(robot, n_support_points, dt, sigma_gp=1.0, tensor_args=tensor_args)] + weights_grad_cost_l.append(WEIGHT_GRAD_COST_SMOOTHNESS) + cost_composite = CostComposite(robot, n_support_points, [*cost_collision_l, *cost_smoothness_l], + weights_cost_l=weights_grad_cost_l, tensor_args=tensor_args) + guide = GuideManagerTrajectoriesWithVelocity( + dataset, cost_composite, clip_grad=True, + interpolate_trajectories_for_collision=True, + num_interpolated_points=ceil(n_support_points * 1.5), + tensor_args=tensor_args, + ) + t_start_guide = ceil(START_GUIDE_STEPS_FRACTION * model.n_diffusion_steps) + + # instance i's start/goal MUST be paired with instance i's obstacles + n_inst = N_TEST_INSTANCES + test_instances = [_startgoal(cfg, i) for i in range(n_inst)] + + results = [] + for instance_idx, (start_pos, goal_pos) in enumerate(test_instances): + # rebuild env/task/guide for THIS instance's obstacle layout + dataset = _build_for_instance(cfg, args, tensor_args, instance_idx) + robot, task, env = dataset.robot, dataset.task, dataset.env + guide = _make_guide(dataset, tensor_args) + num_agents = cfg['num_agents'] + start_state_pos = torch.tensor(start_pos.flatten(), **tensor_args) + goal_state_pos = torch.tensor(goal_pos.flatten(), **tensor_args) + + hard_conds = dataset.get_hard_conditions( + torch.vstack((start_state_pos, goal_state_pos)), normalize=True + ) + init_traj4proj = straight_line_init_traj4proj(start_pos, goal_pos, num_agents, n_support_points) + + sample_fn_kwargs = dict( + guide=guide, + n_guide_steps=N_GUIDE_STEPS, + t_start_guide=t_start_guide, + noise_std_extra_schedule_fn=lambda x: 0.5, + ) + + print(f"\n==== [{cfg['tag']}] test instance {instance_idx} ====") + trajs_normalized_iters = model.run_inference( + None, hard_conds, + n_samples=N_SAMPLES, horizon=n_support_points, + return_chain=True, + sample_fn=ddpm_sample_fn, + **sample_fn_kwargs, + n_diffusion_steps_without_noise=N_DIFFUSION_STEPS_WITHOUT_NOISE, + dataset=dataset, + init_traj4proj=init_traj4proj, + proj_params=None if NO_PROJECTION else PROJ_PARAMS, + ) + trajs_iters = dataset.unnormalize_trajectories(trajs_normalized_iters) + trajs_final = trajs_iters[-1] # (n_samples, horizon, state_dim) + + # Feasibility checked against the paper's own official criterion + # (2*robot_radius inter-robot contact distance, real obstacle/wall SDF) + # -- see check_paths_feasible_batch docstring for why this replaced + # task.get_trajs_collision_and_free's stricter self-collision field. + pos_t = trajs_final[..., :robot.q_dim].reshape(N_SAMPLES, n_support_points, num_agents, 2) + feasible_mask = check_paths_feasible_batch(pos_t, robot.radius, env) + n_feasible_samples = int(feasible_mask.sum().item()) + sample0_feasible = bool(feasible_mask[0].item()) + + pos = trajs_final[..., :robot.q_dim].detach().cpu().numpy() + sample0 = pos[0].reshape(n_support_points, num_agents, 2).transpose(1, 0, 2) + diffs = np.diff(sample0, axis=1) + plen = np.linalg.norm(diffs, axis=-1).sum(axis=1).mean() + + print(f"instance {instance_idx}: sample0_feasible={sample0_feasible} avg_path_length={plen:.4f} " + f"feasible_samples={n_feasible_samples}/{pos.shape[0]}") + + # --- SMD Table 3-6 metrics ------------------------------------- + # A: mean |acceleration| over waypoints, averaged over agents, on + # sample 0 (finite differences at dt = duration/horizon). + # C: collision ratio = fraction of the 16 sampled trajectories that + # are NOT feasible (their Tables report this as "C"). + dt_ = TRAJECTORY_DURATION / n_support_points + _s0 = pos[0].reshape(n_support_points, num_agents, 2) + _acc = np.diff(_s0, n=2, axis=0) / (dt_ ** 2) + metric_A = float(np.linalg.norm(_acc, axis=-1).mean()) + metric_C = float(1.0 - (n_feasible_samples / max(pos.shape[0], 1))) + + results.append(dict( + instance_idx=instance_idx, + metric_A=metric_A, + metric_C=metric_C, + sample0_feasible=sample0_feasible, + avg_path_length=float(plen), + n_feasible_samples=n_feasible_samples, + n_samples=int(pos.shape[0]), + )) + + success_rate = sum(r['sample0_feasible'] for r in results) / len(results) + any_of_n_success_rate = sum(1 for r in results if r['n_feasible_samples'] > 0) / len(results) + feasible_lengths = [r['avg_path_length'] for r in results if r['sample0_feasible']] + avg_len = float(np.mean(feasible_lengths)) if feasible_lengths else float('nan') + total_samples = sum(r['n_samples'] for r in results) + total_feasible_samples = sum(r['n_feasible_samples'] for r in results) + + summary = dict( + tag=cfg['tag'], results=results, success_rate=success_rate, + metric_A=float(np.mean([r['metric_A'] for r in results])), + metric_C=float(np.mean([r['metric_C'] for r in results])), + projection_enabled=(not NO_PROJECTION), + any_of_n_success_rate=any_of_n_success_rate, + avg_path_length_over_success=avg_len, + sample_feasibility_rate=total_feasible_samples / total_samples, + ) + with open(os.path.join(model_dir, out_name), 'w') as f: + json.dump(summary, f, indent=2) + + print(f"\n[{cfg['tag']}] SUCCESS RATE: {success_rate:.2f} | ANY-OF-N: {any_of_n_success_rate:.2f} | " + f"A: {summary['metric_A']:.4f} | C: {summary['metric_C']:.4f} | " + f"AVG PATH LEN: {avg_len:.4f} | " + f"SAMPLE FEASIBILITY: {total_feasible_samples}/{total_samples}") + return summary + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--no-projection', action='store_true', + help="DM baseline: run the same models with projection " + "disabled; writes eval_results_dm.json") + args = parser.parse_args() + + if args.no_projection: + NO_PROJECTION = True + print("*** DM mode: projection DISABLED, writing eval_results_dm.json ***") + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/export_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/export_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..b79f00c9a23501dc4323746ade74bb84fe8a35e6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/export_instances.py @@ -0,0 +1,83 @@ +""" +Exports the held-out test instances for every scenario to a single .npz, so the +SMD-citing baselines (PCD, DiRecT, MDOC, ...) can be planned on the EXACT same +start/goal configurations as our SMD and MMD evaluations. + +Why a file instead of importing get_test_instances directly: each baseline lives +in its own conda env with its own (incompatible) `mmd`/`mdoc` package, so they +cannot share a process with this repo. Exporting once and passing an .npz around +is what makes the comparison genuinely apples-to-apples -- every method plans the +same 10 instances per scenario, and nothing can silently regenerate its own. + +This is a pure re-export of evaluate_smd_all.get_test_instances(): the random-map +scenarios read instances_data/.pkl, and the fixed-wall scenarios +(corridor/room/shelf) resample deterministically under EVAL_SEED. Running it +twice gives byte-identical output. + +Usage: + python export_instances.py # all scenarios + python export_instances.py --out /path/inst.npz + python export_instances.py --tags room-6,shelf-3 +""" +import os +import sys +import argparse + +import numpy as np +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +from torch_robotics.torch_utils.torch_utils import get_torch_device + +from scenarios import SCENARIOS, get_scenario +from evaluate_smd_all import get_test_instances, N_TEST_INSTANCES +from evaluate_mmd_all import build_composite_reference + +DEFAULT_OUT = os.path.join(REPO_ROOT, 'baselines_eval', 'instances.npz') + + +def export(tags=None, out_path=DEFAULT_OUT): + device = get_torch_device(device='cuda' if torch.cuda.is_available() else 'cpu') + tensor_args = {'device': device, 'dtype': torch.float32} + + cfgs = [get_scenario(t) for t in tags] if tags else SCENARIOS + + blob = {} + meta = [] + for cfg in cfgs: + tag = cfg['tag'] + env_comp, robot_comp, task_comp = build_composite_reference(cfg, tensor_args) + instances = get_test_instances(cfg, task_comp, tensor_args) + + starts = np.stack([s for s, _ in instances], axis=0) # (n_inst, num_agents, 2) + goals = np.stack([g for _, g in instances], axis=0) + + blob[f'{tag}/starts'] = starts.astype(np.float64) + blob[f'{tag}/goals'] = goals.astype(np.float64) + + # robot radius travels with the instances -- the feasibility criterion + # depends on it, and a baseline that assumes a different radius would + # silently be solving a different problem (cf. DGD's 0.04 vs our 0.05). + radius = float(robot_comp.radius) + meta.append(dict(tag=tag, scenario=cfg['scenario'], num_agents=cfg['num_agents'], + env_id=cfg['env_id'], map_name=cfg['map_name'], + robot_id=cfg['robot_id'], num_obstacles=cfg['num_obstacles'], + radius=radius, n_instances=int(starts.shape[0]))) + print(f"{tag:>12s} n={starts.shape[0]:2d} agents={cfg['num_agents']} " + f"radius={radius} start[0][0]={starts[0][0].round(4).tolist()}") + + os.makedirs(os.path.dirname(out_path), exist_ok=True) + np.savez(out_path, meta=np.array(meta, dtype=object), **blob) + print(f"\nwrote {out_path} ({len(meta)} scenarios, {N_TEST_INSTANCES} instances each)") + return out_path + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--out', default=DEFAULT_OUT) + ap.add_argument('--tags', default=None, help='comma-separated scenario tags') + a = ap.parse_args() + export(a.tags.split(',') if a.tags else None, a.out) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle.py new file mode 100644 index 0000000000000000000000000000000000000000..59deda94f192ce9554cf1009167acd2d64119500 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle.py @@ -0,0 +1,223 @@ +"""Per-instance feasibility oracle. + +Answers, for one sampled (starts, goals) set: does a collision-free, velocity-respecting +plan exist for all N robots? Only the POSITIVE direction is trusted -- we certify by +exhibiting a plan. Failing to find one is reported as 'unverified', never as 'infeasible', +so an instance we cannot certify is excluded from the denominator rather than being scored +against a method. + +Deliberately NOT IPOPT: it is a local solver on a non-convex program, so non-convergence +says nothing about existence. And deliberately not generate_via_cbs, whose low-level +planner is the trained diffusion model -- feasibility would then depend on the model under +test. + +Layer 1 per-robot geodesic on the free-space grid (exact on the grid): + - is the goal reachable from the start through free space? + - is the geodesic <= the travel budget v_max * duration? + These are the two failure modes actually observed: crossroom x2.20 sealed its + quadrants, and a too-slow velocity limit put the goal out of reach in the horizon. + +Layer 2 prioritised space-time A* for the coupling, which layer 1 cannot see (robots + blocking one another). A plan found here is a certificate. Prioritised planning + is incomplete, so failure -> 'unverified'. +""" +import numpy as np +import torch +from scipy import ndimage + +try: + from skimage.graph import MCP_Geometric +except Exception: + MCP_Geometric = None + +# The oracle must use the SAME feasibility definition as the scorer, or it rejects +# instances the scorer would accept. Trajectories are scored by +# check_paths_feasible_batch, which tests SDF > robot_radius -- obstacle_cutoff_margin +# is a planner COST term, not a hard requirement. Using r+0.055 made dense look +# disconnected (10 components, P_same 0.37) when at the scored clearance it is a single +# component (P_same 1.00), which is how the SMD paper reports Dense at scale 1.0 at all. +CLEARANCE_MARGIN = float(__import__('os').environ.get('ORACLE_CLEARANCE_MARGIN') or '0.0') + + +_MASK_CACHE = {} + + +def _free_mask(env, robot_radius, G=400): + """Memoised. The SDF sweep is ~160k point evaluations and was being redone for + every instance and both layers -- roughly 40 rebuilds per (map, N) cell, which is + what made certification the bottleneck. It depends only on (env, radius, G).""" + key = (id(env), round(float(robot_radius), 6), G) + hit = _MASK_CACHE.get(key) + if hit is None: + hit = _free_mask_uncached(env, robot_radius, G) + _MASK_CACHE[key] = hit + return hit + + +def _free_mask_uncached(env, robot_radius, G=400): + xs = np.linspace(-1, 1, G) + X, Y = np.meshgrid(xs, xs) + pts = torch.tensor(np.stack([X.ravel(), Y.ravel()], -1), dtype=torch.float32) + with torch.no_grad(): + S = env.compute_sdf(pts.reshape(1, -1, 2)).reshape(G, G).numpy() + S = np.minimum(S, np.minimum(1 - np.abs(X), 1 - np.abs(Y))) # workspace walls count + return S > (robot_radius + CLEARANCE_MARGIN), 2.0 / (G - 1) + + +def _rc(p, G=400): + """world (x,y) -> (row, col), clamped into the grid.""" + c = int(round((p[0] + 1) / 2 * (G - 1))) + r = int(round((p[1] + 1) / 2 * (G - 1))) + return max(0, min(G - 1, r)), max(0, min(G - 1, c)) + + +def _nearest_free(free, rc, max_r=16): + """Snap a point to the nearest free cell. + + Instances are sampled at the task's own clearance (robot radius + its + obstacle_cutoff_margin), while this mask uses the planner's stricter 0.105. A legal + endpoint can therefore sit a few cells outside the mask without the instance being + infeasible, so search a generous neighbourhood (16 cells ~ 0.08 world units at G=400) + before declaring it unreachable.""" + if free[rc]: + return rc + G = free.shape[0] + for rad in range(1, max_r + 1): + r0, c0 = rc + for dr in range(-rad, rad + 1): + for dc in range(-rad, rad + 1): + if max(abs(dr), abs(dc)) != rad: + continue + r, c = r0 + dr, c0 + dc + if 0 <= r < G and 0 <= c < G and free[r, c]: + return (r, c) + return None + + +def layer1(env, robot_radius, starts, goals, v_max, duration, G=400): + """Per-robot reachability and travel budget. Exact on the grid.""" + if MCP_Geometric is None: + return None, 'skimage unavailable' + free, cell = _free_mask(env, robot_radius, G) + budget = v_max * duration + out = [] + cost = np.where(free, 1.0, np.inf) + for i, (s, g) in enumerate(zip(starts, goals)): + rs, rg = _nearest_free(free, _rc(s, G)), _nearest_free(free, _rc(g, G)) + if rs is None or rg is None: + out.append({'robot': i, 'ok': False, 'reason': 'endpoint not in free space'}) + continue + mcp = MCP_Geometric(cost) + cum, _ = mcp.find_costs([rs]) + d = cum[rg] + if not np.isfinite(d): + out.append({'robot': i, 'ok': False, 'reason': 'goal unreachable (disconnected)'}) + continue + L = float(d * cell) + ok = L <= budget + out.append({'robot': i, 'ok': bool(ok), 'geodesic': round(L, 3), + 'budget': round(budget, 3), + 'reason': None if ok else f'geodesic {L:.2f} > budget {budget:.2f}'}) + return out, None + + +def layer2(env, robot_radius, starts, goals, v_max, duration, horizon, Gc=None, + max_expansions=400_000): + """Prioritised space-time A*. Returns (certified, reason). + + Grid pitch is one motion step (v_max*dt) so a move is one cell; robots are separated + by 2*radius. A plan found is a certificate; anything else is 'unverified'. + """ + import heapq + dt = duration / horizon + step = v_max * dt + # Grid pitch must be FINER than one motion step, or narrow gaps vanish: at one-cell- + # per-step, crossroom@1.10 came out 28x28 and its edge gaps sealed, so robot 0 failed + # with no other robot even present -- a resolution artifact, not a coupling conflict. + # Resolve at ~step/SUB and allow up to SUB cells of movement per timestep. + SUB = 3 + if Gc is None: + Gc = int(np.clip(round(2.0 * SUB / max(step, 1e-6)), 48, 220)) + free, cell = _free_mask(env, robot_radius, Gc) + reach = max(1, int(np.floor(step / cell))) # cells traversable in one timestep + sep_cells = max(1, int(np.ceil(2 * robot_radius / cell))) + T = horizon + occ = {} # (t, r, c) -> robot id, for priority order + + def blocked(r, c, t): + for dr in range(-sep_cells, sep_cells + 1): + for dc in range(-sep_cells, sep_cells + 1): + if dr * dr + dc * dc > sep_cells * sep_cells: + continue + if (t, r + dr, c + dc) in occ: + return True + return False + + moves = [(dr, dc) + for dr in range(-reach, reach + 1) + for dc in range(-reach, reach + 1) + if dr * dr + dc * dc <= reach * reach] # includes (0,0) = wait + for i, (s, g) in enumerate(zip(starts, goals)): + rs, rg = _nearest_free(free, _rc(s, Gc)), _nearest_free(free, _rc(g, Gc)) + if rs is None or rg is None: + return False, f'robot {i}: endpoint not in free space' + h = lambda rc: max(abs(rc[0] - rg[0]), abs(rc[1] - rg[1])) / max(reach, 1) + start = (0, rs[0], rs[1]) + openq = [(h(rs), 0, start)] + came, gsc, exp = {start: None}, {start: 0}, 0 + goal_state = None + while openq: + _, _, cur = heapq.heappop(openq) + exp += 1 + if exp > max_expansions: + return False, f'robot {i}: search budget exhausted' + t, r, c = cur + if (r, c) == rg: + goal_state = cur + break + if t + 1 >= T: + continue + for dr, dc in moves: + nr, nc = r + dr, c + dc + if not (0 <= nr < Gc and 0 <= nc < Gc) or not free[nr, nc]: + continue + if blocked(nr, nc, t + 1): + continue + ns = (t + 1, nr, nc) + ng = gsc[cur] + 1 + if ng < gsc.get(ns, 1 << 30): + gsc[ns] = ng; came[ns] = cur + heapq.heappush(openq, (ng + h((nr, nc)), ng, ns)) + if goal_state is None: + return False, f'robot {i}: no space-time path under priority order' + node = goal_state # reserve this robot's path, then park it + path = [] + while node is not None: + path.append(node); node = came[node] + for (t, r, c) in path: + occ[(t, r, c)] = i + tg, rg_, cg_ = goal_state + for t in range(tg, T): + occ[(t, rg_, cg_)] = i + return True, None + + +def certify(env, robot_radius, starts, goals, v_max, duration, horizon, + run_layer2=True): + """Full verdict for one instance.""" + l1, err = layer1(env, robot_radius, starts, goals, v_max, duration) + if err: + return {'certified': False, 'status': 'unverified', 'reason': err} + bad = [d for d in l1 if not d['ok']] + if bad: + return {'certified': False, 'status': 'infeasible', 'layer': 1, + 'reason': f"robot {bad[0]['robot']}: {bad[0]['reason']}", + 'per_robot': l1} + if not run_layer2: + return {'certified': True, 'status': 'certified', 'layer': 1, 'per_robot': l1} + ok, why = layer2(env, robot_radius, starts, goals, v_max, duration, horizon) + if ok: + return {'certified': True, 'status': 'certified', 'layer': 2, 'per_robot': l1} + # layer 1 passed, coupling not shown: NOT a claim of infeasibility + return {'certified': False, 'status': 'unverified', 'layer': 2, + 'reason': why, 'per_robot': l1} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle_rbt.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle_rbt.py new file mode 100644 index 0000000000000000000000000000000000000000..d6cc25676e0bc6d3759e7725aef361aac2ef37e3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/feasibility_oracle_rbt.py @@ -0,0 +1,235 @@ +"""Per-instance feasibility oracle. + +Answers, for one sampled (starts, goals) set: does a collision-free, velocity-respecting +plan exist for all N robots? Only the POSITIVE direction is trusted -- we certify by +exhibiting a plan. Failing to find one is reported as 'unverified', never as 'infeasible', +so an instance we cannot certify is excluded from the denominator rather than being scored +against a method. + +Deliberately NOT IPOPT: it is a local solver on a non-convex program, so non-convergence +says nothing about existence. And deliberately not generate_via_cbs, whose low-level +planner is the trained diffusion model -- feasibility would then depend on the model under +test. + +Layer 1 per-robot geodesic on the free-space grid (exact on the grid): + - is the goal reachable from the start through free space? + - is the geodesic <= the travel budget v_max * duration? + These are the two failure modes actually observed: crossroom x2.20 sealed its + quadrants, and a too-slow velocity limit put the goal out of reach in the horizon. + +Layer 2 prioritised space-time A* for the coupling, which layer 1 cannot see (robots + blocking one another). A plan found here is a certificate. Prioritised planning + is incomplete, so failure -> 'unverified'. +""" +import numpy as np +import torch +from scipy import ndimage + +try: + from skimage.graph import MCP_Geometric +except Exception: + MCP_Geometric = None + +# The oracle must use the SAME feasibility definition as the scorer, or it rejects +# instances the scorer would accept. Trajectories are scored by +# check_paths_feasible_batch, which tests SDF > robot_radius -- obstacle_cutoff_margin +# is a planner COST term, not a hard requirement. Using r+0.055 made dense look +# disconnected (10 components, P_same 0.37) when at the scored clearance it is a single +# component (P_same 1.00), which is how the SMD paper reports Dense at scale 1.0 at all. +CLEARANCE_MARGIN = float(__import__('os').environ.get('ORACLE_CLEARANCE_MARGIN') or '0.0') + + +_MASK_CACHE = {} + + +def _free_mask(env, robot_radius, G=400): + """Memoised. The SDF sweep is ~160k point evaluations and was being redone for + every instance and both layers -- roughly 40 rebuilds per (map, N) cell, which is + what made certification the bottleneck. It depends only on (env, radius, G).""" + key = (id(env), round(float(robot_radius), 6), G) + hit = _MASK_CACHE.get(key) + if hit is None: + hit = _free_mask_uncached(env, robot_radius, G) + _MASK_CACHE[key] = hit + return hit + + +def _arena(env): + lim = env.limits.detach().cpu().numpy() + XLIM = float(max(abs(lim[0, 0]), abs(lim[1, 0]))) + YLIM = float(max(abs(lim[0, 1]), abs(lim[1, 1]))) + return max(XLIM, YLIM), XLIM, YLIM + +def _free_mask_uncached(env, robot_radius, G=400): + HALF, XLIM, YLIM = _arena(env) + xs = np.linspace(-HALF, HALF, G) + X, Y = np.meshgrid(xs, xs) + pts = torch.tensor(np.stack([X.ravel(), Y.ravel()], -1), dtype=torch.float32) + with torch.no_grad(): + S = env.compute_sdf(pts.reshape(1, -1, 2)).reshape(G, G).numpy() + S = np.minimum(S, np.minimum(XLIM - np.abs(X), YLIM - np.abs(Y))) + return S > (robot_radius + CLEARANCE_MARGIN), 2.0 * HALF / (G - 1) + + +def _rc(p, G=400, HALF=1.0): + """world (x,y) -> (row, col) over [-HALF,HALF]^2, clamped.""" + c = int(round((p[0] + HALF) / (2 * HALF) * (G - 1))) + r = int(round((p[1] + HALF) / (2 * HALF) * (G - 1))) + return max(0, min(G - 1, r)), max(0, min(G - 1, c)) + + +def _nearest_free(free, rc, max_r=16): + """Snap a point to the nearest free cell. + + Instances are sampled at the task's own clearance (robot radius + its + obstacle_cutoff_margin), while this mask uses the planner's stricter 0.105. A legal + endpoint can therefore sit a few cells outside the mask without the instance being + infeasible, so search a generous neighbourhood (16 cells ~ 0.08 world units at G=400) + before declaring it unreachable.""" + if free[rc]: + return rc + G = free.shape[0] + for rad in range(1, max_r + 1): + r0, c0 = rc + for dr in range(-rad, rad + 1): + for dc in range(-rad, rad + 1): + if max(abs(dr), abs(dc)) != rad: + continue + r, c = r0 + dr, c0 + dc + if 0 <= r < G and 0 <= c < G and free[r, c]: + return (r, c) + return None + + +def layer1(env, robot_radius, starts, goals, v_max, duration, G=400): + """Per-robot reachability and travel budget. Exact on the grid.""" + if MCP_Geometric is None: + return None, 'skimage unavailable' + free, cell = _free_mask(env, robot_radius, G) + HALF = _arena(env)[0] + budget = v_max * duration + out = [] + cost = np.where(free, 1.0, np.inf) + for i, (s, g) in enumerate(zip(starts, goals)): + rs, rg = _nearest_free(free, _rc(s, G, HALF)), _nearest_free(free, _rc(g, G, HALF)) + if rs is None or rg is None: + out.append({'robot': i, 'ok': False, 'reason': 'endpoint not in free space'}) + continue + mcp = MCP_Geometric(cost) + cum, _ = mcp.find_costs([rs]) + d = cum[rg] + if not np.isfinite(d): + out.append({'robot': i, 'ok': False, 'reason': 'goal unreachable (disconnected)'}) + continue + L = float(d * cell) + ok = L <= budget + out.append({'robot': i, 'ok': bool(ok), 'geodesic': round(L, 3), + 'budget': round(budget, 3), + 'reason': None if ok else f'geodesic {L:.2f} > budget {budget:.2f}'}) + return out, None + + +def layer2(env, robot_radius, starts, goals, v_max, duration, horizon, Gc=None, + max_expansions=400_000): + """Prioritised space-time A*. Returns (certified, reason). + + Grid pitch is one motion step (v_max*dt) so a move is one cell; robots are separated + by 2*radius. A plan found is a certificate; anything else is 'unverified'. + """ + import heapq + dt = duration / horizon + step = v_max * dt + # Grid pitch must be FINER than one motion step, or narrow gaps vanish: at one-cell- + # per-step, crossroom@1.10 came out 28x28 and its edge gaps sealed, so robot 0 failed + # with no other robot even present -- a resolution artifact, not a coupling conflict. + # Resolve at ~step/SUB and allow up to SUB cells of movement per timestep. + SUB = 3 + if Gc is None: + # FO_GC overrides the pitch when a passable band is narrower than the default + # cell (RBT_WIDE corridor: 0.03 m band vs 0.05 m cell -> channel sealed). + _env_gc = __import__('os').environ.get('FO_GC') + Gc = int(_env_gc) if _env_gc else int(np.clip(round(2.0 * SUB / max(step, 1e-6)), 48, 220)) + free, cell = _free_mask(env, robot_radius, Gc) + HALF = _arena(env)[0] + reach = max(1, int(np.floor(step / cell))) # cells traversable in one timestep + sep_cells = max(1, int(np.ceil(2 * robot_radius / cell))) + T = horizon + occ = {} # (t, r, c) -> robot id, for priority order + + def blocked(r, c, t): + for dr in range(-sep_cells, sep_cells + 1): + for dc in range(-sep_cells, sep_cells + 1): + if dr * dr + dc * dc > sep_cells * sep_cells: + continue + if (t, r + dr, c + dc) in occ: + return True + return False + + moves = [(dr, dc) + for dr in range(-reach, reach + 1) + for dc in range(-reach, reach + 1) + if dr * dr + dc * dc <= reach * reach] # includes (0,0) = wait + for i, (s, g) in enumerate(zip(starts, goals)): + rs, rg = _nearest_free(free, _rc(s, Gc, HALF)), _nearest_free(free, _rc(g, Gc, HALF)) + if rs is None or rg is None: + return False, f'robot {i}: endpoint not in free space' + h = lambda rc: max(abs(rc[0] - rg[0]), abs(rc[1] - rg[1])) / max(reach, 1) + start = (0, rs[0], rs[1]) + openq = [(h(rs), 0, start)] + came, gsc, exp = {start: None}, {start: 0}, 0 + goal_state = None + while openq: + _, _, cur = heapq.heappop(openq) + exp += 1 + if exp > max_expansions: + return False, f'robot {i}: search budget exhausted' + t, r, c = cur + if (r, c) == rg: + goal_state = cur + break + if t + 1 >= T: + continue + for dr, dc in moves: + nr, nc = r + dr, c + dc + if not (0 <= nr < Gc and 0 <= nc < Gc) or not free[nr, nc]: + continue + if blocked(nr, nc, t + 1): + continue + ns = (t + 1, nr, nc) + ng = gsc[cur] + 1 + if ng < gsc.get(ns, 1 << 30): + gsc[ns] = ng; came[ns] = cur + heapq.heappush(openq, (ng + h((nr, nc)), ng, ns)) + if goal_state is None: + return False, f'robot {i}: no space-time path under priority order' + node = goal_state # reserve this robot's path, then park it + path = [] + while node is not None: + path.append(node); node = came[node] + for (t, r, c) in path: + occ[(t, r, c)] = i + tg, rg_, cg_ = goal_state + for t in range(tg, T): + occ[(t, rg_, cg_)] = i + return True, None + + +def certify(env, robot_radius, starts, goals, v_max, duration, horizon, + run_layer2=True): + """Full verdict for one instance.""" + l1, err = layer1(env, robot_radius, starts, goals, v_max, duration) + if err: + return {'certified': False, 'status': 'unverified', 'reason': err} + bad = [d for d in l1 if not d['ok']] + if bad: + return {'certified': False, 'status': 'infeasible', 'layer': 1, + 'reason': f"robot {bad[0]['robot']}: {bad[0]['reason']}", + 'per_robot': l1} + if not run_layer2: + return {'certified': True, 'status': 'certified', 'layer': 1, 'per_robot': l1} + ok, why = layer2(env, robot_radius, starts, goals, v_max, duration, horizon) + if ok: + return {'certified': True, 'status': 'certified', 'layer': 2, 'per_robot': l1} + # layer 1 passed, coupling not shown: NOT a claim of infeasibility + return {'certified': False, 'status': 'unverified', 'layer': 2, + 'reason': why, 'per_robot': l1} diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data.py new file mode 100644 index 0000000000000000000000000000000000000000..a1d4ad874de63eb07556d55bd86786b9c3ab48f6 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data.py @@ -0,0 +1,162 @@ +""" +Generate training demonstrations for SMD on the "empty" random-map scenario +with a 3-robot composite planar-disk robot. + +This reimplements (for our composite/multi-robot robot) the same +generate_trajectories.py pipeline used upstream in mpd-public: sample a +random collision-free start/goal pair in the composite robot's joint +configuration space (this makes inter-robot collision avoidance and per-robot +obstacle avoidance just "collision avoidance for one big robot"), plan a +path with RRT-Connect, smooth/resample it to a fixed horizon with cubic +splines (matching torch_robotics' smoothen_trajectory, used identically in +mpd-public's data generation), and keep it if the resampled trajectory is +still collision-free. + +Output layout matches what smd.datasets.trajectories.TrajectoryDatasetBase +expects to load: + data_trajectories///args.yaml + data_trajectories///metadata.yaml + data_trajectories///trajs-free.pt +""" +import os +import sys +import time +import yaml +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask +from torch_robotics.trajectory.utils import smoothen_trajectory +from mp_baselines.planners.rrt_connect import RRTConnect + + +ENV_ID = 'EnvEmptyNoWait2D' # base name written to metadata; loader appends 'ExtraObjects' +ROBOT_ID = 'RobotCompositeThreePlanarDisk' +DATASET_SUBDIR = f'{ENV_ID}-{ROBOT_ID}' +MAP_NAME = 'empty_map' +INSTANCE_IDX = 0 # obstacle set to use while generating data (always empty here) + +N_TASKS = 300 # number of distinct start/goal contexts +N_TRAJ_PER_TASK = 8 # RRT-Connect samples per context (RRT-Connect is randomized) +N_SUPPORT_POINTS = 64 +DURATION = 5.0 +THRESHOLD_START_GOAL_POS = 1.0 +OBSTACLE_CUTOFF_MARGIN = 0.03 + +RRT_STEP_SIZE = 0.05 +RRT_N_RADIUS = 0.3 +RRT_N_ITERS = 5000 +RRT_MAX_TIME = 15.0 +RRT_N_PRE_SAMPLES = 5000 + + +def build_task(tensor_args): + env_class = getattr(environments, ENV_ID + 'ExtraObjects') + env = env_class(tensor_args=tensor_args, instance_idx=INSTANCE_IDX, map_name=MAP_NAME) + + robot_class = getattr(robots, ROBOT_ID) + robot = robot_class(tensor_args=tensor_args) + + task = PlanningTask( + env=env, robot=robot, tensor_args=tensor_args, + obstacle_cutoff_margin=OBSTACLE_CUTOFF_MARGIN, + ) + return env, robot, task + + +def generate_one_task(task, robot, tensor_args, results_dir, task_id): + # Sample a start/goal pair far enough apart in the composite config space. + start_state_pos, goal_state_pos = None, None + for _ in range(200): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > THRESHOLD_START_GOAL_POS: + start_state_pos, goal_state_pos = s, g + break + if start_state_pos is None: + print(f"[task {task_id}] could not sample a far-enough start/goal pair, skipping") + return 0 + + trajs_free_l = [] + for attempt in range(N_TRAJ_PER_TASK): + planner = RRTConnect( + task=task, + n_iters=RRT_N_ITERS, + start_state_pos=start_state_pos, + goal_state_pos=goal_state_pos, + step_size=RRT_STEP_SIZE, + n_radius=RRT_N_RADIUS, + max_time=RRT_MAX_TIME, + tensor_args=tensor_args, + n_pre_samples=RRT_N_PRE_SAMPLES, + ) + path = planner.optimize(debug=False) + if path is None or len(path) < 2: + continue + + path_t = torch.stack(list(path)) # (N, q_dim) + dt = DURATION / N_SUPPORT_POINTS + pos, vel = smoothen_trajectory( + path_t, n_support_points=N_SUPPORT_POINTS, dt=dt, + set_average_velocity=True, tensor_args=tensor_args, + ) + traj = torch.cat((pos, vel), dim=-1).unsqueeze(0) # (1, H, 2*q_dim) + + _, traj_free = task.get_trajs_collision_and_free(traj) + if traj_free is not None and traj_free.nelement() > 0: + trajs_free_l.append(traj_free) + + if len(trajs_free_l) == 0: + print(f"[task {task_id}] no collision-free trajectories found") + return 0 + + trajs_free = torch.cat(trajs_free_l, dim=0) + + task_dir = os.path.join(results_dir, str(task_id)) + os.makedirs(task_dir, exist_ok=True) + torch.save(trajs_free.cpu(), os.path.join(task_dir, 'trajs-free.pt')) + + with open(os.path.join(task_dir, 'args.yaml'), 'w') as f: + yaml.dump({ + 'threshold_start_goal_pos': THRESHOLD_START_GOAL_POS, + 'obstacle_cutoff_margin': OBSTACLE_CUTOFF_MARGIN, + }, f) + + with open(os.path.join(task_dir, 'metadata.yaml'), 'w') as f: + yaml.dump({ + 'env_id': ENV_ID, + 'robot_id': ROBOT_ID, + 'instance_idx': INSTANCE_IDX, + 'map_name': MAP_NAME, + 'num_trajectories_free': int(trajs_free.shape[0]), + }, f) + + print(f"[task {task_id}] saved {trajs_free.shape[0]} free trajectories") + return trajs_free.shape[0] + + +def main(): + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tensor_args = {'device': device, 'dtype': torch.float32} + + results_dir = os.path.join(REPO_ROOT, 'data_trajectories', DATASET_SUBDIR) + os.makedirs(results_dir, exist_ok=True) + + env, robot, task = build_task(tensor_args) + + t_start = time.time() + total = 0 + for task_id in range(N_TASKS): + total += generate_one_task(task, robot, tensor_args, results_dir, task_id) + if task_id % 10 == 0: + elapsed = time.time() - t_start + print(f"--- progress: {task_id + 1}/{N_TASKS} tasks, {total} total trajs, {elapsed:.1f}s elapsed ---") + + print(f"DONE. {total} total free trajectories across {N_TASKS} tasks in {time.time() - t_start:.1f}s") + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data_all.py new file mode 100644 index 0000000000000000000000000000000000000000..e08e8f07a3c2ebe833c05c520d575263c276ffcd --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_data_all.py @@ -0,0 +1,336 @@ +""" +Generates training demonstrations for any (env, composite robot, map_name) +scenario defined in scenarios.py. + +Method: batch straight-line-plus-perturbation sampling instead of RRT-Connect. +RRT-Connect over the composite robot's joint space (state_dim = 2*num_agents) +gets dramatically harder as num_agents grows -- it worked fine at 3 and 6 +agents (~150s and ~70min respectively for 300 tasks) but even with scaled-up +iteration/time budgets, 9-agent (18-dim) joint-space tree search was still +projected at 6+ hours for a single scenario. Since obstacle/collision +avoidance for a *specific* evaluation scenario is enforced at inference time +by SMD's projection + guide mechanism (not baked into the training weights -- +this was true even for the already-validated empty-3 case), the training +demonstrations just need to be a diverse set of *real, collision-free* +start-to-goal trajectories for the diffusion prior to learn from. They don't +need to come from a tree-search planner specifically. + +So: sample a random start/goal pair, build a straight-line interpolation plus +several randomly-perturbed (smooth sinusoidal bump) variants, batch-check all +variants for collision in one vectorized GPU call (self-collision + object/ +obstacle collision + workspace bounds -- the real collision fields, same ones +RRT-Connect itself would have used), and keep whichever variants come back +collision-free. This turns a sequential tree-search problem into a handful of +GPU tensor ops, independent of robot count. + +Usage: + python generate_data_all.py # e.g. empty-6, basic-3, room-9 + python generate_data_all.py --all # run every scenario in order +""" +import os +import sys +import math +import time +import argparse +import yaml +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask + +from scenarios import SCENARIOS, get_scenario + +N_SUPPORT_POINTS = 64 +DURATION = 5.0 +OBSTACLE_CUTOFF_MARGIN = 0.03 +INSTANCE_IDX = 0 + + +def gen_params_for(num_agents, has_obstacles): + """More contexts / variants for scenarios that turn out harder to satisfy + (tighter geometry -> lower per-context acceptance rate) -- all still cheap + since there's no tree search, just batched collision checks. 9 agents with + real obstacles/walls is the hardest tier: a single-bump detour essentially + never clears everything simultaneously, so it gets many more contexts.""" + if num_agents <= 3: + return dict(n_contexts=400, k_variants=12) + elif num_agents <= 6: + return dict(n_contexts=600, k_variants=20) + elif not has_obstacles: + return dict(n_contexts=500, k_variants=24) + else: # 9 agents + obstacles/walls: hardest tier + return dict(n_contexts=4000, k_variants=40) + + +def build_task(cfg, tensor_args): + env_class = getattr(environments, cfg['env_id'] + 'ExtraObjects') + env = env_class(tensor_args=tensor_args, instance_idx=INSTANCE_IDX, map_name=cfg['map_name']) + + robot_class = getattr(robots, cfg['robot_id']) + robot = robot_class(tensor_args=tensor_args) + + task = PlanningTask( + env=env, robot=robot, tensor_args=tensor_args, + obstacle_cutoff_margin=OBSTACLE_CUTOFF_MARGIN, + ) + return env, robot, task + + +def sample_context(task, threshold, max_tries=300): + for _ in range(max_tries): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > threshold: + return s, g + return None, None + + +def build_candidate_batch(start, goal, k_variants, n_support_points, dt, tensor_args): + q_dim = start.shape[0] + alphas = torch.linspace(0, 1, n_support_points, **tensor_args).unsqueeze(-1) # (H,1) + base_pos = start.unsqueeze(0) * (1 - alphas) + goal.unsqueeze(0) * alphas # (H, q_dim) + t_norm = torch.linspace(0, 1, n_support_points, **tensor_args) # (H,) + envelope = torch.sin(math.pi * t_norm).unsqueeze(-1) # 0 at both ends, peak at middle, (H,1) + + pos_variants = [base_pos] + for _ in range(k_variants - 1): + # Sum of 1-2 random sinusoidal harmonics (random freq/phase), independent + # per-agent direction/magnitude -- richer detour shapes than a single bump, + # needed so *some* variant clears every obstacle for *every* agent at once + # in cluttered scenes. + detour = torch.zeros(n_support_points, q_dim, **tensor_args) + n_harmonics = int(torch.randint(1, 3, (1,)).item()) + for _h in range(n_harmonics): + freq = float(torch.randint(1, 3, (1,)).item()) + phase = torch.rand(1, **tensor_args).item() * math.pi + shape = (torch.sin(freq * math.pi * t_norm + phase).unsqueeze(-1) * envelope) # (H,1) + noise_scale = 0.05 + 0.45 * torch.rand(1, **tensor_args).item() + offset_dir = (torch.rand(q_dim, **tensor_args) * 2 - 1) * noise_scale + detour = detour + shape * offset_dir.unsqueeze(0) + pos_variants.append(base_pos + detour) + pos_batch = torch.stack(pos_variants, dim=0) # (K, H, q_dim) + + avg_vel = (goal - start) / (n_support_points * dt) + vel_batch = torch.zeros_like(pos_batch) + vel_batch[:, 1:-1, :] = avg_vel.unsqueeze(0).unsqueeze(0) + + return torch.cat([pos_batch, vel_batch], dim=-1) # (K, H, 2*q_dim) + + +def build_single_agent_candidates(start2, goal2, k_variants, n_support_points, tensor_args): + """Same idea as build_candidate_batch but for one agent's 2D path only.""" + alphas = torch.linspace(0, 1, n_support_points, **tensor_args).unsqueeze(-1) + base = start2.unsqueeze(0) * (1 - alphas) + goal2.unsqueeze(0) * alphas # (H,2) + t_norm = torch.linspace(0, 1, n_support_points, **tensor_args) + envelope = torch.sin(math.pi * t_norm).unsqueeze(-1) + + variants = [base] + for _ in range(k_variants - 1): + detour = torch.zeros(n_support_points, 2, **tensor_args) + n_harmonics = int(torch.randint(1, 3, (1,)).item()) + for _h in range(n_harmonics): + freq = float(torch.randint(1, 4, (1,)).item()) + phase = torch.rand(1, **tensor_args).item() * math.pi + shape = (torch.sin(freq * math.pi * t_norm + phase).unsqueeze(-1) * envelope) + noise_scale = 0.05 + 0.55 * torch.rand(1, **tensor_args).item() + offset_dir = (torch.rand(2, **tensor_args) * 2 - 1) * noise_scale + detour = detour + shape * offset_dir.unsqueeze(0) + variants.append(base + detour) + return torch.stack(variants, dim=0) # (K, H, 2) + + +def generate_decomposed(cfg, env, task, tensor_args, dt, threshold, k_per_agent=60, n_combos=24, + obstacle_margin=0.06, max_tries=1): + """ + For the hardest tier (many agents + real obstacles/walls): a single joint + detour essentially never clears every obstacle for every agent at once + (observed 0/4000 contexts x 40 variants for dense-9). Decompose instead: + solve each agent's *own* obstacle avoidance independently (a much easier + 2D problem against a fixed obstacle field, checked via env.compute_sdf + directly -- no composite robot needed for this pre-filter stage), then + only search over how to *combine* the independently-obstacle-clear + per-agent paths so the composite robot's self-collision field (i.e. + inter-robot collision) is also satisfied. The repo's own + task.get_trajs_collision_and_free is still the final authority -- this + per-agent stage is purely a yield-boosting pre-filter. + """ + num_agents = cfg['num_agents'] + start, goal = sample_context(task, threshold) + if start is None: + return None + + start_pa = start.view(num_agents, 2) + goal_pa = goal.view(num_agents, 2) + + per_agent_free = [] + for i in range(num_agents): + cands = build_single_agent_candidates(start_pa[i], goal_pa[i], k_per_agent, N_SUPPORT_POINTS, tensor_args) + sdf = env.compute_sdf(cands) # (K, H) + free_mask = (sdf > obstacle_margin).all(dim=1) + free_idx = free_mask.nonzero().squeeze(-1) + if free_idx.numel() == 0: + return None # this agent couldn't clear obstacles at all this context; try a new context + per_agent_free.append(cands[free_idx]) # (Fi, H, 2) + + combos = [] + for _ in range(n_combos): + picks = [pac[torch.randint(0, pac.shape[0], (1,)).item()] for pac in per_agent_free] + combos.append(torch.cat(picks, dim=-1)) # (H, 2*num_agents) + pos_batch = torch.stack(combos, dim=0) # (n_combos, H, q_dim) + + avg_vel = (goal - start) / (N_SUPPORT_POINTS * dt) + vel_batch = torch.zeros_like(pos_batch) + vel_batch[:, 1:-1, :] = avg_vel.unsqueeze(0).unsqueeze(0) + traj_batch = torch.cat([pos_batch, vel_batch], dim=-1) + + _, traj_free = task.get_trajs_collision_and_free(traj_batch) + return traj_free + + +def _save_context(cfg, results_dir, task_id, traj_free, threshold): + task_dir = os.path.join(results_dir, str(task_id)) + os.makedirs(task_dir, exist_ok=True) + torch.save(traj_free.cpu(), os.path.join(task_dir, 'trajs-free.pt')) + + with open(os.path.join(task_dir, 'args.yaml'), 'w') as f: + yaml.dump({ + 'threshold_start_goal_pos': threshold, + 'obstacle_cutoff_margin': OBSTACLE_CUTOFF_MARGIN, + }, f) + with open(os.path.join(task_dir, 'metadata.yaml'), 'w') as f: + yaml.dump({ + 'env_id': cfg['env_id'], + 'robot_id': cfg['robot_id'], + 'instance_idx': INSTANCE_IDX, + 'map_name': cfg['map_name'], + 'num_trajectories_free': int(traj_free.shape[0]), + }, f) + + +def run_scenario(cfg): + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tensor_args = {'device': device, 'dtype': torch.float32} + has_obstacles = cfg['num_obstacles'] > 0 or cfg['scenario'] in ('corridor', 'room', 'shelf') + gp = gen_params_for(cfg['num_agents'], has_obstacles) + + results_dir = os.path.join(REPO_ROOT, 'data_trajectories', cfg['dataset_subdir']) + os.makedirs(results_dir, exist_ok=True) + + existing = [d for d in os.listdir(results_dir) if os.path.isdir(os.path.join(results_dir, d))] + # The skip threshold is overridable: GEN_MIN_CONTEXTS lets a caller ask for + # MORE data on top of an existing dataset instead of silently no-opping. + # Hard-coding 100 meant "generate more demonstrations" quietly did nothing + # for any scenario that already had some, which wasted a full retrain. + _min_ctx = int(os.environ.get('GEN_MIN_CONTEXTS', '100')) + if len(existing) >= _min_ctx: + print(f"[{cfg['tag']}] already has {len(existing)} task dirs " + f"(>= GEN_MIN_CONTEXTS={_min_ctx}), skipping data generation") + return + print(f"[{cfg['tag']}] has {len(existing)} task dirs, target {_min_ctx} -- generating more") + + env, robot, task = build_task(cfg, tensor_args) + dt = DURATION / N_SUPPORT_POINTS + threshold = cfg['threshold_start_goal_pos'] + + # Dense (20 scattered obstacles) turned out to be much harder for the simple + # joint-perturbation method than basic (10 obstacles) even at just 3-6 agents + # (observed 2/400 and 0/400 successful contexts respectively) -- route it + # through the more robust per-agent-decomposed method at every robot count, + # not just the >6 tier. + # The simple joint-perturbation method turned out to yield far too few + # demonstrations whenever real obstacles/walls are present, even at just + # 6 agents (observed: basic-6 29/600, room-6 17/600, shelf-6 6/600 -- + # all far below the ~30%+ yield of 3-agent scenarios or the decomposed + # method's yield at any agent count). Route every obstacle/wall scenario + # through the more robust decomposed method regardless of robot count -- + # it has been strictly better in every head-to-head comparison so far + # and stays fast even at low agent counts (dense-3: 46.8s). + use_decomposed = has_obstacles + print(f"[{cfg['tag']}] gen params: {gp} (decomposed per-agent method: {use_decomposed})") + t_start = time.time() + total = 0 + saved_contexts = 0 + + if use_decomposed: + # Empirically, requiring *every* agent to simultaneously clear dense + # obstacles/walls in the same random draw is genuinely rare (observed + # ~4/20000 draws for dense-9) -- this is honestly a reflection of how + # hard these specific scenarios are (the paper's own baselines largely + # fail here too). Run on a bounded time budget instead of a fixed + # context count, so one brutally hard scenario can't consume unbounded + # time; take whatever real, verified-collision-free demonstrations + # accumulate in that window. + TIME_BUDGET_S = 1200 + TARGET_SUCCESSES = 150 + task_id = 0 + while (time.time() - t_start) < TIME_BUDGET_S and saved_contexts < TARGET_SUCCESSES: + traj_free = generate_decomposed(cfg, env, task, tensor_args, dt, threshold, + k_per_agent=50, n_combos=gp['k_variants']) + task_id += 1 + if traj_free is None or traj_free.nelement() == 0: + if task_id % 500 == 0: + elapsed = time.time() - t_start + print(f"[{cfg['tag']}] progress: {task_id} contexts tried, " + f"{saved_contexts} saved, {total} total trajs, {elapsed:.1f}s elapsed") + continue + _save_context(cfg, results_dir, saved_contexts + _off, traj_free, threshold) + total += traj_free.shape[0] + saved_contexts += 1 + elapsed = time.time() - t_start + print(f"[{cfg['tag']}] progress: {task_id} contexts tried, " + f"{saved_contexts} saved, {total} total trajs, {elapsed:.1f}s elapsed") + print(f"[{cfg['tag']}] DONE (time/target budgeted). {total} total free trajectories across " + f"{saved_contexts} contexts ({task_id} tried) in {time.time() - t_start:.1f}s") + if saved_contexts < 10: + print(f"[{cfg['tag']}] WARNING: only {saved_contexts} usable demonstrations found in the " + f"time budget -- this scenario is extremely constrained; training may be noisy.") + return + + n_contexts = int(os.environ.get('GEN_N_CONTEXTS', gp['n_contexts'])) + # Task dirs are named by task_id, so a plain re-run OVERWRITES contexts + # 0..n-1 instead of adding any. GEN_TASK_OFFSET shifts new contexts past + # the existing ones so the dataset actually grows. + _off = int(os.environ.get('GEN_TASK_OFFSET', '0')) + for _t in range(n_contexts): + task_id = _t + _off + start, goal = sample_context(task, threshold) + if start is None: + continue + traj_batch = build_candidate_batch(start, goal, gp['k_variants'], N_SUPPORT_POINTS, dt, tensor_args) + _, traj_free = task.get_trajs_collision_and_free(traj_batch) + if traj_free is None or traj_free.nelement() == 0: + continue + + _save_context(cfg, results_dir, saved_contexts + _off, traj_free, threshold) + total += traj_free.shape[0] + saved_contexts += 1 + + if task_id % 50 == 0: + elapsed = time.time() - t_start + print(f"[{cfg['tag']}] progress: {task_id + 1}/{n_contexts} contexts, " + f"{saved_contexts} saved, {total} total trajs, {elapsed:.1f}s elapsed") + + print(f"[{cfg['tag']}] DONE. {total} total free trajectories across {saved_contexts}/{n_contexts} " + f"contexts in {time.time() - t_start:.1f}s") + if saved_contexts < 20: + print(f"[{cfg['tag']}] WARNING: very few contexts produced any collision-free trajectory " + f"({saved_contexts}) -- this scenario's geometry may need larger perturbation " + f"amplitude or more variants to get usable training data.") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_singleagent_data.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_singleagent_data.py new file mode 100644 index 0000000000000000000000000000000000000000..d682f1e5908cd65ed2ce96da2f27fd777a265f32 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_singleagent_data.py @@ -0,0 +1,217 @@ +""" +Generates single-robot (RobotPlanarDisk) demonstrations for each of the 6 +scenario environments, to be used for training single-agent SMD models +(smd.planners.single_agent.mpd.SMD). These, combined with PrioritizedPlanning/ +CBS (smd.planners.multi_agent), let us generate *real* coordinated multi-robot +solutions as training data for composite SMD -- mirroring what the paper's +own Appendix A.2 says they did (train a single-robot model, run it through +search-based multi-robot coordination, use those solutions as SMD's training +data) instead of the from-scratch synthetic method used in the first pass. + +Single-robot avoidance is a much easier problem than the composite case (no +inter-agent coordination at all), so the same fast batch straight-line-plus- +perturbation method from generate_data_all.py applies directly and should +have very high yield. + +Usage: + python generate_singleagent_data.py # e.g. empty, basic, room + python generate_singleagent_data.py --all +""" +import os +import sys +import math +import time +import argparse +import yaml +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask + +N_SUPPORT_POINTS = 64 +DURATION = 5.0 +OBSTACLE_CUTOFF_MARGIN = 0.05 # matches mpd.py's SMD planner's own obstacle_cutoff_margin +INSTANCE_IDX = 0 +ROBOT_ID = 'RobotPlanarDisk' +N_CONTEXTS = int(__import__('os').environ.get('SMD_N_CONTEXTS', '600')) +K_VARIANTS = int(__import__('os').environ.get('SMD_K_VARIANTS', '16')) +THRESHOLD_START_GOAL_POS = 1.0 + +SCENARIOS = { + 'empty': dict(env_id='EnvEmptyNoWait2D', map_name='empty_map'), + 'basic': dict(env_id='EnvEmptyNoWait2D', map_name='basic_map'), + 'dense': dict(env_id='EnvEmptyNoWait2D', map_name='dense_map'), + 'corridor': dict(env_id='EnvConveyor2D', map_name='corridor_map', threshold=0.6), + 'room': dict(env_id='EnvHighways2D', map_name='room_map', threshold=0.6), + 'shelf': dict(env_id='EnvDropRegion2D', map_name='shelf_map', threshold=0.6), + # SMD Fig. 3 maps, rebuilt (only the corridor ships as code, and unexported) + 'corridorbay': dict(env_id='EnvCorridorSMD2D', map_name='corridorbay_map', threshold=0.6), + 'crossroom': dict(env_id='EnvCrossRoom2D', map_name='crossroom_map', threshold=0.6), +} + +# HARD twins. Each carries its own obs_scale, so its single-agent trajectories must be +# generated separately -- a hard cell trained on 1.0x single-agent data would be learning +# the wrong geometry. The dataset dir is keyed on the scenario name, so the twin's name +# is what keeps it from overwriting the shipped data. +# THE SCALES EVALUATION USES. This table and scenarios.HARD_OBS_SCALE share a name and +# once disagreed: the evaluation table was retuned (corridor 2.20->1.50, crossroom +# 2.20->1.10, dense 1.80->1.00) and this one was not, so six maps generated their +# single-agent demonstrations in geometry they were never scored in -- shelf built at +# 1.15 and scored at 1.6, which is 20% of the workspace blocked against 40%. Nothing +# compared the two, and the data recorded no scale of its own, so it was invisible. +# Imported now rather than duplicated, and check_scales.py compares them. +import sys as _sys, os as _os +_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) +from scenarios import HARD_OBS_SCALE, HARD_ROBOT_R # noqa: E402 +for _k in list(SCENARIOS): + _h = dict(SCENARIOS[_k]) + _h['obs_scale'] = HARD_OBS_SCALE.get(_k, 1.0) + if _k in HARD_ROBOT_R: + _h['robot_r'] = HARD_ROBOT_R[_k] + SCENARIOS[_k + 'hard'] = _h +# ROBOTARIUM twins. The layouts are unchanged -- the arena's y-range is exactly [-1, 1], +# so a layout on [-1, 1]^2 is already in metres there -- but the robot is 0.055 m rather +# than 0.05, so its single-agent trajectories must be generated separately: a model whose +# demonstrations were swept by a 0.05 m disk will clip obstacles when scored at 0.055. +# Three maps also drop their obstacle scale so an 18-robot team fits at the testbed's +# 0.25 m planned separation; measured, not chosen. +ROBOTARIUM_ROBOT_R = 0.13 # Plan B: hardware radius +# EVERY map is listed explicitly, with no fallback to this file's HARD_OBS_SCALE. That +# table disagrees with the one evaluation uses in scenarios.py -- shelf 1.15 against 1.6, +# basic 1.0 against 2.2 -- so falling back would have generated the port's training data +# in geometry the port is not scored in. The values here are the scales the Robotarium +# port is evaluated at. +ROBOTARIUM_OBS_SCALE = {'empty': 1.0, 'basic': 1.5, 'dense': 1.0, 'corridor': 1.1, + 'room': 1.45, 'shelf': 0.4, 'corridorbay': 0.2, + 'crossroom': 0.85} +for _k in [k for k in list(SCENARIOS) if not k.endswith('hard')]: + _r = dict(SCENARIOS[_k]) + _r['obs_scale'] = ROBOTARIUM_OBS_SCALE[_k] + _r['robot_r'] = ROBOTARIUM_ROBOT_R + SCENARIOS[_k + 'rbt'] = _r + +# WIDE-CHANNEL corridor twin: identical config to corridorrbt; the geometry change +# comes from RBT_WIDE=1 (outer walls +-0.40), which the caller must export for +# generation, training AND inference. Own name so nothing overwrites the frozen data. +SCENARIOS['corridorwiderbt'] = dict(SCENARIOS['corridorrbt']) + +for _k in SCENARIOS: + SCENARIOS[_k].setdefault('obs_scale', 1.0) + + +def build_task(env_id, map_name, tensor_args): + env_class = getattr(environments, env_id + 'ExtraObjects') + env = env_class(tensor_args=tensor_args, instance_idx=INSTANCE_IDX, map_name=map_name) + robot_class = getattr(robots, ROBOT_ID) + robot = robot_class(tensor_args=tensor_args) + task = PlanningTask(env=env, robot=robot, tensor_args=tensor_args, obstacle_cutoff_margin=OBSTACLE_CUTOFF_MARGIN) + return env, robot, task + + +def sample_context(task, threshold, max_tries=300): + for _ in range(max_tries): + q_free = task.random_coll_free_q(n_samples=2) + s, g = q_free[0], q_free[1] + if torch.linalg.norm(s - g) > threshold: + return s, g + return None, None + + +def build_candidate_batch(start, goal, k_variants, n_support_points, dt, tensor_args): + q_dim = start.shape[0] # 2 for a single planar disk + alphas = torch.linspace(0, 1, n_support_points, **tensor_args).unsqueeze(-1) + base_pos = start.unsqueeze(0) * (1 - alphas) + goal.unsqueeze(0) * alphas + t_norm = torch.linspace(0, 1, n_support_points, **tensor_args) + envelope = torch.sin(math.pi * t_norm).unsqueeze(-1) + + pos_variants = [base_pos] + for _ in range(k_variants - 1): + detour = torch.zeros(n_support_points, q_dim, **tensor_args) + n_harmonics = int(torch.randint(1, 3, (1,)).item()) + for _h in range(n_harmonics): + freq = float(torch.randint(1, 3, (1,)).item()) + phase = torch.rand(1, **tensor_args).item() * math.pi + shape = (torch.sin(freq * math.pi * t_norm + phase).unsqueeze(-1) * envelope) + noise_scale = 0.05 + 0.45 * torch.rand(1, **tensor_args).item() + offset_dir = (torch.rand(q_dim, **tensor_args) * 2 - 1) * noise_scale + detour = detour + shape * offset_dir.unsqueeze(0) + pos_variants.append(base_pos + detour) + pos_batch = torch.stack(pos_variants, dim=0) + + avg_vel = (goal - start) / (n_support_points * dt) + vel_batch = torch.zeros_like(pos_batch) + vel_batch[:, 1:-1, :] = avg_vel.unsqueeze(0).unsqueeze(0) + + return torch.cat([pos_batch, vel_batch], dim=-1) + + +def run_scenario(scenario): + cfg = SCENARIOS[scenario] + threshold = cfg.get('threshold', THRESHOLD_START_GOAL_POS) + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tensor_args = {'device': device, 'dtype': torch.float32} + + dataset_subdir = f'singleagent_{scenario}_{cfg["env_id"]}-{ROBOT_ID}' + results_dir = os.path.join(REPO_ROOT, 'data_trajectories', dataset_subdir) + os.makedirs(results_dir, exist_ok=True) + + existing = [d for d in os.listdir(results_dir) if os.path.isdir(os.path.join(results_dir, d))] + if len(existing) >= 100: + print(f"[singleagent-{scenario}] already has {len(existing)} task dirs, skipping") + return + + # Export the scenario's geometry so the env/robot actually build at it. build_task + # ignores cfg here and OBS_SCALE/ROBOT_R are read from the environment (primitives.py, + # smd_params.py); if we don't set them the model silently trains at scale 1.0 / r 0.05. + if 'obs_scale' in cfg: os.environ['OBS_SCALE'] = repr(float(cfg['obs_scale'])) + if 'robot_r' in cfg: os.environ['ROBOT_R'] = repr(float(cfg['robot_r'])) + env, robot, task = build_task(cfg['env_id'], cfg['map_name'], tensor_args) + dt = DURATION / N_SUPPORT_POINTS + + t_start = time.time() + total = 0 + saved = 0 + for attempt in range(N_CONTEXTS): + start, goal = sample_context(task, threshold) + if start is None: + continue + traj_batch = build_candidate_batch(start, goal, K_VARIANTS, N_SUPPORT_POINTS, dt, tensor_args) + _, traj_free = task.get_trajs_collision_and_free(traj_batch) + if traj_free is None or traj_free.nelement() == 0: + continue + + task_dir = os.path.join(results_dir, str(saved)) + os.makedirs(task_dir, exist_ok=True) + torch.save(traj_free.cpu(), os.path.join(task_dir, 'trajs-free.pt')) + with open(os.path.join(task_dir, 'args.yaml'), 'w') as f: + yaml.dump({'threshold_start_goal_pos': threshold, 'obstacle_cutoff_margin': OBSTACLE_CUTOFF_MARGIN}, f) + with open(os.path.join(task_dir, 'metadata.yaml'), 'w') as f: + yaml.dump({'env_id': cfg['env_id'], 'robot_id': ROBOT_ID, 'instance_idx': INSTANCE_IDX, + 'map_name': cfg['map_name'], 'num_trajectories_free': int(traj_free.shape[0])}, f) + + total += traj_free.shape[0] + saved += 1 + if attempt % 100 == 0: + print(f"[singleagent-{scenario}] progress: {attempt + 1}/{N_CONTEXTS} contexts, " + f"{saved} saved, {total} total trajs, {time.time() - t_start:.1f}s elapsed") + + print(f"[singleagent-{scenario}] DONE. {total} total free trajectories across {saved} contexts " + f"in {time.time() - t_start:.1f}s") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('scenario', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for s in SCENARIOS: + run_scenario(s) + elif args.scenario: + run_scenario(args.scenario) + else: + parser.error("provide a scenario or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_cbs.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_cbs.py new file mode 100644 index 0000000000000000000000000000000000000000..7bbe1cc0a25947b33380f6b622067a23369f46b3 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_cbs.py @@ -0,0 +1,147 @@ +""" +Generates real, coordinated multi-robot demonstrations for composite SMD +training by running full Conflict-Based Search (CBS, with backtracking) over +N instances of the trained single-agent SMD diffusion planner -- one instance +per robot, each bound to that robot's own start/goal. + +This is the "do it properly" upgrade over generate_via_pp.py: PrioritizedPlanning +resolves conflicts in a fixed order with no backtracking, which produces +demonstrations that all share one narrow coordination "style" (confirmed to +plateau composite-SMD's success rate on hard scenarios regardless of data +volume). CBS explores the constraint tree properly (same is_xcbs=True, +is_ecbs=True variant validated against the official MMD paper's own Table 1/2 +numbers in evaluate_mmd_all.py), producing genuinely more diverse, higher- +quality coordinated solutions -- at real per-attempt compute cost. + +Usage: + python generate_via_cbs.py # e.g. basic-6, dense-3, room-6 + python generate_via_cbs.py --all +""" +import os +import sys +import time +import argparse +import yaml +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +from smd.planners.multi_agent import CBS +from smd.common.constraints import MultiPointConstraint +from smd.common.conflicts import PointConflict + +from scenarios import SCENARIOS, get_scenario +from generate_via_pp import ( + build_reference, sample_non_overlapping, get_shared, make_light_smd, + SCENARIO_TO_SA_KEY, +) +from generate_singleagent_data import SCENARIOS as SA_SCENARIOS, ROBOT_ID as SA_ROBOT_ID + +INSTANCE_IDX = 0 +TARGET_SUCCESSES = 150 +TIME_BUDGET_S = 3600 +CBS_RUNTIME_LIMIT = 60 # seconds, per multi-robot instance solve attempt -- matches +# the official MMD paper's own runtime limit (verified against their Table 1/2) + + +def run_scenario(cfg): + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tensor_args = {'device': device, 'dtype': torch.float32} + num_agents = cfg['num_agents'] + sa_key = SCENARIO_TO_SA_KEY[cfg['scenario']] + sa_cfg = SA_SCENARIOS[sa_key] + model_id = f'singleagent_{sa_key}_{sa_cfg["env_id"]}-{SA_ROBOT_ID}' + + results_dir = os.path.join(REPO_ROOT, 'data_trajectories', f'cbs_{cfg["dataset_subdir"]}') + os.makedirs(results_dir, exist_ok=True) + scratch_dir = os.path.join(REPO_ROOT, 'logs_cbs_scratch') + os.makedirs(scratch_dir, exist_ok=True) + + existing = [d for d in os.listdir(results_dir) if os.path.isdir(os.path.join(results_dir, d))] + if len(existing) >= TARGET_SUCCESSES: + print(f"[cbs-{cfg['tag']}] already has {len(existing)} task dirs, skipping") + return + + env, robot, task = build_reference(cfg, tensor_args) + robot_radius = robot.radius + threshold_pair_dist = 2.2 * robot_radius + + t_start = time.time() + saved = len(existing) + attempt = 0 + while (time.time() - t_start) < TIME_BUDGET_S and saved < TARGET_SUCCESSES: + attempt += 1 + starts = sample_non_overlapping(task, robot_radius, num_agents, threshold_pair_dist) + goals = sample_non_overlapping(task, robot_radius, num_agents, threshold_pair_dist) + if starts is None or goals is None: + continue + + low_level_planner_l = [] + try: + shared = get_shared(model_id, scratch_dir, starts[0], goals[0]) + for i in range(num_agents): + low_level_planner_l.append(make_light_smd(shared, starts[i], goals[i])) + + cbs = CBS( + low_level_planner_l, starts, goals, + is_xcbs=True, is_ecbs=True, + conflict_type_to_constraint_types={PointConflict: {MultiPointConstraint}}, + reference_robot=robot, reference_task=task, + results_dir=scratch_dir, + ) + paths_l, num_ct_expansions, success_status, num_collisions = cbs.plan(runtime_limit=CBS_RUNTIME_LIMIT) + except Exception as e: + print(f"[cbs-{cfg['tag']}] attempt {attempt} raised {type(e).__name__}: {e}") + continue + + if str(success_status) != 'TrialSuccessStatus.SUCCESS' or num_collisions > 0: + if attempt % 5 == 0: + print(f"[cbs-{cfg['tag']}] progress: {attempt} attempts, {saved} saved, " + f"{time.time() - t_start:.1f}s elapsed (last status: {success_status})") + continue + + # paths_l: list of num_agents tensors, each (H, state_dim=4: x,y,vx,vy), already + # in fixed identity-slot order (CBS doesn't need the PP-style un-permute step). + pos_l = [p[:, :2] for p in paths_l] + vel_l = [p[:, 2:4] for p in paths_l] + composite_pos = torch.cat(pos_l, dim=-1) # (H, 2*num_agents) + composite_vel = torch.cat(vel_l, dim=-1) + composite_traj = torch.cat([composite_pos, composite_vel], dim=-1).unsqueeze(0) # (1, H, 4*num_agents) + + task_dir = os.path.join(results_dir, str(saved)) + os.makedirs(task_dir, exist_ok=True) + torch.save(composite_traj.cpu(), os.path.join(task_dir, 'trajs-free.pt')) + with open(os.path.join(task_dir, 'args.yaml'), 'w') as f: + yaml.dump({'threshold_start_goal_pos': cfg['threshold_start_goal_pos'], + 'obstacle_cutoff_margin': 0.03}, f) + with open(os.path.join(task_dir, 'metadata.yaml'), 'w') as f: + yaml.dump({'env_id': cfg['env_id'], 'robot_id': cfg['robot_id'], 'instance_idx': INSTANCE_IDX, + 'map_name': cfg['map_name'], 'num_trajectories_free': 1}, f) + + saved += 1 + print(f"[cbs-{cfg['tag']}] progress: {attempt} attempts, {saved} saved, " + f"{time.time() - t_start:.1f}s elapsed, ct_expansions={num_ct_expansions}") + + print(f"[cbs-{cfg['tag']}] DONE. {saved} demonstrations saved across {attempt} attempts " + f"in {time.time() - t_start:.1f}s") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + parser.add_argument('--tags', nargs='+', default=None, help='run a specific list of tags') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tags: + for t in args.tags: + run_scenario(get_scenario(t)) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag, --tags ..., or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_pp.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_pp.py new file mode 100644 index 0000000000000000000000000000000000000000..df5fa59f7ab8f824967f5735db473182c1d4ff91 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/generate_via_pp.py @@ -0,0 +1,291 @@ +""" +Generates real, coordinated multi-robot demonstrations for composite SMD +training by running PrioritizedPlanning (smd.planners.multi_agent) over N +instances of the trained single-agent SMD diffusion planner +(smd.planners.single_agent.mpd.SMD) -- one instance per robot, each bound to +that robot's own start/goal. This mirrors the paper's own Appendix A.2 +approach (train single-robot model, run it through search-based multi-robot +coordination, use the solved trajectories as SMD's training data) more +faithfully than the from-scratch synthetic generator used in the first pass. + +PrioritizedPlanning (not full CBS) is used for speed: it plans agents in a +fixed order, each new agent treating already-planned agents' paths as hard +constraints -- no backtracking search, much faster for bulk data generation +where we need many diverse solved instances rather than one optimal one. + +Usage: + python generate_via_pp.py # e.g. empty-3, room-9 + python generate_via_pp.py --all +""" +import os +import sys +import time +import random +import argparse +import yaml +import torch + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) +os.environ["HDF5_USE_FILE_LOCKING"] = "FALSE" + +from torch_robotics import environments, robots +from torch_robotics.tasks.tasks import PlanningTask +from smd.planners.single_agent import SMD +from smd.planners.single_agent.common import PlannerOutput +from smd.planners.multi_agent import PrioritizedPlanning +from smd.config.smd_params import SMDParams as params + +from scenarios import SCENARIOS, get_scenario +from generate_singleagent_data import SCENARIOS as SA_SCENARIOS, ROBOT_ID as SA_ROBOT_ID, INSTANCE_IDX as SA_INSTANCE_IDX + +N_SUPPORT_POINTS = 64 +INSTANCE_IDX = 0 +TARGET_SUCCESSES = int(__import__('os').environ.get('SMD_PP_TARGET', '240')) +TIME_BUDGET_S = float(__import__('os').environ.get('SMD_PP_BUDGET_S', '2400')) +PP_RUNTIME_LIMIT = 60 # seconds, per multi-robot instance solve attempt + +# Every scenario maps to the single-agent dataset it is built from. A HARD twin must map +# to its OWN hard single-agent key, not the shipped one: the geometry differs, so reusing +# the shipped trajectories would train the multi-agent model on paths planned through +# space that is no longer free. +SCENARIO_TO_SA_KEY = {k: k for k in SA_SCENARIOS} + + +def build_reference(cfg, tensor_args): + env_class = getattr(environments, cfg['env_id'] + 'ExtraObjects') + env = env_class(tensor_args=tensor_args, instance_idx=INSTANCE_IDX, map_name=cfg['map_name']) + robot_class = getattr(robots, SA_ROBOT_ID) + robot = robot_class(tensor_args=tensor_args) + task = PlanningTask(env=env, robot=robot, tensor_args=tensor_args, obstacle_cutoff_margin=0.05) + return env, robot, task + + +def sample_non_overlapping(task, robot_radius, n, threshold_pair_dist, max_tries=2000): + """Sample n single-robot-free 2D positions that are also mutually non-overlapping.""" + pts = [] + tries = 0 + while len(pts) < n and tries < max_tries: + tries += 1 + q = task.random_coll_free_q(n_samples=1) + ok = all(torch.linalg.norm(q - p) > threshold_pair_dist for p in pts) + if ok: + pts.append(q) + if len(pts) < n: + return None + return pts + + +def build_planner_kwargs(model_id, start_pos, goal_pos, results_dir): + kwargs = dict( + model_id=model_id, + planner_alg='smd', + start_state_pos=start_pos, + goal_state_pos=goal_pos, + use_guide_on_extra_objects_only=params.use_guide_on_extra_objects_only, + n_samples=16, # reduced from 64 for speed; only need one good solution, not full diversity + n_local_inference_noising_steps=params.n_local_inference_noising_steps, + n_local_inference_denoising_steps=params.n_local_inference_denoising_steps, + start_guide_steps_fraction=params.start_guide_steps_fraction, + n_guide_steps=params.n_guide_steps, + n_diffusion_steps_without_noise=params.n_diffusion_steps_without_noise, + weight_grad_cost_collision=params.weight_grad_cost_collision, + weight_grad_cost_smoothness=params.weight_grad_cost_smoothness, + weight_grad_cost_constraints=params.weight_grad_cost_constraints, + weight_grad_cost_soft_constraints=params.weight_grad_cost_soft_constraints, + factor_num_interpolated_points_for_collision=params.factor_num_interpolated_points_for_collision, + trajectory_duration=params.trajectory_duration, + device=params.device, + debug=False, + seed=params.seed, + results_dir=results_dir, + trained_models_dir=os.path.join(REPO_ROOT, 'data_trained_models') + '/', + ) + return kwargs + + +_SHARED_CACHE = {} + + +def get_shared(model_id, results_dir, start_pos, goal_pos): + """Build (once per model_id) the expensive, start/goal-independent parts of an + SMD planner: dataset/env/robot/task, the loaded+compiled+warmed-up diffusion + model, and the guide. These are identical for every agent and every attempt + within a scenario (and across robot-counts, since e.g. basic-3/6/9 all share + the same single-agent checkpoint), so building them once and reusing avoids + the dominant cost of SMD.__init__ (checkpoint load + torch.compile + warmup). + """ + if model_id in _SHARED_CACHE: + return _SHARED_CACHE[model_id] + kwargs = build_planner_kwargs(model_id, start_pos, goal_pos, results_dir) + real = SMD(**kwargs) + shared = dict( + dataset=real.dataset, task=real.task, robot=real.robot, model=real.model, + n_support_points=real.n_support_points, t_start_guide=real.t_start_guide, + n_guide_steps=real.n_guide_steps, guide=real.guide, tensor_args=real.tensor_args, + num_samples=real.num_samples, + n_local_inference_noising_steps=real.n_local_inference_noising_steps, + n_local_inference_denoising_steps=real.n_local_inference_denoising_steps, + n_diffusion_steps_without_noise=real.n_diffusion_steps_without_noise, + weight_grad_cost_constraints=real.weight_grad_cost_constraints, + weight_grad_cost_soft_constraints=real.weight_grad_cost_soft_constraints, + sample_fn_kwargs=real.sample_fn_kwargs, + results_dir=real.results_dir, + ) + _SHARED_CACHE[model_id] = shared + return shared + + +def make_light_smd(shared, start_pos, goal_pos): + """Construct an SMD instance bound to a specific start/goal without re-running + the expensive __init__ (bypassed via object.__new__), reusing the shared bundle + for everything else. Safe because PrioritizedPlanning calls each agent's planner + strictly sequentially and consumes its output immediately (verified in + prioritized_planning.py: plan() loops `planner_output = low_level_planner_l[i](...)` + with no later cross-referencing of other planners' internal state).""" + planner = object.__new__(SMD) + planner.constraints = [] + planner.weight_grad_cost_constraints = shared['weight_grad_cost_constraints'] + planner.weight_grad_cost_soft_constraints = shared['weight_grad_cost_soft_constraints'] + planner.start_state_pos = torch.clone(start_pos) + planner.goal_state_pos = torch.clone(goal_pos) + planner.robot = shared['robot'] + planner.context = None + planner.run_prior_only = False + planner.run_prior_then_guidance = False + planner.n_diffusion_steps_without_noise = shared['n_diffusion_steps_without_noise'] + planner.hard_conds = shared['dataset'].get_hard_conditions( + torch.vstack((start_pos, goal_pos)), normalize=True) + planner.model = shared['model'] + planner.n_support_points = shared['n_support_points'] + planner.t_start_guide = shared['t_start_guide'] + planner.n_guide_steps = shared['n_guide_steps'] + planner.guide = shared['guide'] + planner.tensor_args = shared['tensor_args'] + planner.num_samples = shared['num_samples'] + planner.n_local_inference_noising_steps = shared['n_local_inference_noising_steps'] + planner.n_local_inference_denoising_steps = shared['n_local_inference_denoising_steps'] + planner.dataset = shared['dataset'] + planner.task = shared['task'] + planner.results_dir = shared['results_dir'] + planner.recent_call_data = PlannerOutput() + planner.sample_fn_kwargs = shared['sample_fn_kwargs'] + return planner + + +def run_scenario(cfg): + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tensor_args = {'device': device, 'dtype': torch.float32} + num_agents = cfg['num_agents'] + sa_key = SCENARIO_TO_SA_KEY[cfg['scenario']] + sa_cfg = SA_SCENARIOS[sa_key] + model_id = f'singleagent_{sa_key}_{sa_cfg["env_id"]}-{SA_ROBOT_ID}' + + results_dir = os.path.join(REPO_ROOT, 'data_trajectories', f'pp_{cfg["dataset_subdir"]}') + os.makedirs(results_dir, exist_ok=True) + scratch_dir = os.path.join(REPO_ROOT, 'logs_pp_scratch') + os.makedirs(scratch_dir, exist_ok=True) + + existing = [d for d in os.listdir(results_dir) if os.path.isdir(os.path.join(results_dir, d))] + if len(existing) >= TARGET_SUCCESSES: + print(f"[pp-{cfg['tag']}] already has {len(existing)} task dirs, skipping") + return + + env, robot, task = build_reference(cfg, tensor_args) + robot_radius = robot.radius + # 2.2 * robot_radius = 0.11, but is_multi_agent_start_goal_states_valid hardcodes a + # 0.15 minimum start-start separation -- so this sampler was producing configurations + # the planner's own validator then rejected. In open maps random points land farther + # apart than 0.11 by luck and the gate passes incidentally; pack 21 robots into a tight + # map and they cluster at the bound, so EVERY attempt fails with "Start or goal states + # are invalid" (measured: 501/501 on corridorbay-21, and 234/234 after widening the + # map, which was never the cause). evaluate_smd_all.get_test_instances already uses + # max(0.16, 2.2r) for exactly this reason; this makes generation agree with it. + threshold_pair_dist = max(0.16, 2.2 * robot_radius) + + t_start = time.time() + saved = len(existing) # append new (randomized-order) demos after the existing ones + attempt = 0 + total_trajs = 0 + while (time.time() - t_start) < TIME_BUDGET_S and saved < TARGET_SUCCESSES: + attempt += 1 + starts = sample_non_overlapping(task, robot_radius, num_agents, threshold_pair_dist) + goals = sample_non_overlapping(task, robot_radius, num_agents, threshold_pair_dist) + if starts is None or goals is None: + continue + + # PrioritizedPlanning always resolves conflicts in the order its inputs are + # given (agent 0 first, no backtracking), so a fixed identity order makes + # every demonstration share one narrow coordination "style". Randomizing + # which agent gets solved first each attempt diversifies the demonstrations + # without needing full CBS backtracking. The saved composite trajectory is + # un-permuted back to the fixed identity-slot order afterward. + perm = list(range(num_agents)) + random.shuffle(perm) + starts_perm = [starts[i] for i in perm] + goals_perm = [goals[i] for i in perm] + + low_level_planner_l = [] + try: + shared = get_shared(model_id, scratch_dir, starts_perm[0], goals_perm[0]) + for i in range(num_agents): + low_level_planner_l.append(make_light_smd(shared, starts_perm[i], goals_perm[i])) + + planner = PrioritizedPlanning(low_level_planner_l, starts_perm, goals_perm, + reference_robot=robot, reference_task=task, + results_dir=scratch_dir) + paths_l_perm, _, success_status, num_collisions = planner.plan(runtime_limit=PP_RUNTIME_LIMIT) + except Exception as e: + print(f"[pp-{cfg['tag']}] attempt {attempt} raised {type(e).__name__}: {e}") + continue + + if str(success_status) != 'TrialSuccessStatus.SUCCESS' or num_collisions > 0: + if attempt % 20 == 0: + print(f"[pp-{cfg['tag']}] progress: {attempt} attempts, {saved} saved, " + f"{time.time() - t_start:.1f}s elapsed (last status: {success_status})") + continue + + # Un-permute back to fixed identity-slot order (perm[k] is the original + # identity of the agent solved k-th). + paths_l = [None] * num_agents + for k, orig_idx in enumerate(perm): + paths_l[orig_idx] = paths_l_perm[k] + + # paths_l: list of num_agents tensors, each (H, state_dim=4: x,y,vx,vy). + pos_l = [p[:, :2] for p in paths_l] + vel_l = [p[:, 2:4] for p in paths_l] + composite_pos = torch.cat(pos_l, dim=-1) # (H, 2*num_agents) + composite_vel = torch.cat(vel_l, dim=-1) + composite_traj = torch.cat([composite_pos, composite_vel], dim=-1).unsqueeze(0) # (1, H, 4*num_agents) + + task_dir = os.path.join(results_dir, str(saved)) + os.makedirs(task_dir, exist_ok=True) + torch.save(composite_traj.cpu(), os.path.join(task_dir, 'trajs-free.pt')) + with open(os.path.join(task_dir, 'args.yaml'), 'w') as f: + yaml.dump({'threshold_start_goal_pos': cfg['threshold_start_goal_pos'], + 'obstacle_cutoff_margin': 0.03}, f) + with open(os.path.join(task_dir, 'metadata.yaml'), 'w') as f: + yaml.dump({'env_id': cfg['env_id'], 'robot_id': cfg['robot_id'], 'instance_idx': INSTANCE_IDX, + 'map_name': cfg['map_name'], 'num_trajectories_free': 1}, f) + + saved += 1 + total_trajs += 1 + print(f"[pp-{cfg['tag']}] progress: {attempt} attempts, {saved} saved, {time.time() - t_start:.1f}s elapsed") + + print(f"[pp-{cfg['tag']}] DONE. {saved} demonstrations saved across {attempt} attempts " + f"in {time.time() - t_start:.1f}s") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_dense_maps.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_dense_maps.py new file mode 100644 index 0000000000000000000000000000000000000000..c0f801bda527f566981d2712ab8553dc29b35f8e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_dense_maps.py @@ -0,0 +1,98 @@ +"""Generate Basic-20 and Dense-40 instance files: more circles, same circle sizes. + +Increasing the COUNT rather than the RADIUS is the better difficulty knob for the sphere +maps. Inflating radii distorts each obstacle away from the geometry the benchmark was +designed around; adding obstacles keeps every circle the size it always was and raises +clutter, which is what actually makes routing a team hard. + +Matches the shipped distribution exactly (measured from basic_map.pkl / dense_map.pkl): +radii ~ U(0.072, 0.12), centres in [-0.85, 0.85]^2, obstacles mutually non-overlapping. +Nesting is preserved -- loaded_set[i][bucket] = (obs_info, agents_info) -- because the env +reads loaded_set[instance_idx][0][0] and the evaluator indexes buckets by team size. + +A generated map is REJECTED and resampled unless its free space stays connected with a +usable margin, so an unsolvable map is never written. +""" +import argparse, os, pickle, sys +import numpy as np + +R_LO, R_HI, LIM = 0.072, 0.120, 0.85 +HERE = os.path.dirname(os.path.abspath(__file__)) +INST = os.path.join(HERE, '..', '..', 'instances_data') + + +def sample_obstacles(n, rng, gap=0.01, tries=80000): + obs = [] + for _ in range(tries): + if len(obs) == n: + break + r = float(rng.uniform(R_LO, R_HI)) + c = rng.uniform(-LIM, LIM, size=2) + if all(np.linalg.norm(c - o[0]) > (r + o[1] + gap) for o in obs): + obs.append((c, r)) + return obs if len(obs) == n else None + + +def quality(obs, robot_r=0.05, grid=300): + """Free-space fraction and the largest radius keeping free space one region.""" + from scipy.ndimage import label + xs = np.linspace(-1, 1, grid) + X, Y = np.meshgrid(xs, xs) + d = np.full_like(X, np.inf) + for c, r in obs: + d = np.minimum(d, np.sqrt((X - c[0]) ** 2 + (Y - c[1]) ** 2) - r) + free = d > robot_r + if not free.any(): + return 0.0, 0.0 + def ok(rr): + f = d > rr + if not f.any(): + return False + lbl, k = label(f) + if k == 0: + return False + s = np.bincount(lbl.ravel())[1:] + return s.max() >= 0.99 * f.sum() + lo, hi = 0.0, 0.4 + if ok(lo): + for _ in range(18): + mid = 0.5 * (lo + hi) + lo, hi = (mid, hi) if ok(mid) else (lo, mid) + return float(free.mean()), float(lo) + + +def main(a): + src = pickle.load(open(os.path.join(INST, f'{a.src}.pkl'), 'rb')) + out, rng = [], np.random.default_rng(a.seed) + print(f' {a.src} -> {a.out} ({a.n} obstacles, {len(src)} instances)') + for i in range(len(src)): + for attempt in range(60): + obs = sample_obstacles(a.n, rng) + if obs is None: + continue + frac, rc = quality(obs) + if rc >= a.min_margin * 0.05: # free space stays connected & usable + break + else: + print(f' instance {i}: could not place {a.n} obstacles with margin ' + f'>= {a.min_margin}x -- ABORTING rather than writing an unsolvable map') + return 1 + # preserve the (obs_info, agents_info) nesting per bucket + out.append([(obs, src[i][b][1]) for b in range(len(src[i]))]) + if i < 3 or i == len(src) - 1: + print(f' instance {i}: free {frac*100:.0f}% r_crit {rc:.3f} = {rc/0.05:.1f}x robot r') + p = os.path.join(INST, f'{a.out}.pkl') + pickle.dump(out, open(p, 'wb')) + print(f' wrote {p}') + return 0 + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--src', required=True) + ap.add_argument('--out', required=True) + ap.add_argument('--n', type=int, required=True) + ap.add_argument('--seed', type=int, default=7) + ap.add_argument('--min-margin', type=float, default=1.2, + help='reject a map whose r_crit is below this multiple of the robot radius') + sys.exit(main(ap.parse_args())) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..4524e4c5b0e169b039062c99de8a760e14c6cbfc --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances.py @@ -0,0 +1,72 @@ +""" +Build a synthetic `instances_data/empty_map.pkl` for the "empty" random-map +scenario (no obstacles) with 3 composite-robot agents, matching the schema +expected by: + - torch_robotics.environments.env_empty_nowait_2d_extra_objects.EnvEmptyNoWait2DExtraObjects + loaded_set[instance_idx][0][0] -> obs_info: list of (center_xy, radius) + - smd.config.smd_experiment_configs.EnvEmptyNoWait2DRobotCompositeThreePlanarDiskRandom + loaded_set[instance_idx][idx][1] -> agents_info: list of (start_xy, goal_xy) + idx = 0 for 3 agents, 1 for 6, 2 for 9 + +We only ever use idx=0 (3 agents), but populate 1/2 too so nothing indexes out +of range if touched incidentally. +""" +import os +import pickle +import numpy as np + +np.random.seed(0) + +N_INSTANCES = 20 # number of test instances (paper uses 10 per config; we use 20 for margin) +N_AGENTS_BUCKETS = [3, 6, 9] +RADIUS = 0.05 +MARGIN = 0.02 +LIMIT = 0.9 # keep configs inside [-LIMIT, LIMIT]^2, robot radius safety margin from workspace bound + + +def sample_non_colliding_points(n, radius, margin, limit, max_tries=20000): + pts = [] + tries = 0 + while len(pts) < n and tries < max_tries: + tries += 1 + p = np.random.uniform(-limit, limit, size=2) + ok = True + for q in pts: + if np.linalg.norm(p - q) < 2 * radius + margin: + ok = False + break + if ok: + pts.append(p) + if len(pts) < n: + raise RuntimeError(f"Could not sample {n} non-colliding points after {max_tries} tries") + return pts + + +def make_instance(): + buckets = [] + for n_agents in N_AGENTS_BUCKETS: + starts = sample_non_colliding_points(n_agents, RADIUS, MARGIN, LIMIT) + goals = sample_non_colliding_points(n_agents, RADIUS, MARGIN, LIMIT) + agents_info = [(np.array(s), np.array(g)) for s, g in zip(starts, goals)] + obs_info = [] # empty map: no obstacles + buckets.append((obs_info, agents_info)) + return buckets + + +def main(): + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + out_dir = os.path.join(repo_root, 'instances_data') + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, 'empty_map.pkl') + + dataset = [make_instance() for _ in range(N_INSTANCES)] + + with open(out_path, 'wb') as f: + pickle.dump(dataset, f) + + print(f"Wrote {N_INSTANCES} instances to {out_path}") + print(f"Instance 0, 3-agent bucket, agents_info[0]: {dataset[0][0][1][0]}") + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances_all.py new file mode 100644 index 0000000000000000000000000000000000000000..a3f22ad4502a7a6103c68acf0567c4a34a8ea82e --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/make_instances_all.py @@ -0,0 +1,107 @@ +""" +Generate instances_data/.pkl files for every random-map scenario +(empty/basic/dense) across all robot-count buckets, matching the schema: + loaded_set[instance_idx][bucket_idx] = (obs_info, agents_info) + bucket_idx: 0 -> 3 (or 2) agents, 1 -> 6 agents, 2 -> 9 agents + obs_info: list of (center_xy, radius) + agents_info: list of (start_xy, goal_xy) + +Practical maps (corridor/room/shelf) don't need this -- their obstacles are +fixed wall/box geometry already baked into the environment code, not +per-instance data, and their start/goal pairs are sampled directly at +data-gen / eval time instead. +""" +import os +import pickle +import numpy as np + +np.random.seed(0) + +N_INSTANCES = 20 +N_AGENTS_BUCKETS = [3, 6, 9] +RADIUS = 0.05 +MARGIN = 0.02 +LIMIT = 0.9 + +MAPS = [ + ('empty_map', 0), + ('basic_map', 10), + ('dense_map', 20), +] + + +def sample_non_colliding_points(n, radius, margin, limit, avoid=(), max_tries=20000): + pts = [] + tries = 0 + while len(pts) < n and tries < max_tries: + tries += 1 + p = np.random.uniform(-limit, limit, size=2) + ok = True + for q, r in avoid: + if np.linalg.norm(p - q) < radius + r + margin: + ok = False + break + if ok: + for q in pts: + if np.linalg.norm(p - q) < 2 * radius + margin: + ok = False + break + if ok: + pts.append(p) + if len(pts) < n: + raise RuntimeError(f"Could not sample {n} non-colliding points after {max_tries} tries") + return pts + + +def make_obstacles(n_obstacles): + if n_obstacles == 0: + return [] + radii = np.random.uniform(0.06, 0.12, size=n_obstacles) + centers = [] + tries = 0 + while len(centers) < n_obstacles and tries < 20000: + tries += 1 + p = np.random.uniform(-0.85, 0.85, size=2) + r = radii[len(centers)] + ok = True + for (q, rq) in centers: + if np.linalg.norm(p - q) < r + rq + 0.04: + ok = False + break + if ok: + centers.append((p, r)) + if len(centers) < n_obstacles: + raise RuntimeError("Could not place all obstacles without overlap") + return [(c, r) for c, r in centers] + + +def make_instance(n_obstacles): + obs_info = make_obstacles(n_obstacles) + avoid = obs_info + buckets = [] + for n_agents in N_AGENTS_BUCKETS: + starts = sample_non_colliding_points(n_agents, RADIUS, MARGIN, LIMIT, avoid=avoid) + goals = sample_non_colliding_points(n_agents, RADIUS, MARGIN, LIMIT, avoid=avoid) + agents_info = [(np.array(s), np.array(g)) for s, g in zip(starts, goals)] + buckets.append((obs_info, agents_info)) + return buckets + + +def main(): + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + out_dir = os.path.join(repo_root, 'instances_data') + os.makedirs(out_dir, exist_ok=True) + + for map_name, n_obstacles in MAPS: + out_path = os.path.join(out_dir, f'{map_name}.pkl') + if os.path.exists(out_path) and map_name == 'empty_map': + print(f"{map_name}.pkl already exists, leaving as-is (from earlier run)") + continue + dataset = [make_instance(n_obstacles) for _ in range(N_INSTANCES)] + with open(out_path, 'wb') as f: + pickle.dump(dataset, f) + print(f"Wrote {N_INSTANCES} instances ({n_obstacles} obstacles/instance) to {out_path}") + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_cbs_datasets.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_cbs_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..10555779c5fa011cbc7584ad124ac8f1d6571697 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_cbs_datasets.py @@ -0,0 +1,72 @@ +""" +Three-way merge (original synthetic + PP-generated + CBS-generated) into +data_trajectories/mergedcbs_, for the CBS pilot experiment +(basic-6, dense-3, room-6 by default). Copies (not symlinks) each task dir -- +TrajectoryDatasetBase.load_trajectories() uses os.walk(..., followlinks=False) +(the default), which silently skips symlinked subdirectories. + +Usage: + python merge_cbs_datasets.py [ ...] + python merge_cbs_datasets.py --all-pilot # basic-6, dense-3, room-6 +""" +import os +import sys +import shutil +import argparse + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from scenarios import SCENARIOS, get_scenario + +DATA_DIR = os.path.join(REPO_ROOT, 'data_trajectories') +PILOT_TAGS = ['basic-6', 'dense-3', 'room-6'] + + +def _ids(d): + if not os.path.isdir(d): + return [] + return sorted([x for x in os.listdir(d) if os.path.isdir(os.path.join(d, x))], key=int) + + +def run_scenario(cfg): + original_dir = os.path.join(DATA_DIR, cfg['dataset_subdir']) + pp_dir = os.path.join(DATA_DIR, f"pp_{cfg['dataset_subdir']}") + cbs_dir = os.path.join(DATA_DIR, f"cbs_{cfg['dataset_subdir']}") + merged_dir = os.path.join(DATA_DIR, f"mergedcbs_{cfg['dataset_subdir']}") + + original_ids = _ids(original_dir) + pp_ids = _ids(pp_dir) + cbs_ids = _ids(cbs_dir) + expected_total = len(original_ids) + len(pp_ids) + len(cbs_ids) + + if os.path.isdir(merged_dir): + existing = _ids(merged_dir) + if len(existing) >= expected_total: + print(f"[{cfg['tag']}] mergedcbs already has {len(existing)} task dirs " + f"(orig={len(original_ids)}, pp={len(pp_ids)}, cbs={len(cbs_ids)}), skipping") + return + shutil.rmtree(merged_dir) + + os.makedirs(merged_dir, exist_ok=True) + idx = 0 + for src_dir, ids in [(original_dir, original_ids), (pp_dir, pp_ids), (cbs_dir, cbs_ids)]: + for src_id in ids: + shutil.copytree(os.path.join(src_dir, src_id), os.path.join(merged_dir, str(idx))) + idx += 1 + + print(f"[{cfg['tag']}] merged {len(original_ids)} original + {len(pp_ids)} pp + " + f"{len(cbs_ids)} cbs = {idx} total -> {merged_dir}") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tags', nargs='*', default=None) + parser.add_argument('--all-pilot', action='store_true') + args = parser.parse_args() + + tags = PILOT_TAGS if args.all_pilot else args.tags + if not tags: + parser.error("provide scenario tag(s) or --all-pilot") + for t in tags: + run_scenario(get_scenario(t)) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_datasets.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..6b50a983fda4163277d9993fcd9ac2d0081e6abe --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/merge_datasets.py @@ -0,0 +1,78 @@ +""" +Merges each scenario's original (from-scratch synthetic) demonstrations with its +PP-generated (MMD-inspired, randomized-order) demonstrations into a single +data_trajectories/merged_ directory, with sequential renumbering -- +required since TrajectoryDatasetBase expects contiguous 0..N-1 directory names. + +Copies (not symlinks) each task dir: TrajectoryDatasetBase.load_trajectories() +discovers task dirs via os.walk(..., followlinks=False) (the default), which +silently skips symlinked subdirectories -- a symlink-based merge would load as an +empty dataset. Files are tiny (a few KB each), so copying is cheap. + +Usage: + python merge_datasets.py + python merge_datasets.py --all +""" +import os +import sys +import shutil +import argparse + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, REPO_ROOT) + +from scenarios import SCENARIOS, get_scenario + +DATA_DIR = os.path.join(REPO_ROOT, 'data_trajectories') + + +def run_scenario(cfg): + original_dir = os.path.join(DATA_DIR, cfg['dataset_subdir']) + pp_dir = os.path.join(DATA_DIR, f"pp_{cfg['dataset_subdir']}") + merged_dir = os.path.join(DATA_DIR, f"merged_{cfg['dataset_subdir']}") + + original_ids = sorted( + [d for d in os.listdir(original_dir) if os.path.isdir(os.path.join(original_dir, d))], + key=int + ) if os.path.isdir(original_dir) else [] + pp_ids = sorted( + [d for d in os.listdir(pp_dir) if os.path.isdir(os.path.join(pp_dir, d))], + key=int + ) if os.path.isdir(pp_dir) else [] + + expected_total = len(original_ids) + len(pp_ids) + if os.path.isdir(merged_dir): + existing = [d for d in os.listdir(merged_dir) if os.path.isdir(os.path.join(merged_dir, d))] + if len(existing) >= expected_total: + print(f"[{cfg['tag']}] merged dataset already has {len(existing)} task dirs " + f"(original={len(original_ids)}, pp={len(pp_ids)}), skipping") + return + # Partial/stale merge (e.g. from before a generation top-up) -- rebuild clean. + shutil.rmtree(merged_dir) + + os.makedirs(merged_dir, exist_ok=True) + + idx = 0 + for src_id in original_ids: + shutil.copytree(os.path.join(original_dir, src_id), os.path.join(merged_dir, str(idx))) + idx += 1 + for src_id in pp_ids: + shutil.copytree(os.path.join(pp_dir, src_id), os.path.join(merged_dir, str(idx))) + idx += 1 + + print(f"[{cfg['tag']}] merged {len(original_ids)} original + {len(pp_ids)} pp = {idx} total -> {merged_dir}") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('tag', nargs='?', default=None) + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + if args.all: + for cfg in SCENARIOS: + run_scenario(cfg) + elif args.tag: + run_scenario(get_scenario(args.tag)) + else: + parser.error("provide a scenario tag or --all") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/plot_hard_maps.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/plot_hard_maps.py new file mode 100644 index 0000000000000000000000000000000000000000..6cc48278cecbabb7ec39f63b511890fcb6c4b468 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/plot_hard_maps.py @@ -0,0 +1,143 @@ +"""Shipped vs proposed-hard version of every map, robots drawn to scale. + +Each map gets its own axis and its own hard value, because one global setting measures +nothing on some maps and makes others unsolvable: Conveyor's channels are wide enough to +need 1.8x, Highways is capped at 1.7x by PLACEMENT (N=15,21 cannot be instantiated above +it), and Dense fragments by 1.6x. The sphere maps use obstacle COUNT rather than radius, +so each circle keeps the size the benchmark was designed around. +""" +import os, sys +import numpy as np +import torch +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.patches import Circle +from scipy.ndimage import label + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from torch_robotics import environments as E + +G = 380 +# label, env class, map_name, shipped -> hard +SPEC = [ + # Hard values are SEARCHED, not guessed: the largest inflation keeping r_crit >= 0.10, + # i.e. a disk of radius 2r still passes, i.e. two robots fit abreast. See + # tune_hard_settings.py. Obstacle-count changes were dropped -- 20/40 circles pushed + # Dense to r_crit 1.2x, below the abreast threshold -- so the sphere maps are inflated + # at their ORIGINAL counts instead. + ('Empty', 'EnvEmptyNoWait2D', 'empty_map', dict(r=0.05), dict(r=0.10), 'robot radius 0.05 -> 0.10'), + ('Basic', 'EnvEmptyNoWait2D', 'basic_map', dict(obs=1.0), dict(obs=1.00), 'cannot inflate (wall gap already < 2 robots)'), + # Dense is ALREADY at the limit at 1.0x (r_crit 0.103): it cannot be inflated at all + # without closing the map to a pair of robots. + ('Dense', 'EnvEmptyNoWait2D', 'dense_map', dict(obs=1.0), dict(obs=1.00), 'cannot inflate (wall gap already < 2 robots)'), + ('Conveyor/Corridor', 'EnvConveyor2D', None, dict(obs=1.0), dict(obs=1.45), 'obstacles x1.45'), + ('Highways/Room', 'EnvHighways2D', None, dict(obs=1.0), dict(obs=1.34), 'obstacles x1.34'), + # 1.98x, not 2.0x: at 2.0 the four blocks MERGE into one mass and r_crit then measures + # a perimeter ring rather than an aisle. + ('Drop-Region/Shelf', 'EnvDropRegion2D', None, dict(obs=1.0), dict(obs=1.15), 'obstacles x1.15'), + ('Corridor+bay (SMD Fig.3a)','EnvCorridorSMD2D', None, dict(obs=1.0), dict(obs=1.00), 'single-file by design, cannot inflate'), + ('Cross Room (SMD Fig.3c)', 'EnvCrossRoom2D', None, dict(obs=1.0), dict(obs=1.13), 'obstacles x1.13'), +] + + +def build(cls, map_name, obs_scale): + os.environ['OBS_SCALE'] = str(obs_scale) + import importlib + import torch_robotics.environments.primitives as P; importlib.reload(P) + import torch_robotics.environments as EE; importlib.reload(EE) + C = getattr(EE, cls + 'ExtraObjects') + ta = {'device': 'cpu', 'dtype': torch.float32} + return C(tensor_args=ta, instance_idx=0, map_name=map_name) if map_name else C(tensor_args=ta) + + +def sdf(env): + xs = np.linspace(-1, 1, G); X, Y = np.meshgrid(xs, xs) + pts = torch.tensor(np.stack([X.ravel(), Y.ravel()], -1), dtype=torch.float32) + with torch.no_grad(): + S = env.compute_sdf(pts.reshape(1, -1, 2)).reshape(G, G).numpy() + # the workspace wall is an obstacle too; compute_sdf does not know about it + return X, Y, np.minimum(S, np.minimum(1.0 - np.abs(X), 1.0 - np.abs(Y))) + + +def _connected(S, rr): + f = S > rr + if not f.any(): + return False + l, k = label(f) + if k == 0: + return False + s = np.bincount(l.ravel())[1:] + return s.max() >= 0.99 * f.sum() + + +def _rcrit(S, steps=500, keep=0.5): + """Largest r at which free space is still ONE region -- by FORWARD SCAN, not bisection. + + "Free space is connected" is NOT monotone in r, so bisection is invalid: past the + bottleneck the space fragments, but at very large r only a single tiny blob survives + near the point of maximum clearance, which is connected again. The predicate is + True ... False ... True. Two brackets over the same map gave 0.061 and 0.381. + + Scanning upward and stopping at the FIRST disconnection is well defined. + """ + hi = float(S.max()) + if hi <= 0 or not _connected(S, 0.0): + return 0.0 + base = float((S > 0.0).sum()) + prev = 0.0 + for rr in np.linspace(0.0, hi, steps): + if not _connected(S, rr) or float((S > rr).sum()) < keep * base: + return prev + prev = rr + return prev + + +def margin(S, r): + return _rcrit(S) / r + + +def place(S, X, Y, r, n, seed=3): + rng = np.random.default_rng(seed) + free = np.argwhere(S > r + 0.015) + if len(free) < n: return np.zeros((0, 2)) + pts = [] + for _ in range(6000): + if len(pts) == n: break + i, j = free[rng.integers(0, len(free))] + p = np.array([X[i, j], Y[i, j]]) + if all(np.linalg.norm(p - q) > max(0.16, 2.2 * r) for q in pts): pts.append(p) + return np.array(pts) + + +fig, axes = plt.subplots(2, 8, figsize=(41, 11.6)) +for col, (label_, cls, mp, ship, hard, note) in enumerate(SPEC): + for row, cfg in enumerate((ship, hard)): + ax = axes[row][col] + r = cfg.get('r', 0.05) + env = build(cls, cfg.get('map', mp), cfg.get('obs', 1.0)) + X, Y, S = sdf(env) + ax.contourf(X, Y, (S <= 0).astype(float), levels=[.5, 1.5], colors=['#6b3b33']) + ax.contour(X, Y, S, levels=[r], colors=['#d98b7a'], linewidths=1.0, linestyles='--') + for p in place(S, X, Y, r, 9): + ax.add_patch(Circle(p, r, facecolor='#2b6cb0', edgecolor='k', lw=.7, zorder=5)) + # workspace wall (solid) and the limit on the robot's CENTRE (dashed, wall - r). + # Both papers confine start/goal to [-0.95, 0.95]; DiRecT's projector enforces + # ws_min + r <= x <= ws_max - r. Drawing both makes the usable band explicit. + ax.add_patch(plt.Rectangle((-1, -1), 2, 2, fill=False, ec='k', lw=2.2, zorder=6)) + ax.add_patch(plt.Rectangle((-1 + r, -1 + r), 2 - 2 * r, 2 - 2 * r, fill=False, + ec='#2b6cb0', lw=1.0, ls='--', zorder=6)) + ax.set_xlim(-1.06, 1.06); ax.set_ylim(-1.06, 1.06); ax.set_aspect('equal') + ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(f'{label_}\n{"SHIPPED" if row == 0 else "HARD — " + note}', + fontsize=13, pad=6, + color=('#333' if row == 0 else '#b03030')) + ax.text(0.5, -0.055, f'free {100*(S>r).mean():.0f}% margin {margin(S, r):.1f}x robot r', + transform=ax.transAxes, ha='center', va='top', fontsize=11) +fig.suptitle('Scenario 1 — shipped (top) vs hard setting (bottom), 9 robots at true scale. Hard = largest inflation where two robots still pass abreast (r_crit >= 2r), WALL INCLUDED. black = workspace wall, blue dashed = limit on a robot\u2019s centre', + fontsize=17, y=0.985) +fig.tight_layout(rect=[0, 0, 1, 0.94]) +fig.subplots_adjust(hspace=0.30) +out = '/hard_maps.png' +fig.savefig(out, dpi=95, bbox_inches='tight') +print(f' wrote {out}') diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/precheck_cells.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/precheck_cells.py new file mode 100644 index 0000000000000000000000000000000000000000..99628f2e122c48e66a8260c5e119f2f336140f4c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/precheck_cells.py @@ -0,0 +1,96 @@ +"""Can each (map, N, radius, obstacle-scale) cell even be INSTANTIATED? + +A difficulty knob can fail a planner for two very different reasons: + (a) the planning problem got harder -- what we want to measure; + (b) valid start/goal sets stopped existing, so the instance generator degrades or the + stored instances become invalid -- which says nothing about any planner. + +Confusing the two would make the whole hardness study worthless, so every cell is +screened here first. A cell that cannot produce N_TEST_INSTANCES valid instances is +excluded from the sweep and reported as excluded, not as a planner failure. + +Usage: python precheck_cells.py --tags corridor-3 room-9 --radii 0.05 0.065 --obs 1.0 1.4 +""" +import argparse, os, sys, time, json, itertools, subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) + +CHILD = r''' +import os, sys, json, numpy as np +sys.path.insert(0, __HERE__) +sys.path.insert(0, os.path.join(__HERE__, '..', '..')) +import warnings; warnings.filterwarnings('ignore') +from contextlib import redirect_stdout, redirect_stderr +import io +_buf = io.StringIO() +try: + with redirect_stdout(_buf), redirect_stderr(_buf): + from scenarios import get_scenario + from evaluate_smd_all import get_test_instances, N_TEST_INSTANCES + from generate_via_pp import build_reference + from smd.common.multi_agent_utils import is_multi_agent_start_goal_states_valid + import torch + ta = dict(device='cpu', dtype=torch.float32) + cfg = get_scenario(__TAG__) + env, robot, task = build_reference(cfg, ta) + inst = get_test_instances(cfg, task, ta) + n_valid = 0 + for s, g in inst: + try: + # signature is (reference_robot, reference_task, [start (2,)], [goal (2,)]) + sl = [torch.tensor(x, **ta).reshape(2) for x in s] + gl = [torch.tensor(x, **ta).reshape(2) for x in g] + n_valid += bool(is_multi_agent_start_goal_states_valid(robot, task, sl, gl)) + except Exception: + pass + print("RESULT " + json.dumps(dict(n_got=len(inst), n_valid=int(n_valid), + n_want=int(N_TEST_INSTANCES)))) +except Exception as e: + print("RESULT " + json.dumps(dict(error=f"{type(e).__name__}: {e}"[:160]))) +''' + +def probe(tag, r, obs, timeout=420): + env = dict(os.environ, ROBOT_R=str(r), OBS_SCALE=str(obs), + OMP_NUM_THREADS='1', MKL_NUM_THREADS='1', OPENBLAS_NUM_THREADS='1', + CUDA_VISIBLE_DEVICES='') + t0 = time.time() + try: + p = subprocess.run([sys.executable, '-c', CHILD.replace('__HERE__', repr(HERE)).replace('__TAG__', repr(tag))], + capture_output=True, text=True, timeout=timeout, env=env) + line = [l for l in p.stdout.splitlines() if l.startswith('RESULT ')] + d = json.loads(line[-1][7:]) if line else { + 'error': (p.stderr.strip().splitlines() or ['no output'])[-1][:160]} + except subprocess.TimeoutExpired: + d = {'error': f'timeout >{timeout}s'} + d.update(tag=tag, radius=r, obs_scale=obs, secs=round(time.time() - t0, 1)) + return d + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--tags', nargs='+', required=True) + ap.add_argument('--radii', nargs='+', type=float, default=[0.05]) + ap.add_argument('--obs', nargs='+', type=float, default=[1.0]) + ap.add_argument('--jobs', type=int, default=12) + ap.add_argument('--out', default=os.path.join(HERE, 'precheck.json')) + a = ap.parse_args() + + cells = [(t, r, o) for t, r, o in itertools.product(a.tags, a.radii, a.obs) + if (r == a.radii[0]) or (o == a.obs[0])] # one axis at a time, shared control + print(f' {len(cells)} cells, {a.jobs} parallel\n') + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=a.jobs) as ex: + rows = list(ex.map(lambda c: probe(*c), cells)) + + print(f" {'cell':<13}{'r':>7}{'obs':>6}{'valid':>8} status") + ok = [] + for d in sorted(rows, key=lambda x: (x['tag'], x['obs_scale'], x['radius'])): + if 'error' in d: + st = 'EXCLUDE ' + d['error'] + elif d['n_valid'] < d['n_want']: + st = f"EXCLUDE only {d['n_valid']}/{d['n_want']} valid" + else: + st = 'ok'; ok.append(d) + v = '-' if 'error' in d else f"{d['n_valid']}/{d['n_want']}" + print(f" {d['tag']:<13}{d['radius']:>7.3f}{d['obs_scale']:>6.2f}{v:>8} {st}") + json.dump(rows, open(a.out, 'w'), indent=2) + print(f"\n {len(ok)}/{len(rows)} cells usable -> {a.out}") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/recertify_instances.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/recertify_instances.py new file mode 100644 index 0000000000000000000000000000000000000000..a8ef3e092dd578b2bf48e4ee65a3cd51693f0064 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/recertify_instances.py @@ -0,0 +1,54 @@ +"""Re-certify saved instances at the SCORED clearance (SDF > robot_radius). + +The start/goal pairs in instances_hard.npz are fine; only their admission mask was +wrong, computed at r+0.055 (the planner's obstacle_cutoff_margin) instead of the +radius the scorer actually uses. No resampling needed for maps whose pool was not +truncated by the bad mask. +""" +import os, sys, json, subprocess +import numpy as np +HERE=os.path.dirname(os.path.abspath(__file__)) +OBS={'empty':1.0,'basic':2.2,'dense':1.8,'corridor':1.5,'room':1.45,'shelf':1.6, + 'corridorbay':1.15,'crossroom':1.1} +RR={'empty':0.1} +VMAX={'empty':0.692,'basic':0.692,'dense':0.692,'room':0.783,'corridor':1.46, + 'corridorbay':1.46,'shelf':1.13,'crossroom':1.13} +NPZ=sys.argv[1] if len(sys.argv)>1 else os.path.join(os.environ.get('RB_RUNS', '.'), 'instances_hard.npz') +z=np.load(NPZ,allow_pickle=True); old=json.loads(str(z['meta'])) +rad={ (m['map'],m['N']): m['radius'] for m in old } +out={} +for mp in OBS: + Ns=sorted({m['N'] for m in old if m['map']==mp}) + if not Ns: continue + payload={str(N):dict(starts=z[f'{mp}/N{N}/starts'].tolist(), + goals=z[f'{mp}/N{N}/goals'].tolist(), + radius=rad[(mp,N)]) for N in Ns} + tf=f'/17351827-0327-40c6-bf09-172d6047c948/scratchpad/rc_{mp}.json' + json.dump(payload,open(tf,'w')) + env=dict(os.environ); env.update(OBS_SCALE=str(OBS[mp]),ROBOT_R=str(RR.get(mp,0.05)), + CUDA_VISIBLE_DEVICES='') + code=f''' +import sys,json,numpy as np,torch +sys.path.insert(0,{HERE!r}) +from scenarios import get_scenario +import generate_via_pp as gp, feasibility_oracle as FO +d=json.load(open({tf!r})); res={{}} +for N,rec in d.items(): + cfg=get_scenario("{mp}hard-"+N) + env_,robot,_=gp.build_reference(cfg,{{"device":"cpu","dtype":torch.float32}}) + S=np.array(rec["starts"]); G=np.array(rec["goals"]); r=rec["radius"] + st=[FO.certify(env_,r,S[i],G[i],{VMAX[mp]},5.0,64,run_layer2=False)["status"] + for i in range(S.shape[0])] + res[N]=st +print("J"+json.dumps(res)) +''' + r=subprocess.run([sys.executable,'-c',code],env=env,capture_output=True,text=True) + if 'J{' not in r.stdout: print(f" {mp}: FAILED"); continue + res=json.loads(r.stdout[r.stdout.index('J{'):][1:]) + for N,st in res.items(): + adm=sum(1 for x in st if x!='infeasible') + out[f'{mp}/N{N}']=dict(status=st,admitted=adm,n=len(st)) + print(f" {mp:>12} N={N:<3} admitted {adm}/{len(st)}") +json.dump(out,open(os.path.join(os.environ.get('RB_RUNS', '.'), 'recertified.json'),'w'),indent=1) +t=sum(v['n'] for v in out.values()); a=sum(v['admitted'] for v in out.values()) +print(f"\n {a}/{t} admitted at the scored clearance (was 847/1120 at r+0.055)") diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/report_sensitivity.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/report_sensitivity.py new file mode 100644 index 0000000000000000000000000000000000000000..dca553012eaaf8cb767a5718b715846cc4a10720 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/report_sensitivity.py @@ -0,0 +1,75 @@ +"""Sensitivity tables: each baseline's response curve along each difficulty axis.""" +import argparse, glob, json, os, re +from collections import defaultdict + +SET_ORDER = ['ctl', 'o11', 'o12', 'o13', 'o16', 'o17', 'o18', 'o19', 'o20', 'r060', 'r065'] +SET_LABEL = {'ctl': 'obs 1.0x', 'o11': 'obs 1.1x', 'o12': 'obs 1.2x', + 'o13': 'obs 1.3x', 'o16': 'obs 1.6x', 'o17': 'obs 1.7x', 'o18': 'obs 1.8x', + 'o19': 'obs 1.9x', 'o20': 'obs 2.0x', 'r060': 'r=0.060', 'r065': 'r=0.065'} +AXIS = {'r060': 'radius', 'r065': 'radius', + 'o11': 'obstacle', 'o12': 'obstacle', 'o13': 'obstacle', 'o16': 'obstacle', + 'o17': 'obstacle', 'o18': 'obstacle', 'o19': 'obstacle', 'o20': 'obstacle'} + + +def load(out): + rows = defaultdict(dict) + for f in sorted(glob.glob(os.path.join(out, '*_*.json'))): + m = re.match(r'(smd|direct)_(.+)_(' + '|'.join(SET_ORDER) + r')\.json$', + os.path.basename(f)) + if not m: + continue + meth, tag, sname = m.groups() + try: + d = json.load(open(f)) + except Exception: + continue + rows[(meth, tag)][sname] = d + return rows + + +def fmt(d): + if d is None: + return ' - ' + s = d.get('success_rate') + return ' -- ' if s is None else f'{s:5.2f} ' + + +def main(out): + rows = load(out) + if not rows: + print(' no results yet'); return + for meth in ('smd', 'direct'): + keys = sorted([k for k in rows if k[0] == meth], + key=lambda k: (k[1].split('-')[0], int(k[1].split('-')[1]))) + if not keys: + continue + print(f'\n=== {meth.upper()} success rate (10 instances/cell, test-only) ===') + print(f" {'cell':<14}" + ''.join(f'{SET_LABEL[s]:>10}' for s in SET_ORDER) + + f"{'drop_rad':>10}{'drop_obs':>10}") + for k in keys: + got = rows[k] + line = f' {k[1]:<14}' + for s in SET_ORDER: + line += fmt(got.get(s)).rjust(10) + c = (got.get('ctl') or {}).get('success_rate') + def drop(names): + vals = [(got.get(n) or {}).get('success_rate') for n in names] + vals = [v for v in vals if v is not None] + if c is None or not vals: + return ' - ' + return f'{min(vals) - c:+9.2f} ' + line += drop(['r060', 'r065']) + drop(['o11', 'o12', 'o13', 'o16', 'o17', 'o18', 'o19', 'o20']) + print(line) + # incompleteness is reported, never silently averaged over + part = [(k[1], s, d.get('n_instances')) + for k, v in rows.items() if k[0] == meth + for s, d in v.items() if d.get('n_instances', 10) < 10] + if part: + print(' [incomplete] ' + ', '.join(f'{t}/{s}:{n}/10' for t, s, n in part)) + print('\n drop_* = worst success on that axis minus control. ' + 'Negative = the axis degrades the method.') + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(); ap.add_argument('--out', required=True) + main(ap.parse_args().out) diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all.py b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all.py new file mode 100644 index 0000000000000000000000000000000000000000..71f314cbeb40a78aa7530fa4c5c49b1b53511344 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all.py @@ -0,0 +1,75 @@ +""" +Driver: runs make_instances -> generate_data -> train -> evaluate for every +scenario in scenarios.py, in order, skipping already-completed stages +(each stage script checks for its own output and no-ops if present), and +tolerating per-scenario failures so one bad scenario doesn't stop the rest. + +Usage: python run_all.py +""" +import json +import os +import subprocess +import sys +import time + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +SCRIPT_DIR = os.path.dirname(__file__) +sys.path.insert(0, SCRIPT_DIR) + +from scenarios import SCENARIOS + +LOG_PATH = os.path.join(SCRIPT_DIR, 'run_all_status.json') + + +def load_status(): + if os.path.exists(LOG_PATH): + with open(LOG_PATH) as f: + return json.load(f) + return {} + + +def save_status(status): + with open(LOG_PATH, 'w') as f: + json.dump(status, f, indent=2) + + +def run_stage(script, tag): + cmd = [sys.executable, os.path.join(SCRIPT_DIR, script), tag] + print(f"\n$ {' '.join(cmd)}", flush=True) + result = subprocess.run(cmd, cwd=SCRIPT_DIR) + if result.returncode != 0: + raise RuntimeError(f"{script} {tag} failed with exit code {result.returncode}") + + +def main(): + status = load_status() + + print("Generating all instances_data pkls (empty/basic/dense)...") + subprocess.run([sys.executable, os.path.join(SCRIPT_DIR, 'make_instances_all.py')], cwd=SCRIPT_DIR, check=True) + + for cfg in SCENARIOS: + tag = cfg['tag'] + if status.get(tag, {}).get('done'): + print(f"[{tag}] already marked done, skipping") + continue + + print(f"\n{'=' * 60}\nSCENARIO: {tag} ({cfg['dataset_subdir']})\n{'=' * 60}", flush=True) + t0 = time.time() + try: + run_stage('generate_data_all.py', tag) + run_stage('train_smd_all.py', tag) + run_stage('evaluate_smd_all.py', tag) + status[tag] = dict(done=True, elapsed=time.time() - t0) + print(f"[{tag}] COMPLETE in {time.time() - t0:.1f}s") + except Exception as e: + status[tag] = dict(done=False, error=str(e), elapsed=time.time() - t0) + print(f"[{tag}] FAILED: {e}") + save_status(status) + + print("\nAll scenarios processed. Status:") + for tag, s in status.items(): + print(f" {tag}: {'OK' if s.get('done') else 'FAILED - ' + str(s.get('error'))}") + + +if __name__ == '__main__': + main() diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all_status.json b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all_status.json new file mode 100644 index 0000000000000000000000000000000000000000..ad7320a16dda790c46fc4a68bef4f85f36a8e38c --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_all_status.json @@ -0,0 +1,66 @@ +{ + "empty-3": { + "done": true, + "elapsed": 63.13148379325867 + }, + "empty-6": { + "done": true, + "elapsed": 1264.7655339241028 + }, + "empty-9": { + "done": true, + "elapsed": 3276.8867933750153 + }, + "basic-3": { + "done": true, + "elapsed": 4795.977411985397 + }, + "basic-6": { + "done": true, + "elapsed": 13408.675094127655 + }, + "basic-9": { + "done": true, + "elapsed": 26241.75526714325 + }, + "dense-3": { + "done": true, + "elapsed": 10631.627315759659 + }, + "dense-6": { + "done": true, + "elapsed": 30197.683992147446 + }, + "dense-9": { + "done": true, + "note": "Intentionally excluded: only 2 demonstrations possible in a 20-min budget (112704 candidate configs tried), genuinely near-infeasible with the fast synthetic generation method. Documented limitation, not retried." + }, + "corridor-2": { + "done": true, + "elapsed": 30.96598529815674 + }, + "room-3": { + "done": true, + "elapsed": 57.65876865386963 + }, + "room-6": { + "done": true, + "elapsed": 1960.9480805397034 + }, + "room-9": { + "done": true, + "elapsed": 3714.583260536194 + }, + "shelf-3": { + "done": true, + "elapsed": 201.7592043876648 + }, + "shelf-6": { + "done": true, + "elapsed": 1571.4311594963074 + }, + "shelf-9": { + "done": true, + "elapsed": 3524.5988461971283 + } +} \ No newline at end of file diff --git a/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_and_push_to_paper.sh b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_and_push_to_paper.sh new file mode 100644 index 0000000000000000000000000000000000000000..ade61e602c1daf98bbd193e0263c4777d7b2a0d4 --- /dev/null +++ b/applications/multi_robot/robotarium/robotarium_baselines/SMD/scripts/repro/run_and_push_to_paper.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Runs the full SMD regeneration pipeline (data -> PP-search demos -> merge -> +# 150k-step training -> evaluation) for whatever's not yet done in +# scenarios.py, then patches the completed SMD numbers into +# /paper/text_new.tex (Appendix C-equivalent +# per-cell table + the tab:multirobot summary row). Never touches "our +# method"'s (Algorithms 1-2) numbers. +# +# Fully idempotent / resumable -- every stage checks its own prior output +# before doing work (see run_full_pipeline.py's docstring). Safe to: +# - run this exact command again any time (after a reboot, a lost +# connection, whatever) -- it picks up exactly where it left off +# - run in the background and walk away: +# nohup bash run_and_push_to_paper.sh > /tmp/smd_paper_push.log 2>&1 & +# disown +# +# Checking progress: +# tail -f , or +# python3 compile_smd_paper_table.py --dry-run (shows N/12 cells ready) +# +# When done, this prints "PAPER PATCHED" and the diff of what changed in +# text_new.tex. It does NOT recompile the PDF (no pdflatex on this machine) -- +# compile text_new.tex wherever you normally do. + +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CONDA_SH="$REPO_ROOT/../miniconda3/etc/profile.d/conda.sh" + +source "$CONDA_SH" +conda activate smd +cd "$SCRIPT_DIR" + +# Guard against launching a second, racing pipeline (e.g. cron firing every +# 15 min while a run from an earlier tick, or a manually-launched one, is +# still going) -- the individual stage scripts (generate_via_pp.py, +# train_smd_merged_all.py, evaluate_smd_merged_all.py) don't lock their +# output files, so two concurrent writers to the same eval_results.json/ +# checkpoint can corrupt it. If one's already running, just exit -- do NOT +# block-and-wait here, or repeated cron ticks pile up waiter processes that +# could all wake and race on patching the paper the moment the run finishes. +# The next cron tick after the real run completes will find nothing running +# and proceed normally. +# +# Uses a PID-file lock, NOT `pgrep -f diff --git a/applications/multi_robot/robotarium/videos/obstacles.json b/applications/multi_robot/robotarium/videos/obstacles.json new file mode 100644 index 0000000000000000000000000000000000000000..dad27e0fea5782198d033844454d4f3974f26374 --- /dev/null +++ b/applications/multi_robot/robotarium/videos/obstacles.json @@ -0,0 +1,9 @@ +{"source": "https://claude.ai/artifact/2Tyt9D1sJ9EpBkVtNKRX6D", "convention": "axis-aligned boxes, center [x,y] m, size [w,h] full extents m", + "maps": { + "empty": [], + "corridor": [[[0.0,0.0],[0.88,0.11]], [[0.0,0.4],[1.1,0.11]], [[0.0,-0.4],[1.1,0.11]]], + "crossroom": [[[0.0,0.0],[1.19,0.153]], [[0.0,0.0],[0.153,1.19]]], + "shelf": [[[0.4,0.4],[0.16,0.16]], [[-0.4,0.4],[0.16,0.16]], [[0.4,-0.4],[0.16,0.16]], [[-0.4,-0.4],[0.16,0.16]]], + "corridorbay": [[[-0.89,0.31],[0.044,0.076]], [[-0.89,-0.31],[0.044,0.076]], [[-0.3,0.31],[0.048,0.076]], [[-0.3,-0.31],[0.048,0.076]], + [[0.3,0.31],[0.048,0.076]], [[0.3,-0.31],[0.048,0.076]], [[0.89,0.31],[0.044,0.076]], [[0.89,-0.31],[0.044,0.076]]] + }} diff --git a/applications/steering/xhand_data/handover_random_1007/DATASET_CARD.txt b/applications/steering/xhand_data/handover_random_1007/DATASET_CARD.txt new file mode 100644 index 0000000000000000000000000000000000000000..7e5c0b2e6be400c22694c3bff762cad64aee89d3 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/DATASET_CARD.txt @@ -0,0 +1,10 @@ +a. Task ID: Teleop-Glove 10-07-2026-1 +Task name: Hand over a tissue box between shelves (randomized positions) using RobotEra XHand1 on DexMate Vega, teleoperated with a Meta glove +c. Number of subtasks: 1 +d. Description of the subtask: The operator picks a tissue box from one level of the shelf with one hand, passes it to the other hand, and places it on a different level. Object placement is randomized across episodes. Teleoperation is glove-based: the operator wears a Meta glove for finger retargeting onto the XHand1, with no exoskeleton. Wrist and arm pose is tracked via Vive. +e. Number of episodes collected: 104 +f. Any extra comments: XHand1 12-DOF per hand + fingertip tactile; Meta-glove teleop, no exoskeleton. 4 RGB cameras (head_left, head_right, left_wrist, right_wrist), 640×360 @ 30 fps, H.264. Command rate 30 Hz. Arms 7 DOF each with joint positions, target DOFs, and 4×4 current/target poses. Episode durations: mean 19 s, median 19 s, range 16–25 s, 33 min total. 10.6 GB. Known issues: right-hand tactile pressure maps are weak (under-scaled) in this session, right-hand fingertip force is fine; the left ring fingertip sensor responds only to firm contact. + +Collected Data History + +2026-10-07: Hand over a tissue box shelf to shelf (randomized positions), XHand1 hands with tactile, Meta-glove teleop (no exoskeleton), 4-camera — 104 episodes — avg ~19 s/episode — /data1/dexmate/teleop/handover_tissue_box_shelf_to_shelf_random_20261007 diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0001_20261007_093126/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0001_20261007_093126/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d9bd430b9a2e72cbbe33bb10663c9bd28c78d6a7 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0001_20261007_093126/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0001_20261007_093126", "meta": {"split": "held-out"}, "act": [14, 626], "T": 627, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0002_20261007_093201/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0002_20261007_093201/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..6c113a7e6161c989cbac0b8c10b90ce0122c1827 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0002_20261007_093201/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0002_20261007_093201", "meta": {"split": "train"}, "act": [9, 576], "T": 577, "maps": "H4sIALa51moC/+3di1bbyJqAUVWVZBsC5Nqdua018/5POcYBqyA2toVuJe1vzZxD90kgcdDOX3JJqqqVFcLhv+LLf0vS3L1KvJLEK0mSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEkquo9v5x7OfPzmp7kjvKQ5e3X9p5Ak85UkXt3603glac5eHaetkH2K8M4xr66kOXiVoRROfcwrSUN61QUYXkkyX0lSv/NVdd4oXkma1XzFK0m8kiReSVqnV863S+KVJFkPHovHX2KsU4xNiodfd9zVMab645+7qePrh3UIIflWkib26rr7MyzBq71Vz2b98eqOV9ISvSp6PfjOK/OVxKvivDrMV+kWryKvJF5Ntx68ySvzlTS5V1f8fPMVryRejetV3d2rxCuJV6N6lXglFe3Vis5fHbwKnbx6XhDySpq/V9VS9l918CpkAxavJF7xShKvqt72Mzh/JY3vVeAVr6RPmxJOfvwBQC8Lm/D84z/8GeYrXklz8Cre6JX5ildSKV6Zr3gljepVzL2Kg3vl+sHK+4PSaBZ+GhjzlflKGt2rrvNVOPFxVeD9Res397/ilVTWfGU9yCvJfLUEr44/2/WDUileFQTUB17V3b1y/ytpDl5d8fN7/Fy8kmS+GnY9yCupGK9We76dV5L5qhSvKl5JI3sVunsVspkqeD6OJOtBXknqw6t3n2JV+694JZXn1a0/gVeSeDWNV/a3S7wqyKvIK2lEr06eeCp6t8LNXr19XmrNK8l8tTiv9stBXkm84pUkXvFKKtqrVd8Puf7UfgZeSZN7tabn0b+fr3glLW2+Wsz1OB28CrySeDWJV8l8JRXkVaefv5T7t9cxNSmYr6SFe2U9yCvJfDWeXH/mK9fjSIv1KmRGhfKfj9N4npe09PmqyIGKVxKveCVpGq/W/LzUy9fjeD6OxKtJvEq8klbg1SL2M9S8khbgVTjx8fK8Ml9JpXt1+eear3glleDVdSvGVcxX9otKM/bq1J72tT2PnlfS2F59TpmFXO9cZV5VvJJKnK88j55X0jy9Ct1+7mqfP8graU5erWu+cj8Zabnz1QXUeCVpVl4tZL6qsusHK893lni1NK/MV9J0Xl2/1FucV5+9PwOvpAK8qhb7vFReSeYrXknq6lW44NXH92eoFvv+IK+ksry64ucvdv8VryTrwfl6ZT0o8WrpXtl/JfGqGK+S+/VJvOKVpFu8cv/2c15V1oPSjLy64ufz6nm+4pU0ilf2M/zxKpmvpIK86kLXkrz6xPPoeSXxatT1YFevkv1X0gy9KvrpXYN5Zb6SzFe8knTOq66fotgB7M39GZy/ksqerzxvglfSnLz6CJgV3a+PV9ICvFrF/a+q5/Wg+7dLvCrTK8/zkng10/Wg+yFLBXj1ufPty/XKfCWVN199zI/3B3klTeHV9aNTqQPVMF49g8UrqQivPG/CfCWN5NWn7yez0PmKV9ICvXL+ilfSDNaD6/LqE+8POn8l8YpXkq70as3Px7ns1fFj7w9KvBrZq6ajV85fSbya1ivPd5bK8uqyWbzilTQfry5qVq5XmVx7q5L9V9LyvQoFAtXnfMUrqUivPr58esY19XG+cr8+qQCvugCzmPNXtf3tkvmqFK8Sr6SivVrX/fo8b0Ja/ny1jPVgsl9UWrhX1Yrvh8wryXxVynqQVxKv5jFfXd7PwCtpOq/Wvp/hpvXg1vuDEq/KWQ/ySuLVBF5V2fuD1ZXzVbIelObk1YruJ1On5Hy7VI5XVQevqsXsZ8j3XzW8korz6vLPXcx81WRe3TfOX0m8mq1XG15JWoFXrh+UxCtJOu1VbT0oqQCv3rw/eO/9QUnz9qq+cT+D+UrSRF4l1+NI4pUk9elV5JUk85Uk9TtfuZ+MpGK8qmfwfJzYfhhi9Eckqd/5qk+vmuzDTTPsb//UrYRCCG8+Dr5LJF6d+7TZh9vNsEbxSlrferDP81cZUdvdtm+swsUbaLxBjFfS4rx6BqsMr2KIp2nKfkj+cYy8kmboVUpdnpcasvmq+9nxTavCpqkzr3o+4R7feBV5JfGKV5KGXQ+m6c5fbeIprzYDeJVtkYgXz03xSpqrV3H5XqWY/TovU8SrvgpV++7Gu3dgvTjiFa94pcWvB8M0XuWftn2DcHM3wHow3bQeTMkW+15e+Oe/LE55FYGlTl6lNIvrcfL9ond972fg1Ty8itUJu6QCvZp4voqRV7zS/L2K6/AqnPjdn/Hq+UV4o1qgF680C6/mcj1O61UzjFfhwwHrQ6/cMIJXWrtXMTtpVcf2ngz1bmivIq94pTK9SrPwKvBq4V7F3CvvD6qzVxPdXzRkN71KmVdpu+n5rqW84pUWsx6cw/2Q69arOIBXwfkrXmkZXtVzmK/qAb3aHx7p1O+eVyN65fyVivbqzU2QecUraUiver1/+5DrQV7xSsvwasr9oqfnq7Dd1L17den+orzilZbpVTXsenAIry6/JG/v18erfl54XmnpXlWbvr265iXhVd9Q8Uqz8urT68F6PK/i3wcTr8xXKs2rOnX2KoWBvGoG9erk8wdjds9kXg3vlRsiamyv6gV5Fd56FXnV16vOK03rVW/7r3jFK4lXt69c3j3Py/2v+vYqf6hacIN8Fe3V/pMdP2x4xStpbl6lOXnVHkK84pXm6lVchVfXnL/iFa80c6/SXLyKvFq4V5FX4hWveCVejepVSLzilXSrV7X5ile8kvmKV7zilXh1w2+ZV7xSsV51eN5ExSvxSrwazKtLveWJV0N4Zf+VeDWAV4FXvNJ4B9/b75irvbq/7fxVMl+JVypkvvr0/UXPetXwaoFeOX+lPryaw/2Qh/bKfoYpSrlXiVfq36vr94sO59WGV7ySVurV8/0sW5oyinjFK5Xk1ZTPS+XVWr1KvFK3+SpNN1+lkbwKuVeBV5N4lXilAbwacb4ay6vDb/n0BvdT/45X5istZ76qivTq0gjGqxHnK+evxKtPLRk/8srB1fN8xSuN5lWJ+xl4NZVXkVfq0av3+9tHnK94tS6vAq/0Obn2XqVmdvvbB90vWgVejfXtdd4rL446elWvYL4Kt3j1/Ne/8+09zVdnzl95cbQUr+qJvYq8GmQ9yCt1Uqq3653DIr36a76yHuy+HvT+oHrzqsq8qmbz/iCvlj9feUk1vlflzFe37BflVY/zlfcHNeR6cMTnD47o1S2e8Wqg+cr7g+rdqxGf58UrXkm84tVsvHK+XX169Xw9TrjJq6pAr5xvn+QbjVfq3atmBfMVr2bglfPtmtAr+xl02SvvD2par6oSvQq84pWW59VCr8fh1dReRV6JVwN4xivzlWbqlf0MvOKVeFWsV5FXg68HvTjqZT3IK/NVX73ZL+r8lSbxquKVOngVeKUe1oPmK14Ntx7klYb06vr3B4fyquYVr6QzXtUz2y/KK15JfXk19PU4vFqSV5FXmoVX1oPilcb2am7vD/JqBV55cWS+4tXsvHL+Sn3PV7fdr49X4pV4xSteSbziFa+0BK/SKrxyf9GpvPL+oMxX5iteaa1e2S/KK16plPWg/Qy8GtyrZP+VevXqvjFf8apnrxKv1KtX9ovyynpQk3XhWTDZv33r1Y3zlfcH9XGpOnv/di+OThyQcQCvKl7JfKVCvCp8vjr5KMIQWqF4Zb7SLL2qsvPtFa94NZpXXlJ19Kq+xastr2S+0kAH5y3rwSvfH4wFenXxJeMVrzRPryKveMUrLcGry8/HWeb+K17xSrNsLs8fTHPyKv+YV7zSHOXae5V4Zb7ilcpYD054fwbz1Qq8OnO/Pi+OTiB1eb9o9+fj8Eq8Eq94tZDTDZXnealXrxKveDWKV8n1g/r8fJV4xSteqZD5KtnPwCteqZD5yv4rXvFq9of0y4sXLl5rxyteObh68Mr9r/r4/lyoV7H9jgmXvKqnep4Xr8xX0k1ereP+7e4nM0X2t898+cWrXrza8mpxXqXswoiQzFc65VW60atwg1eJV+rqlRdHt3v19v6iB6/mcD8ZXvFKvLpmPXi9V30+LzXNwKsq9+riE3XEK/V9cKaPf8Dhepw1nL+6+JKZr8b1ykuqHuar4Hw7r4b3yn4GdfNqJfMVr6wHtU6vhnjeROKV+Uq8uvH9wTXPV1XulfPtw3rlxVFHr5qOXvX5/uAc9rfzakivPN9ZPawH6zTd/dszr8IMnjfBK16JV7d61Xg+zkKKvNJw68H7ZjqvUpjYq/xjXvFKc52vju8P3uhVn+fbebV4r9z/SpN6Zb7SLV6Zr9THerCeaj2YeGW+Eq8+c73ziPtFZ+WV8+28UgleNbziFa9kvuIVr3ilgearZrLnTfBq8V7Zz6CzXp0g4fJ8dc8rXvFKvOIVr3glXvGKVyrdq9vOXwVe8WqAXO+spc5XrnfmlXh1g1dj35+BV9aDEq94xSsV5NWQ+0WX6lWVe+V+fbzS/LxKvOLVUDl/pSHmq8ArXvFK5ite8YpXGsirwCte8Uqz9Sqar3jFK/GKV7y67JXdIrwa5fzV0va384pXmrlXNa9evbJf1HpQ5ite8YpXMl9ZD/JK5ivzlfmKV5rnfNV4f5BXvFIxXnW7Hsd6ULzSuF7FmawHp7+/KK+G9crzvNTNq+O/POwXbdLhh/+5Hqca0at4zquGV+Yrrah48sO2ujpQE/f61Ls6bQ7fRLtnNMJFr9pP8kmv8l9vnX2Jpp6We+evevTq9bV797xUL44ue3UcXLZVc/f6D4/7/3sWIl6zDNvkltT9edWOVHG7SZO+dn+8qsxXvXwXHt/FyL0K/grQFW1fP9hVmy+v//BUVd+aTl71+Ddx61XazcIr81Ufr2TmVeBVz6/uB3+hLuT1vX/94Et19/3h5eNvVfXP3bWfoU+v4hmvtmmIA+f0Fz7xJxucXimwTX74xnA8bVat8s9yIV49ZF79+vqyPtzPV7+/VNV1SPTpVfsV69aruPeqHvKPb/93e8z+Ymq3XWVe+eu/vKVD9kcWU2zfcFqsV6E9jOrw95y1qFOEd9Xu3+8vXj3snn4/HPWox/OqPjlfxd225/cH3yw/wtt/ypg6/SNUyNLh9LS+rdJSf8fZHoDm1av8RXjzT4W3q7a/f+z1OfxJ777+fjx6tTvpydBexaZ9bbebnvdfXedV5exKb3/tZzNraI+uQbeIPLQubTOv7rMNhXGAc6/p5XMeTgDvNu26odm92V44wAud2v/expNeLYLqw29iU9W/f1b14ZT7Zvv138fjH+ZjNmKPth7cf/X2HzaDepUvAU8fOLz69NI7tB9nf/MP61X7jbnL3m1+yvbfxAH29d09vC5Y9kT957ejXdXTz+yccLMb4Ctnn//bduQ/4NcjeIQDJb2MNvv14J+XMW2q/Xrwz99Jafs0nlf5uJp51TTNcK/wXwcLr4Z7scOI89WXdPIb9yENe0bn4cfLHPf8Pfzfv/4cI8//cVizHH9BDwMsgL+0H//YjfcHnO+dawZeccbMq19fX+aruHk+3/5nsK032au8Gdir/Ns3exVSSg77xXg1GFDvD990PJ7y+fx+4CPq8Vd7GqX++bM9z/H4O/+b/7H/r/yUfc5fd+P9AWdX+labsbza/8f3x1evmur33ct6sG4ytZsRvcq+te0m0K019V07no83bMTm8Z/jcdykr/n/9sarp/6/9tfsc/5zvyCv8r3um5d/3P/Hw93zH3OKcT9Z/do8/zp2zy96JvXQ59uvsEu66iDatFuhN19G/MJP/2Z/q3/7lv0v/2Z6bQb26teIXsWBz1/l683jOyT7f/e8ayA9359h79Vjev5xd/X+4+3H6/2BrseRevLqboiTRee9+p1Nc9+/n/Gq+TrwenBEr/JpIg7tVWop2rx6VdeHs5LXepX9GnmllXv19ffdNV4NMF/lXv0c8fzVJF6Fl3fkXry6e/4xf3kVTnt1/Hw93p9B6smre14tx6vYUpSOXqVqF168itWFvU9bXsl8xavRvfozX6UbvTJfiVe84pXEK16FD9eDvBKveDV/rz49X336/u0Sr+bk1TVXTJmvJF7xilfi1cBe/UdGxQ/rwRl61dvznSVe8eoWr+pPzVeBV+IVr3glleaV9wd5Jc3ZK/sZ5u5Vb8+jl/r3aseror2K3h+U+YpXhXvlehzxile8knjFq46f/0Oval6JV7zilTSeV8fv290M91/VA3v1w/uD572yv11zq563Vw2vzFfSvLyyHuSVVLxXNa94JfGKV7wSr7p4Neb59uj81cGrhlfi1ey9sl+UVyrWq639DKvwqsN85Xpn8YpXvJJ4xat+14P2i4pXvKqcv5I+7dVmffsZrAd5pVLnq0fzFa94JevBvw6ub7zq+/Pfdr0zr2S+6sOrzKh6gF/Sar1KvNKCvNrMZL56Ml+Zr6SPvdqOer7depBX0me8muF60PWDw3iV7L8Sr3g1nVfxCq+29ouKV8N51f8viVe80gK8muX7g85f9boe5JXMVx1K69vPMLiHt3lV80plezXi+4O8GtSr0Od85f4MMl9N5VVa7How9OxV5JV4NblXD9N7FXgl8aqQ+YpX0ie92vKq6PVg3+eveCVe8aq3+SoH8JJXiVfiFa8m7BavXuarqvL+oJbh1UrOXz2u0qtkP4N4Zb6at1dV61WqK17JerAsr6rHifYzTOZVfF0P8krmq+K8WuV8VXefr1yPI/OV+WoKr6rK/frEK15N7lU8d779uB5MvNJivNptvT+4uPnqnFeJVzJf8aoQr5y/Eq94NaVe4SOvkvv1iVclerXU8+23eeV+feIVr3gl8YpXXbyqWq/e7We43iv7RcUrXk0xX/FKC/Fqxyte8UrmK16N4NVV+0W7nL/ilcxXvBrVq1vPt295JfMVr8ZfD3a6H/IbryKvxCtezdcr60HN3KsNr3hlPShe8arA+crzJsQrXg3llfu3i1fTevXEq6v18jwv8cp8tYj1oPsziFe8WvB8ZT0oXvFqHl7ZLype9e2V81dXF3klXi1vvlro/ZB5JV7xap3zlfNX4hWvhpuvbnx/0P1FVbhXD6vw6mltz8exn0G8GserAc6388rzUsUr81VRXl1eD/JKvJreK9cP8kq8KmU9uLb7i2Ze7XglXpmv5qBX3+8P8kqz9sp+hqLXg7wSr5Y3Xy10v+hNXiVeiVe8KmS+usUr+9vFK+ev+tbrlvcHeaXivVrH+fY1X+/c6f1BXolX1oMTeLXllawHzVcz6GOvatc7y3zFq4V65f5X4hWvCvLKfgZZD/KKVxKvrvzK69wvyivxynw1o/mqV6/sbxevZuHVKq935pWW5tVKzrfnXu1W65X7i6pEr47ft9vtiF7FeXj1nVeXvbKfQXP0ajOuVy0V8YlXnYTKXs8Pzl9FXolXvJqXV+FDr16ux6kq1+NoEV7ttlN5ZT04/Hx1q1f2t8t8Zb6azXx1/fMHeSXzFa94JfFqNV69e0l5JV7xqpD5ilfi1RheVd95ZT0o3eKV8+284pXKma9GvH6QV/3rdcV+0Q2vxKsO5V6lryN6Fdc2X0VeiVef9mrb/iLOepX6X6KuyasQ33nVXO2V/aKat1d343r1O/fqW+7V46BeVYt9Hn38a2148Cp09cr1OJpbd7lXT6N6dbyRTWqe3niVGZW+8KqLV/Evr1J69qq+ZT3IK816vhrTq6//TuXVQ+7VkvZf5V7Fv7x6nq9S61XglXh1g1fHC27r5unrOa/ul+hVPgkNNF+d8apdD4YNr2Q9eLVX/xwPgWazywi5H8KreMar77zilXh1hVdV69WPH+3/8Jh7VfflVXYsf5n8/cHRvMr2M6T3+xmu98r9kDVPr0b8rvz6qz00tv/R/kP1lHvVDOzVt+m9yrch5AJ9yqv3+69i5lXglZbhVT3wV8uOqKefmVf/+09GyL9f+vcqLdKrd581nH/R096r55Ptm4PdnjehUr3atF5tBvcq66ldAm63/5d79U/uVU/7Dd54Na/zV2+2eYYBvhqvtJh2TXu9czOmVw9fs/nq/35mXv3MjGp68uScV0vaz/DRj0pNffCqbr26fP/2wCvNrG3r1WZUr3atSnXzP9l+hsev2ZmVetOTV9nH96efPxhP/+iZexXbX334ALS9VynWf35nwfMmVDBYx1mmfmpG/LrZ5TjNf72OPPvj4uFp0wJS9/Qryg+3u2y9+T37VSzZq3jwKh0Won+8SrxSiWXftw+b8b7s69Fw+Bv/vmlHnGabHRt9HSf5cmmTrQG/ZZvsW7pSIUfnLV4d3h+MvFLhZd+JXybw6nDY1CljYnM4qF4vgKt6PwudwVR9bVbj1fN8dTglxSsto/sxvUqZVyH3qsmNGsCr/JxYuwJOdeZVXcafF6+0tvKV0t2I569ej4B88ffn8Kpzo0L1uU1JF7x6XIpXiVdam1e7ab2qMq/+OuJ6XABnv8uHujWqZSzyileavVfNiN+V54+Auq8N3+e+cnakflmXV4FXKrv8u7yO433d80dAGtqr7Cvfpdar5opfW7le1Td65f6imrtXKYz3dafz6ng1zP6I3cbXf9cewLG3+yfM1av7Zv/bTeYrle3VmIfpDLxKL3snXrxKvDrtletxxKs4mVehPcbr9m3IMMWrMLJXMfPK+SsV7tWIy8ErvQpDehXX51Xs4pX5Sqv36uTd5T7yqje68k/Ufq0wxJea2qsq82ov1JvrcdxPRrzq5NW7Y2sSr6olevW56wc9L1W6OF9N71XFK15Js/CqOuNVkbMxr6SxvQqXvNr/9yBexZXNV/a3S7yavVf1X15d3n9lvpK6eBXG9SqYr8xXUiHz1SK96n5/BvsZxKtZeHX6k67CK9fjSIvwqpjXcByvrAfFq+u9+oPVMF6V/Rp2vH7wxv0M5ivx6hav2vPtoQe2eHXb+4O8Eq94Zb6SeMWrE1512M/AK4lX1oNSwV69jVe8knh1wqvV7Gf463kT9l9JPXgVrvUqdD/MVzhfRfvbJV6V4VWKz//PK4lXRXhlvpJ68OrtWSReDeKV650lXhXiVc0riVfleOX8ldSDV+/+F14NNl9VlfszSKN51dNhvj6v0nuv3F9Uuv5YC7wa1avDfgbzlVSYV8t5DW/ez8AriVeL9MrzcSRe9Va82qsq2y9a8UriVQHzlecPSrwqwqvGelDiFa8kXvHqFq/iFetBXkmFeeV6Z15JQ3kVedXLepBXkvmKVxKveNWjV4f7IQfPo5d4VYhX5iupb68qXlkPSrziFa8kXk3kVdV69eEP6HL9oPNXEq94JfGKV/2uB3kl8apPr66+n0x9vB7HfCUV5NVyXsNh73/FK4lXE3jVaf8VryReTeLV4XrnbvvbPX9QjjVejetV9/1XvJJjjVfjrgcTryReleRVxSuJV7ySeMWrnrxKyf2QpRG8irzqwSv3b5eG9Sr88eoAFq8ueHXF8yaC+zNIvCpkvuKVVJpX673/VbCfQeJVEV55XqpkPViQV9l8VV/0yvWDEq8G8uri+fZbvTJfSbyaYL6qsv0MlflK4tXc14O1+UriFa8kXvGKVxKv1upVVdnPIPGKVxKveNWbV/aLSrwqw6v0zivnryRezdSrv+7XxyupX6/c/2o4r6wHpSG8cn9RXkm8WpNX3h+UeMUriVe84pVUnlfOt0/tVeSVeMWrUb1qUjBfSbyapjjs9TjufyXxah5euV+fxKvlzFfWg9LtXnl/8PwrNYZXnuclxxqvSvHKfCXHGq94JfGKVye8qq0HpTG8cr6dVxKv1uzV9fe/4pUca9fzw6tpvLL/SjJf8Upa4nwV2vnK/ZB79SrwSup5vgrtfMWrz3nV4XmpvJLMV1PNV431oDSgVxWvepyvGvOVxCteSbx6OeJ4ZT0o8Wpd89Xb8+3u1yfxas5eBV5JvFqeV1teSV28CryaZL5yPxmJV73lfsgSr5boVe35OBKvClkP7rGqb3z+IK8kXk0zX/FK4lU5XjW8knjFK4lX8/Lq+RcQTmEw3a/o1vPtvJJ4xSuJV7zqzau/7s/AK4lXc/Kqyuer5HpnaZVevberkPUgr6SBD8k5vT84r/nq7fuD8WOvkv3t0nq8Kvx8e3K9s8SrcryKmVfR8+ilm47+0rwKLQCzeH4rryRelTJfRetBiVcLXQ/yShr6kORVH16l7ve/sh6UivOqmt0F1zevB7s9z4tXkvlqbK8aXkm8KsKr989LtR6UeFXKfMUriVfjvjjmK2mko59XRXhlP4NU3Hw1v/cHPR9H4tVivTJfaZ79P4cbdsKwkAoA", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0003_20261007_093233/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0003_20261007_093233/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..924e908dadb7f348ab1da14b6930574ccfa1256a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0003_20261007_093233/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0003_20261007_093233", "meta": {"split": "train"}, "act": [11, 590], "T": 591, "maps": "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", "force": "H4sIALa51moC/+XcCVRV1foA8CuaQ6biCKY4lCI5hZqGod6zP8foPYfC+em7IiqKhjIp4oCggC8nnNBywFBMI3NINDRF4aEizgPI05zIzETBFM2JP9+53cMB9tnnK+9/8f7rf9dqrej++vbZ3x7O3vueew0Gg2HlpE5swFQfyaDzml21hVUdtdyXm4Dk4m+0Z4b/Iy/pwk4jxcXsCJQobm+VeWXinD/vVyZOGu1pXedet0xcTOA+Uj8wxF02WrO+S266kpwBFpNcUnwrq5Z7bcF1Wn1HuNPao8cQUrz+rd+0ajxq+1IdNc/WLtfw/ev/rxy1/1Hbw3RoLK3diPM9td2o4406X9n2Kkdyp3MqSNZsj5DwsG5l4r6Mtmp/odaXmj9qP6BeH7W/mFbvsGq8Jj3eIblVoQtp65crYVaNZ+31C7XdqPdzar8qq/VQma0Tg41WXW9Qy6XeF6y9bqI6aj3Kqlyqo86TVEdd/5HXp1Zej5fVvoxajyHbostk//bfPr9YOx51X0Z11DxT56vJ+fZWddT9r7X3M9R41HViWe23yOsN4r62rMYbdf4zBPb8r54PQnZFk/YfMSvXlMk5g7Ud9f5LzV9ZnYNQHbW/WHvfQ3XUcwbyfos4Lq29nqSWe7rz9DJxf2Z+2ZJ/W9eaLpgk26zBjNJu6fGrGSV/Nv457K7DMaF9cnGVFFU9n7UoqAcil7trq3QnwwYWB/mBXrk/eleDt76dpev8jzeA0fdDFXfYw03irSMGHmkGz/dEquJdk3jzRt4SgJk+C2X3W4Xdxoj+P0i8cf5i+0BwtlshuxXd3aXr8G+uG/3uEHgw0ewCNy6VxtxK57rrjwZB/oIvZNfzk3jJKTqR616b/09YUnGN4j7wOMV1dQeOVhzGenDtDuM5r9OjYFPL1bK73itOmtw+j/H28cf6e8La3isVVzn0bilnikwwbtk4Bub+ukIpd8+We4w3r8X5ekHO/Gihw3E+80NveLuL2c2/eEZqNCCfGw+dJR62Bc9hP1iSORG69F6huLMNXnBdarQ3lK+0VMnzmvUG4LmO1yfC680WKden5Tone0PLDguU+naLesptj80tJ8PlVguUeCLX6SNzf97i+Kv0MuwJ13XcGAgdRocrrt3jfK4bsSwI/nl5LljGBs/hfXqY/VQ4fXie7ALO7eE6vE97u/rAKlOY4rSub0+XifBjwByhw/wl9hwP0XazSS4uOkR2U1LWSF88/q303OV4yth+yxTFYT/gOfz8KKqpPzzqYHY41hzeKO1sa3WXbApClHhXHz+Tzq15rOm8V083zxvh2dL16uW4/QXdoC9nKI4XD93jhn4w9OI0YbmYZ4rDcaR245a8zmYXvGS8/Ux2vUDovjJYcbx46IKWTyvmuPkbUUlqvCgYzo0zt6/t83Jch/V1sg+C1E9nKY43PnC+6v3ONCUelstzOL9gPEPlEGF98f6L7rlkbo+aaXZch/cjdJZ+io7XTzHPmJchbWcpjnd9WG54venw1GaOsFysL7r6v4X80ZeacOPh+C3/rxlKvCaxVfj5K9zn5S+apdRDy+H+cuTxEMWxsNpch5+nlHS8eRfPzY7PDIVuuUXXx5tP8T6dHTG7WD0MhgqazrI+wDWUVjwsV8/h53ToLOXiOOK5o/kRUsPEMGVeQ8drN5wP9rvMg4S3ihx3vm/9SeE9fq6Sv6ic3zVd2Lx5yvjQiheSZCe5VI8oVg+eM6XlGM+WDwfPtXOUvPDaDefTT/bMUxzG495/7d6TKhaE6jrMi9p1/a4ym7v6Pre+2B4P3zf3+4TkStx5o0nG2/J86vvDTMXx4mE90NWaP/1POawHz+FzPDiO6h0JVtqN57ZLjvL8l1h7pjI+uPcj5yoSxkv7qcjx1k0WN25dsNBhXrRcxq4rRnWeP9g0U3GYl87T7nDLLTdkOpxtIM5zTFeTHC/4/lRhXkKmrpXn8UjfoD/teP3ZNmOx7JaWL2oPXv/D+mZd8gfXBdOE8x/mT+204mG/t3WbCgHG6bquz74AXYfllnTcvBTef9VOqz1yb7aXsC3C4gN13eYRPsUc7/rQVes7UXFa9TBV8pFdqJ84f7ajIoo5bA+ew30o1eE6Ue144wP7FcZbdsdfdivz6nFdzNlN0kt/b0id6S+MZ4qqL42K+BR8hgYKHY4jtdPKCzosd/4lX6Fb0nGy7Bod8xfmBd2PD8eAp/sU4fUZ7s2S3e3BfrrlooNnvrrtS4mH9wV1POdeNzRdw89Gw5P8ybrjHN3Ugk8Vt5A94F6fOp5W/lwu7Zb2B5rg3j0fYbnoMJ6ew/kKnV5ecLwld/XSjYfzxk/DvGFoRXF7xFxdZSzpePd909/mGP0KvODhO/6KW+30O3deUzvMH8/h9ZV0vOsLMd7uhk49jngO79PNs8YUc1rrF3RPvw4Srk+T0tsY9+3zgBynqUKH/Qqdel7jOexXJR23HoXjDfuBpVzN+v7h1p3w03V748bqOhy/GA/3yqJ6WNyNcQFCF5LdUZrQfQpUeCNAfF94eVpqs3GeMl/hutN7a3Yp5zTakTn1W6asw/B8Y3HK6VJu3aS7rH3dWGjfLPiPtXqSVDU5sZTLqdoWah3eDeOfmeN1W5Un8c7/8JW9aTd8bPJT5iH8h+dip+6DZlVMSjzzXqX061bCAWjkNkhxfr1cuW5zRAq06dBHcY0GBHLd8LanYFEdprhyp5Zz3Y59Z2A/uCj76U6nNnOdnetVONSnheJcPksrPT5mrJfQ3d7mILsHxx9JridLt5v9wYZszK3/QJ5PTcW9f+NhKRdaYRkLtsuCwGE2ihtmj+cMkpJrPIvcefQM87S5CFl9rjKLi15YDtRno1NSGkpuw68w1xUZED78HLPkpUGjylAVis7M0e38LIMtO3kTtvufLOaOzJzDPN3N56OtWsVKoXVXs+lDf4FVWw8yy/lByjZ7qOgayvB7DrnGEIZnN551g1jBnFyYeP6A7NjYN1jWBQdo8Ggfm5QVxz50v8SaxnZhueFp0s28FzDFNVVxbx5oDdnjrzA4agM7r1WE+ktNzLCjfLfoDU/h129OKG5dXjv5eqf0qF6Yq2ZQuD+R32vhdL+Ye3LaGbbkezHMz9L4VuDfbY783tzeidDGd5/873hGveBmU5jwnR8LrFoRjv77XSXe5hdDIdNlyR/5kqSezTFH/VnU1hoQGt8OBqVEMfNaaaWcA0ted3UMk+eYwv8GTQd3UpyTO2PBueVVbR8j4d6i3/SxEFQL4O8BW5llj3XYI01pTzyb35PSFvb3SYewWYfYz6a1SruUdDUyh8LP7GdoGrufBa1ezyyfE+C5rsVhnfH8ZL/XS3hnwnm26sprzHI9GNPi8P/BfdoAD5vuuFbAMxmew36E+76H3Z7DjfjaYHEJyRHF5gl0rV0CoVrrh4A5xDayxCs5Hpi/L/T/5i4sT7UVOhv/SXDC7xfY2qCO0L3YNl5xOBdruYBgL8jblA3/sq8FlnluGOeZZdMuT8g7f112lvzx3Oi8YdCv8xVdN8nPA7qNy4KNLW11nZSXKTuteVi+7VUeLLuLH1eHovYt/cJ4I380O3UfKfkKXzkI0vtl6cZDF5D8H9l9seWiZjzX5QMh+14mNE6sr+s21rkEtd9oLLy+puOGklzj4CHwRXQGnGntQHai63sW6w7fNbxIcs9iz4NtXBNhuehOJZwhuXzjadnxPj9TxkfqJ4oLztV+Frjv3X4w4fhJ2d20W6np4r7sJbtJPzUixUMnuj50Xr8c162H2onyQnXVHWjl3pY+gg/HpJFctRlpuuWia1fzGMlZyhW1h9qJ2iNpJAMvH33nvsINRhQclV3GrhFC12zXEVK5ea1Sdd2R4K4w8EAK+DZvputORB2BaZ++JXQel7qA7RqzE32ujvFSHVNLOklUrnp3xnPf1ike74cGNSTe9UXkHNWNx3f8+pYsV8tx6st1zr2SSQ7bzZrlUl3p9ni1vFBdnqSfl64PXcB+fQrJUfJscW7pTXRdVv1UUjxKfamupqcLZMw+QnIO8Sm69UBHyfP/hhubeYjkKO1rKZdSXyyX4vJvHILMzg66blZoEsmNr3GQVG6nSUm67vmM98D5uP71ocP6Uh2lXOr1Ud183yTS9e2ocJDk3B4f0HWvRTvLLj2+vtDNb9MOPKN+0HUYr7zNQas6z7f3W9XV3pto9XKDqtmTyqU4ajxsD4pbtcG67UaN5+BIqwfF9bBzhg8a0NzA3Ykk16n391Zz8clvQkg/mvO4vsdqrsVJR+jgl2BV51Ntt647fvUtOZ7e/HIthuYu7mgkO71+5XC+upwXPPeRz7i8KnHPBnp0rggX3t8Nj4ZWkl3kAP5eGZ1L9e+g6Ujzs2L77flnDZsTn7Ovu3wLmdm3mMh9NPFX5jZxK9ReeFt+P8TuAbfcho3SGWz8qnDvbT5XnXxoLdfF7UpjAR9tAIyLf5/OvMt1PY2J7FHe5xCYnstsCgyszq3K3Ot7FrODbfswGp43NkB232rsml9LrpuU8jU7e2YpPMmpBgOm3pfm7jPwXVYcw2eK+lw2nzd91Zu/R+pRO5GdcQ6CK0fNzzFrucMebiw8si/4JLwpO/uNMVwXlRPEeha0leshf04XnsZ1eDaf614D2qx/XXaZ4b/w2+NhAuvb/g5r+NlNuZ7LHZtx61u/Vip7Gb6Thf0WK7//3qXeXDc4/xzDc9nED/zl9/f+zne+trvYuYseDM/98e9/uDCu6/uiK+s+LZJZnoV2yh/AdYEba8nO8veawJEabql0/u1Rynv/8J2g6QJtpyjv9Q9zeyXXs3lfdrPVfOW9/cv4n8VE9O/KhjeIUt4bkv1UehV3qfIlySNysuLSm1Vgr+LwbFnt8HO/V3VJs0MUh88taTn87EP9+SDP4TNG73Yp6gfSDG9Np46n5cyfBzmS3IZOnRU3+d5gTbcjf7jikmJDNJ36ewcxe+ZrOvW5h+j6in0mIvi+mvp3rfo32avp8HeyKPVQO1E9pvl+rNtf8OXcy0Nxot85UTt8foPiROWqrw+fx6TU92jcCpIzlIsiuchln5NcH/dlJLf3ywWk+lLbDZ9vIsU7u4nUHqI8q52o/yU29i0qt6tJ+wy/oGg+sP2+Dsnh8yUUJ+qnDrmRpP6ndqJ4xeoh6Pdqh8/TvGo9/kpeRE5dX3weiVTfURG0+ho3WdVJCdtITtTv/4oTzRvFnKBf/aW8OCeTxhs1nsuhZFr7ZiwmOdE8pHZO69eR3BCPRaR+KpqH1O6rgu2v7Irl7/AFqzrROkLdvrnHq5DiJZ1430gqV/A7lcXGB3EeF91/1a7/8o9p7lBF2jyZlmN8VYfPsSh/OJ7SdINSapLcvZ+L1vQxM5w164HPvFHuv+rvp4rGW7HvsQrmIXyuSVnfjx6g6Q6Y6ujuA/A1bIM9aX4eNqd5UXvMTreqE41ftRPN42onWv+pnWieLHy3KC9R32g6fOZNKffdy5pO/f3jJXPPkfqB6D5Ndr3iJMo6B79L/aeddwLJieb7v+JE+zLcA1PWYep4ot+3UDvR7wuoy72WdpDkXEb+ZFUX+eIIqR74PQwD4SUeH0Uv0T6+2Hnv8wSruu1jE2n1qHOe5ER5LnZ9gvZVv5ZsO0a7vonxJEf93Sit3xH5HwhXuV9YXAAA"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0004_20261007_093303/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0004_20261007_093303/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d5802ab259c6c2a43cf0c171a001357a7a8d3207 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0004_20261007_093303/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0004_20261007_093303", "meta": {"split": "train"}, "act": [13, 520], "T": 521, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0006_20261007_093408/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0006_20261007_093408/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b1046c5702c4c54f94b6bae1bfeb36cac68f7266 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0006_20261007_093408/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0006_20261007_093408", "meta": {"split": "train"}, "act": [8, 530], "T": 531, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0009_20261007_093532/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0009_20261007_093532/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..88ae03d518e86fbb904ab09a2713ade9cb70df93 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0009_20261007_093532/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0009_20261007_093532", "meta": {"split": "train"}, "act": [12, 593], "T": 594, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0010_20261007_093600/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0010_20261007_093600/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..09049b60413a1a18959aec522cc050c506ec7033 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0010_20261007_093600/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0010_20261007_093600", "meta": {"split": "train"}, "act": [33, 633], "T": 634, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0011_20261007_093657/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0011_20261007_093657/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..ab221d25833ee9416cf5daefb0c2839669f39020 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0011_20261007_093657/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0011_20261007_093657", "meta": {"split": "train"}, "act": [10, 488], "T": 489, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0012_20261007_093722/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0012_20261007_093722/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..87bf7b26d83466e691cbbd4bbec284ba26a55ced --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0012_20261007_093722/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0012_20261007_093722", "meta": {"split": "train"}, "act": [14, 558], "T": 559, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0013_20261007_093750/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0013_20261007_093750/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..5b5e3aed95d90b84bb384ef01a18db00ba142df4 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0013_20261007_093750/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0013_20261007_093750", "meta": {"split": "train"}, "act": [13, 566], "T": 567, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0014_20261007_093824/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0014_20261007_093824/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..5ac3a7784fdcd30cbf1a5cfd2c214bafb1e783dd --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0014_20261007_093824/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0014_20261007_093824", "meta": {"split": "train"}, "act": [7, 614], "T": 615, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0015_20261007_093855/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0015_20261007_093855/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e775aab4a118a4d26aa35fbc888e049790e97ad8 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0015_20261007_093855/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0015_20261007_093855", "meta": {"split": "train"}, "act": [10, 647], "T": 648, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0018_20261007_094012/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0018_20261007_094012/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..665f4de533b49c7301c7bdadca81e952f825483a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0018_20261007_094012/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0018_20261007_094012", "meta": {"split": "train"}, "act": [11, 641], "T": 642, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0021_20261007_094156/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0021_20261007_094156/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b95df59d5c4949df0d757077c5c2f893104877f7 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0021_20261007_094156/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0021_20261007_094156", "meta": {"split": "train"}, "act": [16, 623], "T": 624, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0022_20261007_094231/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0022_20261007_094231/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b5915142d39cacd8a177906fe37f56769c682b7f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0022_20261007_094231/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0022_20261007_094231", "meta": {"split": "train"}, "act": [14, 605], "T": 606, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0023_20261007_094300/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0023_20261007_094300/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..189f9a279fd14cf0a5f175104fa2afc0ce48424c --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0023_20261007_094300/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0023_20261007_094300", "meta": {"split": "train"}, "act": [12, 545], "T": 546, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0024_20261007_094328/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0024_20261007_094328/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..6ee73d475682a6ad854b9332094592cc3553f8bc --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0024_20261007_094328/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0024_20261007_094328", "meta": {"split": "train"}, "act": [8, 489], "T": 490, "maps": "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", "force": "H4sIAL+51moC/9WceXhN1xbA7zM1lYZoa5aK9lWqUSVKY0rO3onQ0BifEimRVFBSc0lNNxFCW4Qa09KoqRElngr1SFViDgmKIEg8NZWKoRTFu+vk7ZN97j17nS3u+9575x9fv/vrWnutvfbaa0+xWP4z39ikdOJM7v/m8wxu89/ghlUPUGQ4ZcpMfxkuramr4kxu+4qzUnpzZ3lJyfMsDJTiLDd2CvUe33Ba+819RnPFmZxS/20prqD1FCkud1s3p3L/63q393gs7LfhWXU0GcMe9VFkuLS8cVJ6rXSclDws/nh52PjgOcvp4VJ63cPqS8nrPGKaFBfu9ZWcHXmSfglbJ8Vh8cJzmF9so0x50rjquSPZqZyL+wYpOxr3XCbFYXbo/HynixSXmBEtNz6QONX1G5KfZfOkrn9j7vs/bfzxXGJ6Tadylk1ZUu2TtiO0mZRf9vT+TIrruSreqdywrHC5eJG0wxLQTnGm/7D+0MU90r+yeVw27kvDhdeLdGr7sHqjNHpl85A0N3PRU3N8vs/NbCTFYXmD5xKjt0pxsvOC7HwZ3m2TlN5pAzOkOJezu6T0uuR+LpcPkPqK15scN1SKw/IBrxeTVxpO1t5LQducytW4KZd3ZfM9xunqccl5Guvf0nDS8z6yLuPjRbbOcV8yR64ekhznsv2GjUvdvNWllhz38zopv4TXfSQ1X8pyyUv2Ss2rBS0rOXVekO03jCvNOrRG7Fq5PF5xkxTnO2ChU+dB2fFraWdxal1n/bSFHNdslVS8YPs+pVnH4/tccvMMz2Hrfdn1pSPnSUzbZ1vXzr4WQzoffZ2Y1S9JV5cTt5BMlMsd3luJzkoltTIaUpSzxdWA1zLIhH3dqVn+W7lhH6mcH0nN6qZZWbnk+5eHmOr9qccFMu+naGrml02tr5Izk4ZSNI+vDlK5zt8NQTnYB5lBbhpyOr22+WjBiIumXPiUg/6gN+LmhygHdXu/hN+kOPBLYl6x3lrj+fld0eVd0Mu4lhE5hhzsm4Ed2zcNUrmzdx9ov40rmlzCtZzjH7T3sjmX+IZqR9Oo/io3ILEiMeISN0cquybcNuTadlujiwPoj5Zdwx388mXKMYXPzyCvx+5+KPeH9VuVOzWpL8oVuS5V7TDjoJ4UcbwdkDfAjvKvReLyGrymhNa4Sw4tGeDANQ46p1tXHK790DD+Zl+7p7MXuF/ufIRyYG+Tu3dIQdhwtH3QH4fSH5DBZ0ai7YP1dM8cC434FOfAXhkO5gXgdmcPRdsH5xUbmpU15SDuRfK6jLmu2+eS4aDuHPvOPcJzrP95vbC/Bv7j7TXioH4BO9yDx2ict/cyBw70AtfQ92PUXqgnqw16ht5+GIPGKdjr8kFFGr9zgikH8i53sKIczL8gr0HTOAeOzwcwnwP39eR4VF749VQlddtz1HtxHMrBuha42T6xpvJeeuyus5f52Z6D9o0/Ow6ft7yincCVfMmLOypbc56lvwVYUc731VjV3g7ecaXm+HlwbKPbyqGKlei17bEoB+s34AaQyaXm7M97oD+86yWgXK77XGV2n+fplb1TqVldDBwvj/VraTilcl//2OGVDbkdEcEl+f7ian+wN2sSzlkSLvrtSXelJ+ZOceDmBXR34M6/hXPK9BP+MvLSEsopYAffHyxf6NbdrRb4RW8y71/ghtZxp7NWO3K8Xqvi4x/5ipspB/ECHG8Ha58998c1N51fFp4uT+w5qE+hfX3z4x043f6abf1rz5V5bCEOdZNtPrLnzoe4OciDdTLEFc8Z7uvZ9MpwEAdjjr1Af3k7wZR7PamKKQftqx5VlQZfwscRxAvodfsE5yw/tFFA77ImU9HxC/WQDLen9yqVKyC4X2C9KuKM9Pb6DJcHdZ0MZ6kapXKR1/F8Cut9GW5h43Q1no043o6im/2U46P086DR+IV1D3AdjlvxcW5bR8lwje82VO5dqmiqF84NYPzeL1Myf7B6yV6vDAf17uiPn6UnIq0OnG4/0VY3Qf6bts7qMM51+5NzEtT++LJrHJoP4JzEnmP5wP7+xuUFL+r8wn7n5UG/RbzvposrIy63u4fS6m8VTTkYb76ZLqZ6LdY0pdrPer8Y9dvAC1lynNuBJ+J2fTSR2tf1Oj/v+9aBM8qn4D8YH3y8GHEQ9yAv/eVY1C8FYZNU7sxonIN6w2NDBXo1fgLev7Z1FHD3U2NwO2zc+bfK0+uVY0zjCrjBiz5x4OzrEhHH19lwDiHSy9e7e348qxwJfsaQm+TqRfjxBv4jv4534JbvX6LjoN414u7F79a4sUOSHOSl3Lmk2HMQLyCPz1dMnm+lBIf28XmDcT6etR24P+s6ciwvqPEcaHXgWP/q9kHyFqn+M+L4OgLWR8Dd3z0JrTfc0wIU18yWNGTPdGq/ntDNg7e/IPOntqEXB32OcgPHVKSzlB503DdfUPu5iI+/mbsCaV70cLqi3Qxqv/b1W3hDY/eMiqH0wQg6/fYUHRcUlUzcT75HYE9S9WXC5/Rhg8k08LB+v2TMw3eoR7NUcmXlCJULO1ibtB88nRZe9tNxidFJFO6AjvksrLivwxqSiXXjaFSnmjputO8GeuriWtL21RByq9xGf5eBHQl5fxxNrvQcLW5/8TngqbBTNG5NE5r8dS+ytHkL0n1aRzJ5WR+a/sw9Vf622pVVLiuqQOUGn4om8P+2HxJCLG/3cOBC/3mS1qE+9M1OAwjkub++2Zn4dNxqY4apHLQF/s3stIUeGNmdjh3RlcC467zqsPL7/lDSv2Otf/e9VeVm9l9HRy8eRK96vKPqDWzUjY4rKkvY/hvLufdORdGN5RfT+dHNCfTh6xWqUqYLvkK6s9jeflWoh8c2mhDdV9UzsJCQj5eXnJUweevvnCKx+Xtp6NIahNVhfHwzbvrBrWSk3xHquexZwup7Po8wLqHzNlIh9qTN99vV/7ZWC9VxbC7v23cjyaNn6KMVOwz3F9kX13oVWdumgO6I2KcYrR/YNzI2iXzle86mt+CJOaMvODtZ1Tsv4Gcdx3LUk3JFH6Qa6rXnfB4vIclNz1N+z8l+jwy+KucWqRzECGZHRvhclauyr3ox1z3Vz4gDeVsmQvvci7n2ZYUc2KHJQ/TKcCAP/MfiCuNAnraXuX1payMue80iYu1yTpPnmfOp4TnTuykryeD4AlMO8llKfr4pRyZ/QSAnMHsLxmcacn4vrSANr+RREvUcYes3/nc2fr2bJpOz607Sq3M9DP3COLADuIzwF4vHZfw5PxFXOzG/RJ6d/2zjVtOb0i9fkyf6IJ7rzBe3j+cwO9j3Qs56siwlnzbP8UK56reL5dlGhCkH8gqD6hdzrgMN47lqr7/r/Cc6N612ZjOpV+4E1WqEH3P8RVwn1+PauBTdh4O8BhyTJ4oryC/AMf9hXPLzRzVO2f2qIfd481qVY/aKuIZ+G3ScfVzZc8xekbx/HD1AXqx1ROtfkbzIGyd1nKjfcmteJn/uPUzXnPMxHEfsu3K8DN1W4RB1pe+h43JNn0fkcG4udQsJIGwf3Yhrs7ocXVPvEO1/oaPKNW4xT8h92CBH0yu6jwRcSJMDmt497RIMOeXWfQIc1C/YOXb5Y7dIrxb7NQ7O6UTcDLJX40T3oFoFFqnyoL4y0wvyNK5qFNo+M65en79QGc5eXprXNDm9AjvA3vikvfSHS21ReVsfFurkifwMcQryNL2C+6zBaZfU/mVcwdFKT92/PGf95oqh3sJKxX424yD+oH9TlvcnbH1uxMGZ65vf7aTTj3VB46rMqGvkYHqmphfW5xg3sl0flHOhf5DrnbZrnMgv0D7gttQdgcqrVr8s7ZqVockT2REw/4Eqj9kL+yqieJ7eO4PGpPiZypsTuMOUC9x9lQxrkaXFqei+GXAgb59PO8LWoSI/T220S+sPjAO9TJ6ofeBn0MvWH3A+bcQd+eouyTu4U+NgXwDjNrUegvYb5HtoX0r9wag8t68fqfay/hXlAybvlls3NO6ZvA/LdUDjD+wALnyKL2HnuiJ5oLeQtkU5GOfg5xm/B6B6QR5wrH0iO5i9L0x9F5W32f+xKo+1T3TPHOwF7lGzUHQeBA7GEeNE71M+Wl2k+uXIsQjCzhExP6+/07vYf4L3R8zPjBPdUwV5fByI7qUxP7N4Ft3XY34x4+oPv0UO5GVqnOjeZtv3f1HlsfaJ7m3CfbF+A7bS7KrFeVz0vid75Dzy/b10OsovFuVu7h9NZqWuowcvjEXtWHuyD3nUZhltGdEJ5YKiPGnUxsWUrVtEXN0tNem7Xos0DhsfR7vMp9o9JkFddzYmjtRuP4UGZ3ui8XfrwBtk5OOB2jpZxPW/kK2snzuVaOs8QX6BfTXw3c39vytm3JmJEwnIVf0yrLKQAx8XBq1UsPmccdqekDVNKM/b+xVNHnavHvbVGOfu0UPIwf5oSv1fFXZei3Hzpp5H+02/j4q/dwGOxR/2/g04sEXGDhYv2DuC+TeqSXHgZ21/F7n3ynPY/VOmE8t/9px16QlFvKdTsieQfHCfkOPv+ME5oow87F4zz8H5hwyHvY/i24fFAc9h7w14DnsHxN+lLEiaIGWvqG6yl4fdg5fVC3Eqw/HysHcE/F1U7H0yzy08/aUUh+UDnsPyC8/BuZ8Mh93T5zls/JZGr6w87P0Cz8F9THF+KXmTjMU9fycZ49gZjJn/eL3O4Hi92PsonhPNg/Z6sX7juZ7Rs6Q4LO/yHNa/OnmKj78Mh73vKQ2HxQHPwbm4DIflNZ7D8hDPYfWBrn+7e0hxcF/qafXy4xKbj3T5FHlPx+u1dPhNyi/Y+y1+fMj2m2ycYvbyemU5zM86DqkjeA4bR7Ly+PN9Wb2y8vDPuZwuT7Za4OdMDhu/PCf7jhSrw2T/3oj9/VMRJ/v3aXh5GOfsd7OleX8p+3c/sDqRtxebL2X+Lsm/AEvZ3a2QTAAA"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0025_20261007_094355/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0025_20261007_094355/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..34855ecc8b931d2362c32c804b29f542eb13e84f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0025_20261007_094355/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0025_20261007_094355", "meta": {"split": "train"}, "act": [12, 542], "T": 543, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0026_20261007_094425/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0026_20261007_094425/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..f2a1e7c0da7806ce82fe423c83089c2a4ed7637d --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0026_20261007_094425/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0026_20261007_094425", "meta": {"split": "train"}, "act": [9, 611], "T": 612, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0027_20261007_094455/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0027_20261007_094455/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..42f272ac23bf3cbfaf8f03410a998061f95ae381 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0027_20261007_094455/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0027_20261007_094455", "meta": {"split": "train"}, "act": [11, 566], "T": 567, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0028_20261007_094527/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0028_20261007_094527/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..8fe2a3f2d43f678bc2f9e33aefb24b8040c32c3f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0028_20261007_094527/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0028_20261007_094527", "meta": {"split": "train"}, "act": [14, 676], "T": 677, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0029_20261007_094601/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0029_20261007_094601/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d86631766ff1f05ae26c1c6471f7334fa4dd6134 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0029_20261007_094601/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0029_20261007_094601", "meta": {"split": "train"}, "act": [11, 622], "T": 623, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0030_20261007_094633/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0030_20261007_094633/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d4e62139944cd7a105c6161223ce02d609bce465 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0030_20261007_094633/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0030_20261007_094633", "meta": {"split": "train"}, "act": [10, 555], "T": 556, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0031_20261007_094659/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0031_20261007_094659/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..cc52b48478382b83327c46cf879ba907a62471b9 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0031_20261007_094659/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0031_20261007_094659", "meta": {"split": "train"}, "act": [15, 528], "T": 529, "maps": "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", "force": "H4sIAMW51moC/9Xce3wNVx4A8CAlWm9KWo9E2414VbpEkkZy5/yIZ0nSxopHyIMKZSVI4hEVEUmIkHcE5QoqKppoy9JVbr3Wkk+jodpsK+LVWmU3XisVdO9v7J07E+ecOat3Pz47//j45Pv5nZnz+M05Z2aunZ2d3cHhDWFSjzRip3MM8mos7L4dmKzrXKJvkxMkRdfhUXbFRXam0hYGnksevUyy+3841pb42tKFRngJXbfz2nibOmm8nVh9H/AVclHxjjZ1prQKgy2dW+lLNq0XU7scoXITGniI1XPXdCHnWb3Mpk70/ESd5HTUYMt6FnWi53fKw116Fv1Fcl1teCYuNs22TrB9RdsjNLqZkKv5brSYO+Ep1l/yL4nllzc+Mthy/LpV+YnVy8OIZ5IPRN2pLoE2HUeizrnovk3zvYmMtml+MS3aLdZPr2YJuerwmTZtD1Fnsu8jVM+tfplp0/oTda02ZtnUidZzRgcnsXqePldsnEeuF3JFfvslW7aH6LxT1IkeCT8eFsunSXfF5s/ndxrsnsFh6/Fm67xbUzVJrD975Aq52jvHbNpPRcsVdTbPL6J5LXGJTe/7ousj0etwPvaJ2PioNfrYdJ79jPq9qLP5ukd0HSA4z7a7N8O283Ybr7dE72+i90vh/YgCo9B9QXReZ9dyqq8tx5HoOHeLaSl90bGlrg29U2MIjJtFRPpL6dyvdB2uexaNtAcRt7+8KYi02+n19zTlxpz+k0Trp+s32sH1nM6KDYz7J9VdaNEAHjjZKWXHbmlDaOv4+zt+IXcr4hX3aOsharyxafYQNvIDxbkNvijRxvmOkucgaeN2xTX81Y7QnG9sY2hSuEVxrkGE0PIQHG8Iph/TFefyZiCh9Ze7Y5tA2b5MxZ15NYzQ7jP+37SE4gN5XIfrxvhmr0GbeVtVbRdFaONyzbC+cD91G9fh/O/1r70gNm871+H9su1WLwh5Th0vgFp/x4q8YfOxQpVzI7T12yh3CZLj16ucM6Gtu9H16JvHdbgOuNWUwNa6LJVr9YQzZXQzeOYaNO1Bc7gP4ricgMO4VbouarqhnrOjuhtzCOzITle5aolWLwOMfrBtYwa339PclB/LJNr+i+N9gHj/HMXdOnn3ybyUUuMbEegNLUKyFUfebUZt3/ZVnuDpm8N1mDfK5/WH8a65XIf5vmRrf0255+/VUfPmR89LkLIki+uwXHQ5r+SANf9WU/PGzQyQ3aHw4RIvX6Fz9c9WXOaNX6j1fOAnH2gTlAW5A4OYDvszuh/6ZHOd9HGa1GC1lyae8+amhLauRaeOt93lZ4mWTyOXecC55AyIPtKJGQ/rL/KMN2z4u7U/U88PnTne5c+s/Y9WLuYhzC9dszO5Dp9DoFP3g+Vnv6bGK6jy0DjX/M8l2v1to4enrsN2S1rpDgHxfIfjzc/wpq47dfltaVXPAZp+6vdOMTUe5lM9h/s0Aabf6zocl40/8dC4C4M/ZNbfgR2ZXIfzl/DKAULxPj5HNPVyqUMe1RkNBnAclct1eKALvsV3uE8o4qqHbJIe7BwAu7vk6bp+PQxw4dAa/nWY16sYb1g+30XGZcrXq+c8vVfI7i9RBXxnXkeJONxvOmnvrVsuzp9ro3zgks86rsNxhE5dL7T+hwfWi57DvNHdiUDdnTzdcfkg21fOz7x4uE5Bp76/scYH3gf1xhHO6+avkOCzuhz++DDP6zAe5l2ew3KPvzhA12E8zAdqRx0f5nUAxgubksl3K6cYMJ6ew30kzBu68WqNPiIO51fosoZl6Y5foXilLYTi4dHtlLu+M89zZoX21XU4X6vvFtYspbYv5me9eNivjg91141XGhMqO3W90FzAjCI5j6sdrT+jw/uWul/RXGpFgZzX1OdHc/j+gajDcvUcjnMhd36n3A9GtNYZ5+Z8VX8cUe+rb3wkO714uH+A403P4f0c1z1qF31kPTUeOr9avsP9CHSz/parm3dFHM5j0anzGiueUL2YxxE6n59VeQ2OUuNhf9Z1/8lXPZfmc53p8GnDpv7m8yvI4zrsB+j04llcdEUO12F+fu7LfhpHm59a4jkezOW79oMkEYd5A137XXl8Z57f4/ptys0c3Xh5+30g+iy//jD/Ybx3ivj1h/0Zz0/P4Xgb9m4/zXVQ45n71Qu9PPSd+cBySy7n8t3wl6X6jpVfcHyo91+oeS3ghkFk/CZUxv5XTt2vqOWGXnzi/GjzJuPg7nL76l6HOd5DJ0mTN6j5ynyg07sOfJ5X39HuW7Ryac4ts4H0QxtfzXXQHO5f1c9/gXGzJNr+UP38F7slizqOsP704pma9TAYpurne5PTRNmp8x/LyeOyIke3PdA1fzVLKJ5euTivm5ypdaz+gtehN9/AcYTx1I56fzPHS3T20nfmeaKIw+fOuI+00VffPYjvp+vk9YyAs8RrE7Oa3x7meTGe39tLM4TcmL7Zug7zs56z7JupHW0c4XEwEDT9mdbv5Vv1qUG64xcPry5DhByWK+KwX6nndSz38LxB46j1UmD0RafXr2j1wnLfe/bTL9d8jOz2Ejxoms7v969tkdpPawKhXVeA+rnLbfvdmudUQR2NxCHxOvkpPE6zvqzvfPe9CO99f4OE+81Q7T8bpS86rtaU7bJJkt3NfpEqZ5Lq7wEmHpkLLq+dJ0bvEMX17LlZWrf9rMblRSUBnp/Pz1qXO/CMxnWekwgeF+8Qhw0TNO2776qfZo/y/ZIEqNjbEhbsHl/PHSXq+/+ttalwYowXrIkeo3Fh/q8Dzoscp5YrFvc7Czv7alzQt+/Dy2ldIONQQ6Kex94+6KRxL7VNhh3LA+DIlb5K+bviXwPvLo/U5y1FtE2CrcF50GtGAbHcdxo4XSLHE4s1zsFhFpQ7r4fmWz4gln3rhr8mPLFPm3YxDBYbHzt8frZ+wBS41KH9E+7wkXDoEFogl4ttc6eHG9DGdZPW06BiwVq4751JMK/neP1MaPfHaSOmwIRr6XDmhVSC++sZaSGE1n+f32w+vzXJ0PGfC2RX6/IioY3XJj3DoWhDEgSfXkJwf925Zii1XOwXNY0S4WB+guyKfyGEdh0du4fAHY8E2WF56GjrcXR/H5EAbT+Il12QXV9mvM/SEqBbh2jZ1f6jO9MlHV2kuIS/elKf1e7613jIarQQrm1YIruhHX9Hdf9wGAMbXeOhf1SifL1l/VtQXUSfYGjqNR/2FqbK7gdTO6Y7dHc+XKlbxXXdpwfBoEMLZIftFt/Fg+rm+L4DvQcs+q8clttsrDfV3Y/xh+Eu7yvXETlnONPlb1oAJ3NXyPVXWjmK6aZuWAitvZbLbu+CcKaL/3UeTNyS+DhezliqK20bAK+/FwdZLZNk57ZrMtU5bbM6+XnRgtFU1/xaoOzwOtDx4t15OF+5ju+qQ+nvAKwdCZ+/sBAat14mu+prcVxnicdyJOAt6LNznjI+ar+cSu33z58fBr3PmftVymL5OtbsjyTffhry5PhwHQZnF1nLfVwv0hOu++qhUBI4X1Mu7fwexQ6X+wE6vJ+wHMabuDNOrhd0E44tobpGhwOhNm4ppD3Kl69j0OaVVJd6PRgqOi9T3FsXU6muWZNxEFyzAroF7Cbbt0whLOe0JgTu/TkDpkwoI7ebv0MGrcigupm/TtQ4Vrz9saGyO5V/mjQfNZDpgnpPggatVysuuN8cqqu+Gg6Xaq0O25fmAo6FgDFklXx+8QV9CavfR8SHQPbNdDDafyU71jjPzw+HBmHpYIg5TJbefpXpPv7bRMW1etCAXPfrRXV1DpPheM0qxZVmuzPLPXNvpeJqYu9KLFfUaCVkTztBcpMvS5F1jZjxer2YpjhevIazlgs7rD90nuNfYdaLfVqq4hw92gu56n91orpXncfLztD3G9nVFl+gnl/0J2M158eKh05drut9B66rOHNC/nvqxUvMcm+NWCbHw/8HL6G/V4rXcbIiRYn3XewpZryK3sng3qlCdvOmb2DGa1yeIo8PvXhqt6Yz/X3bPRlB8vVaHO7f01zTO6OFXfFt63UktKtiuo5DrfUneZiorpnn27D/ZWs8nrNbt1Sp56ELvqG6SV8FyPEsbq/vAa47LZXLLiN6tcS6n7fbmWI9v5K1zHj+3ySD66zKx+1hLKe6ysX+crme0neP+0HTvcx42P8srqh8BdO9ZV4TWdzVGYVMh+1hcaX3i5nuysIkpV6MdjFUF+w4UnYPt5+VHev7o4/3jZDPb3GnKm686hnDwdQyUbdcjLfjw6W6DuOhC1t3TNfh+en1A3TYHpZ4LPdh4WDZ6cVDt+2S9XpZ303Yh/tpnFRVZ2C50raJMKjrGe47nQkzB8rOf3PZYzfhJvU97m0pg6AyIkFxCQcCDSy3o89ipV5Y8bDcc57x8MeZ+7nnh3syXj/FwR8i9nDdldL+ENU7Rnb19ybqu/z02bCurPTxOpBxfldG9IfJQdEwLfwgSQn4gus8j86RXaVDJdO5PXSH8SWz4c0h+wi+HyclnjPw3JhVJnl+4LyL/v5zYvEbUBYSBdebf0LwvUHjy5FUF7qnN7i/O0t2oyd3ITVfxlJdm2HNwPmVKMVJbWcz3XtVcxRXvTSE6gI82sGkdjGQduRTYt+6N6kZ2JoZz+LGbXIkxq3FBpbzr4qFow1LCb5XJn1fSXX4jjDO29H93rkj0/Uf4aA4jGdqE0F1t7bUEhKyEGa8tJ2MW/I7pvvjRzXk+xuLYP2QItL6RAci/Zm+t5nx8GtynMRB3ZppZGrG88w+ve3zB+QPfwmDip4eZHiZM9OV7zlPhk0A6HXwA4n2frDlKJuTS1ZXdYJCgwOZ67uE6e6eHEeab3xEvCRi7n9sh3tR2w6ly39Hh88rqPOSI52kUQ99FMf6rhKd+n1B1ncY6NT7LazvbL799JxB/R4Mz+XdtM5djVuHUB3mlAuDXYj6eTyrbnKTrWtt3ndUn785V3FRPkOZ7tqHs63nV5jAdKfPWvcgUhelMh2u79TPt1gO12Pq58m/1WnK5Xy3rXb4vFHkennf5asd77t3dT3z4qkdPv8VcRnvDxRr3y+2Mc/PtNi6T8v7bnHgPOuaHN8XZbnFL3Qj6ufEv9FJ4ztmWuvltifTqa8D38v4jU57fpzvQ0Ud3jfwXzlPctpX1FnK7VyTSnj96vKo5rJbZVxJeN8zWeJNvJlGeN8dW9zlpDwSWtmH6Sz5T3acfGD5nmV6WBHh5SGct8h9r7CEHJdKJN49RPnP5jAhx8sH6nzPGx+a9yM57aZ2+Lyb5fYcTlEc7/cFLO/P6/U/y/vzjwcVux/gvUtgvD2V49Wf2vF+30J9Hbz7gtrxylXXMy9fqd//5/V7eR9RoD3U9yPe95Lq+RevH2i+9+J8P3h29htCebxsn/X7J7eM7UyXld9Vca1CBkgi5fLGh9rxxqVmfIReZNaL5vkdJ9+rXdROV6FyeflF4zi/w6Iu92reYSFX+55RyDl3Wivk8LsdkfzCm1+JOs0h+HsKor8rhO+5iTjh3y/hjA/NwekHmnV4TEubOl6++l8eor93Y9cu2qbtK/o9tvDvbwj2P17eeBrHyy+aeubktadxvPXg0/Q/3jxM026c/PI0TvR7dlbe/Te5Ffp4qFIAAA=="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0032_20261007_094730/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0032_20261007_094730/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..5bb3eb30c0b6f71a529d764e2fca88962199615b --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0032_20261007_094730/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0032_20261007_094730", "meta": {"split": "train"}, "act": [11, 603], "T": 604, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0033_20261007_094758/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0033_20261007_094758/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..6f7af582cc042310511cf7c5dacf907120df5f6c --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0033_20261007_094758/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0033_20261007_094758", "meta": {"split": "train"}, "act": [14, 613], "T": 614, "maps": "H4sIAMa51moC/+3diXYbR5qg0YhIgPsiyVoslxfJZbmr+8xUzcz7P9xwAzJAAmQuyP1+c6aLkilSBImrPxKZkSFMqfj0/8u3U7r73/j4e0WKd6X08F8ff/Hw+w/vs+n+t+/eY/X032JYQN1/kTH2+dmkmXlVZF7tPIV41cWH4JVUzautS9W8SrwyX0k9POeevIqvevXwi2LFK15JQ3u1+/bL9eDjL+LqwPGrxT52M/1s0pS8yv51f6Tryav1nV9PbxeZaUv3Knb74SWpNSipfDN28OEl6WjxStIsvUr5i7Bvv/vmPVYeZklHnIH2ApQOeRXre2XYktTH8LXRp41XksQrSWrtVeCVJF5JUlWvQn2voiPukkbtVflevJLUl1eBV5Km6FWD0+FxJaknr+Lz+Sp5fCSN16vQZr6SpL68er5xD68kTcUr60FJo/Zq3y8kaczzleNXkibklfWgpNnOVwYySbySpCOsB91/UNIgXqU2x6+8pihpMl55PCVNxCtJ4pUkHWs/ZEnq16vAK0m8kiReSeIVryRNxKvAK0mz88r9cSTxSpKqeGV/Bkkj9io28iran0HSRLwyXkmaolfAksQrSXK+qKRZeGU/GUm8kiReSeKVJM3Dq5VHUlKfXsXg/jiSpuNVw/ulroAlqVevmt9vwnpQ0lS8Kv+MJE3CK4+npN68irySNAmvgusHJc3WK6JJGsSrEBrfH8frg5J4JUm8ksQrSerFq22x1sF2XkkatVfmK0nDeuX6QUm8kqRjefW4BOSVpAnMV9H+opImMl9ZD0qa0HqwznzlfAZJo/EqPmdJkibvlflK0qjXg5JkvpIk85Wk+XgVeCVpuuvBKlkPShp2vqp9JoNTHyQNMV8F60FJ01kP8krSOL2KuVexmle7C0D3o5c0wHwVm9x/EFeShvKq9nzl8ZTUv1eNjl9ZD0rilSR5fVDSJL16gopXksY/X4XG1+NIEq8kqbpXVdQqPJKS+vQqhL3H2+3XJ2mUXgX3H5Q0lflqew5VDOnt+3lFXknilSRV9Cq+7VXwCqIkXknS21453i5pCl7Vv9+E41eSBvMqtPHK6lDSeOcrSeKVJPFK0hy8CrySNA2vovlK0iS9CvYXlTQJr6L9kCXxSpLaexVq399ZkgbxKgTH2yXN3ivXD0oazqvAK0nj9irU8CryStJEvDJfSRqFV45fSeKVJLX2qsX96N3fWdJUvDJfSRrQq8ArSbySJOtBSQvxKoTG9/Pa/hFJGul8xStJ4/Cq5v3oeSVpKl7Zf0ZSn16Fel4FXkmapFeS1JNX4blX5iVJI/bKfsiSJudV4JWk8XrV4vz23bWkJI1vPQgoScPMV5FXkpbhFbwk9eZV4JWk6XjlfFFJk5yvqntl/ytJY18PvrI6lKQOvYrOb5c0Ha9aXT9oAyxJk/HK4ympH6920LEelDQVr4L9RSWN2yv7IUuaglcNz78qlXK8XdJ45yteSRpuvoqlV6n2/SZ4JanH+YpXkpbglSTxSpJev98EryRNwSvX40gasVcv9mdwP3pJU/Gq7n7IvJLU13owuh5H0kLWg5I01HxlXpI0Ca+s7yRNaD3IK0lT8crxK0ljXQ8GXknilSTxStISvXp2/Cq6HkfSbL2SpMnMVwYsSb15Fc1XkiY4X9U53r7ySErq06vHX3t9UNIEvXI4StI0vIq8kjRar54fb7d/u6TxetVqP2ReSerLq9hsP+RY/nlgSRq1V4FXkoZYD4ZWXnk8JfXkVeCVpGV45Xi7pD69cj2OpJl7JUm9ehXDs/t5ma8k8UqSjrserH78vPBIShrUqy6PX8UDbw9R3POmk1+l6XgVOvaqiIf/Ev1iVV7XHUukYpFiZtfrtkmakle1j1/FVfbBUwrD4ZVKr1JpVMr+fikmXklj9CqW68HUrVdxv1d9HwrLvCpyr+J+rxKvpNHNV/WOt6/qPoHv1lvlpy6KAVeERdxnWLF6e/xzepo0nFcx9udVyL26A+stQXr2KuVeFbySlu1Vyr1KO16teoUgxX2YFtuvKKbNeyReSWP1KnZ6vuiuV2k4r/asVe+9Kp7eTLySxupVaHW/1BoD1iteDXQ0a3tYPd4fXCs2j0LpVeSVNCOvas1XkVeSmnq1MyN1vZ/MiL1K916lp0chlQ9DykdIt2esP3OflL+Zv8KSwK+GXvW2/1XaOWk87lI28DMs3h+02s5X25Pe47OnH69qerU+4JUHUk28ij1eP5h79exSvTisV/dLwI1RuVeP/+t8UWkUXrU4n2FOXt3/BTZGpXy+ev5345XEq3F5lXZ3anCsReLVgb/UILu6lAfQUkrZ9YW8kkbj1eOvR+BVMahXKT33asjtI7qoyCbJlD3Ucdh/JqRa89VyvdrZPfDhSRxLrwbdPqKLVvu8iryS9eA0vdr87R68inPyKuZexUNeSbyqv14Z3Ks4t/kq5l9Dyrb2iolX4tUkvAovvEqbKfDuzTkdb8+9iplXMfFKS/Fq1e4Tj86rmHtVntswg/OvnnsVeaVpehV4tderlHgljXK+6mn/9lfv5zVir/KdBSfvVX6DjcAr8WqC81WatVfJfKWZetXlevC1+Wo1uFfb4+1p7q8Pmq80Za/6On7Fq+G9Ml9pyl6Fel5FXk19PRgPeIUujd6r0NarOj/kvBrzfMUrjd+rPu/nxSteSUfzKnQ8X73y7ODV0F5J05uvUl2vYr3njflqaK8ir8Srul7t/LmYRuRV5JU0Sq9ifa9i8/XgaL2KvJJm6dWzFZz5ildST16FRl5F60FeSX17Fdp6FeZ3vH2hXqFL0/Oq9vlXvOKV1NN6sPX+orPwKi7mepxkPagle9WQtjHOV3O9P041r9ClOXrV/MeaV+YraUiv5nH+Fa+kea8H57efDK+k0XsVBvHKfDWIV86/0py86vJee+Yr85XEK1699Q1PvNL0vXr8dS2vvD7IK2mY+SqM5P6DvOrHK8evxCteTcmrwCtN2KtW+8nwavRF85WW6VXr5w2vhvfKfKXlrQd5ZT0o9euV6515JU3Hq2g9aD0oTWa+arMenNP9UufsVdjvlWeDFuLV/O7vvBCvAq802p/Q2PF8NaP9kBd3PsPOsGVpqPl7Naf9kBd3/IpXmpRXMb/eOS73ePuDUuYradxeJV7xilea0HyVrQfjQs8XtR7c8SrGRut9qYtRIuzakJ1/FZ1/Zb7ilUbt1fDz1dD7IUde8Urj9+rx143Obz/m+Ve8GoFX1QZhacAf5djIq2Z32XT8amReJV5pej/LzfZDntP57eHhxDP7X0lz9Co2+ud3vOvBe6N4JU1gPRha3G8izsmr8u3d10l5JQ1Wevaz3OZ+9NF8NROvHL/SBLwKz+ar2l4d5Xg7r/r0Kuz1qtI3ShrPejDUPn7Fq4l4FfZ79fY3h1eaulfx2Ocz8IpXUhdevbI6nJRXO3+buEyvKhyz4pV4Zb7ildTEqyLbr69Y5vWDC/XK2QyanlcjuP8gr3glHd2rju5H7/hV11/p/v2viKWx/svKqyXPV2965fiVeMWrEXoV93pl/yvxynpwgl5JvOKV+UriFa9qeRX2elXpGyUN7VWd/WTi8/36puvVbvP1KmZexWi+0vhKL3k5znwV5jNfLcUr85WWPF/xap5ema80ifmqzvWDse3zhlej9cp8pSnMV8n+DPFh67rMq7gUr96cqXilsXlV60cz8mrCPwX5ngw7p1zxStPwamc9WHM/5OZexbGtB+frlTQPr2LuVWywf3uLiWZsXs33+JU07uqdz7D1KtS+nxevJPXpVajn1XGmPa8PSqo5C+Xni8Ym+/XNYz9kXkkz9CrO16vkeLs0Aa/q3Y/eelAa/gk9pxNNGh1vr3h/5znOV/M+/0q84tWc5iteiVcT8yrGsFivEq/EK17xSuJVV+vBgY63R15JS/Wq8tfUYL++I53fPiqvXD8oXk1lvhrB/Z15JS0268GafzOvD0oT9Gqg4+28knj1JkDP9xetvWdfDVx4JQ3aSG+KW3O+qktPXJZXyc+5eDU+r6qDNTOvXj/eziuNxZtGT7zZeBVzr2p8LbHtg80rqTYz0fkM5ZcenS/KK/Fq7OvBNvv1zcQrx680IbfMV43nK8fbJV716VUovaqmjvMZJF717dXBO2a+rs7OLYLn4JXrcTRupyKvjuGV+UriVZ9e1dz/Kh4aUM1XEq869KrJ+e1z3U9m534TjrdrXGDxqvSq1vWDvJJ4NQKvYh2vVrUfNOe3S7xq4VXgFa80HbdaehXjVL6848xX2X9ezWg9+PL1wcgr8Wrc68HAq31eeX1Qs/IqhpYfZhReRfuLmq9kvhqvV033k2nms/lKGta7ac9Xh84X7eaLmKhXsRjZd1dq7lVmQtcLKl5194iZr7SU9aD5ilfSpLyKL+3i1RzWg7wSr0bqlfs780rz9W4GXoV6+zOYr6Tpe9VmTBuPV3Xnq7nuJ+P8K5mvxubVy/3bq5/fftz5aj2e+SrySvP1KpZeTe58ht35cMleRV5pAt7se7PG8m4OXiVe8UrT9Wph8xWvws7xq+h4u+brVZjy9c6h+XpwTvsz8EoL8mqzphjNlzU9r0Y0XzmfQQuYryb5+uDul77Y/WR4JV7N0Ks4z/tN2P9Ky/Aulmhlb0/Tq3r7t9dHfqrrQV6JV7Pw6mjzFa+koy8aXz7RJr3/1dM71zrevnOVYa0XGXglDT1fjfH8q+qz0M79nSu9+xy9cr2zlrMe3D3ePgawGt6PvpZXq9q48EriVY9eHW3a8/qgNAavxrAIrOdVdjpDWuZ+fbwSrybg1e7xq+7/YiP1yvF2LdCrGHjFK2mspTl51dagKa8HI6+0lPkqZF6N66/X0QcsXx+cy/2d7X8lXvFqDl45n0G86nSZWnk9GLLz26vPErERLuYraSxejamax6+yqcn9Bx2/knhlvpLUyqvY1qsZvj7IK2ki81U1Kxy/koat+T50y/NqbuvB1+83wSvxilfmK6lrr+IELOvMq9jVfsheH5SaPZvyJ2XkVS9eDb+/6ObvxCtZD07Fq1TPq6Otx0a0X5/1oHg1Yq9i6VXiFa80K9dmtx6M5f0mUpfnX73yURKvpKXW/H70qdrT+9hg8UriVQfzVezAq+HnK+czSKP3Kj6fr6rdIqeZV6+8PsgrafHFt5+o9byKpVdH3f+KVxKvqnlVHYr44h15Jak/r1KN4+3x+cc90uuDBa+kpfbW8av4Yom3+d3qXq2O6dVq8PPb40GvIq80sjkkzvPLifu/tJhfbpRijlSl1wezy5WO8/JgPCm9KtJQXm3+MrzydPYFDvTl7H32579ZxPwkhrfvrdDBQ5XS6cnAXuU8pSLtPIK8msQP/WL+hUm7A8cCvCqyb+gqFUULr47xgPFKx/Qq8WqyFbGEYPttXGdvn6T8YPeb3+nUyXx10qlXz17PPPA65WY9eOd3SryasFfFvO/Alrpd7Qz7pW1HqdWq2H4fT4titfn907Q6bfhgdeTV0T8FrxbAVb5kWC1F5vl5tSq9Wm2/j2er9cn27bQ+T3U+4vHpSml92qVX+Ue8394qn6xffNoHr+a0WQeveiIkvnx79y+YNn/JuE5xHR9/Vdy/IJ2Ocx+EiS4BUzYPr8uF33o7xVysTs83Qpyfn13X+P5249VJj17lj1T+oG1+AopZeLX/lJWQZsvvaLxK5du7Z2OnY3mVvXwdZ/BS4SGvTkqvitOLs61X5zer9s+JycxXcf+rods3H7ya/rgR978937Vt717lB8zW2S/Wbw+Aj149/pXvf9xSqO/V5lKTu39b4/GfkkPRVXq1Wq+3V72cp5N7rx6+uPOzs3uvYv0fi3REUE4PTEDH+OD5CRs7y8G9XqWZeLX/nmvzPRKdf5G9eJX9JF3cZkRepbe9vMNqHVt8bzOvijl5lR2/CutyPXgW1hen27fDzcDrwTsjuvQq7Xq154tJ2Tkds/YqzvVIdNr9cQo9TJRFeVHG7Zezy+0j+2F9etHF9zMe9CrNyKvyubdabeerk7DaHr864dW9V4lX0z4Gkn/taf8y7Litz682b37+8/bz5eYXv5y/+9SpVyHmO6kU3ZxjNLxXxSqttkvs0qs7w4Y+3h5it17F0qXy7Vgc8OoOrOl//5fm1Wr3G35g1jpqJ3erwKc3f/7x/uvt5vd/u1z9zKsj/MNTerUKxdarVW9evbKfTMdexQNexT0j2MP9vHg1sdYHl4OdzVenN582P7W//PfnP37Z/P4ftyffuviGpp0FYcxf8Z2nV2G93ppcbI9fFfXXgy9fuG3p1d03vzev4n6vIq/m4tXO6e3dfS9Pbr+cbb368u23rVfvTr9341Vcllfl64N3Xl2ePYFz939u1728Pnh4b8BO56tYvoCSz9HxwEkOMc7hnJalvT54csCr2N0XfHL7dXNc/eu/Pn/7/unpV78+eHV29O9o2jnivn2WpNmuB8P29cE7r67OS6/erYc9nyHG1UnG1fG92mdRTCHuf3bP4Z+rpXl1emDW6vKEs5utV+Hvj9+/f/n4hNfN6R8hXB7/W7ozbG1Rjvk1wgvw6u5rfX8yrFcprrP9rzrdDSSftZ6/QrwAr+JcvcrGmdPznj7n9dfrn54+7x/v//nt7M93D2/fXn39x9fw7qTTz73KdjQJpzP6ruaz4mpzXmgK6WxdgnNVNPLqmA9/f0TkexnuWTIePDN8WogtzKvz8oyndHXV0ye9+np7/vSq4JebH9+u/8/nx19cnH/9Lfx01unnXp9nH/9iPU+viqK80ukkezn/vMZVZd2slaNrinl1JK+ue/Pql9uLJ69+uf7xx/X/+/I46l1eff29c68usinycqZexVR6VWzni/RwsDJWnSJisGMBr8brVdGXV6eX2/nq9tebv/+4efLq8ur2l9/Dh9NOP/nJZebV1clcfmoPzEMxPy8hbQ5QVrjfBK94ZT345NXV15snrz78cfv3t+v/+7gevL3+8I/fw/suvVqF9eXmK757xl7P3auSnQe5VnUk4pV49bgevD67fnzzz3c//twcv/rp5uNvv5y96/IzL9qr7YvcserHPPALaVHz1darj49eferLq5B5FeblVTy2VxKvHteDG69++v7u761X768//vaP89sevZr68au37j/4fFnHK028/Nl72dPnLL0Kd179c+PVB1515FXY9Uqaw3zVk1fP5qu+vZrT64P7zol8e76SzFeN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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0034_20261007_094835/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0034_20261007_094835/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e4996a34ae5526a90e2cdefc08ea89bff45c541a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0034_20261007_094835/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0034_20261007_094835", "meta": {"split": "train"}, "act": [13, 566], "T": 567, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0036_20261007_094924/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0036_20261007_094924/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..bab0a4e4647bd0202a771b1e2831606b4961d032 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0036_20261007_094924/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0036_20261007_094924", "meta": {"split": "train"}, "act": [10, 590], "T": 591, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0038_20261007_095034/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0038_20261007_095034/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..363f5d11eeba78e58e6bbffe49c6de9f0bf264e9 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0038_20261007_095034/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0038_20261007_095034", "meta": {"split": "train"}, "act": [13, 531], "T": 532, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0040_20261007_095130/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0040_20261007_095130/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..39de3bb9280f054d366d1d869c62e7585d6eaf5e --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0040_20261007_095130/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0040_20261007_095130", "meta": {"split": "train"}, "act": [10, 509], "T": 510, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0041_20261007_095156/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0041_20261007_095156/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..ae9fa83e06406efe88eb5e0b289cb04935be02ad --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0041_20261007_095156/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0041_20261007_095156", "meta": {"split": "train"}, "act": [14, 540], "T": 541, "maps": "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349dbz++4ac61oHedNVvyMYrWfGqisOfg1dDEq/MU7/8bu2ttnRj/kuVsHz726Q34uTDNGteK87IFq9m/ZXVszspL7CL01+9enWZoVJe/Xqvro1e3dtfzbwag231rBjktl145SnTNQv9MCTwyrof068dXIBm2alTyv09+5fbXn107/btA+ktXr1/dK3ehUWd1qZ6r/76NX396tXHkhh7+vXjchDO5tab0qGdE9urt1rY45WseFVYJUgGXp0mr0YFxxjr9zvt1RjEK/05wr83e/U2s6fW05gND1b/3SujTmtbvVd//pz21z+TVz+fL16dbAiv1EqcbtReBXvXfT3Se0b4a7iVWwkXr1bd9joGU8fUffU5/eXtSFw8r07Dy+RVkAV+ejyo+ysZZf3ce9317tAAzqp2zk2v3aSj3PpkqViv3hqpr331z/nLq/H5+fKm8d7N64bX2O7+D5rWvDrhVYzxYPitWPbq4evwmohePQwvl2qzHr0SdTOP8fLFm1fr97707ZV6baZBy2z+3173eeJ129efAxHCq5/LXj09feuV3/EgXsXxStJ7dU7h1aPyyvnsr0T3V9oru7qbnX7/+PTKqAqTDQdnxau3+bBNXhnPXtmIXr08T/vn79OnV6Y/vRLy+bnh+xS8HqBb59mrQXs15uJVV5tXXdStUL/ha/7q1atTivHga391KdkhiFdvaHx90f+tzzC7watu5xPWc/BKj4AHE9ErtV7077Eb3IdX48Pklbn2ykt/pX5Irz6CHAa8iktX+N/w9fmgse4y3y62H2JtrHXPdno/tAG2sVdXdblfozpDrs9lu+yVj/Gg8qr7rq/zcvA1AqeIXv14VOvbx0+XPm4t4/ppS/2PB/UMpXqndeHuzzDz6iZB9XsVfX27GNtPM8Iu3t1k7POl2s5BvBrGaZfa08dHkO//2D/MG6qr+aubr5dPnZlXLolXLqJX+sc/PUx//1/9p0UfXn2O++w1p+/fc+/1OLO/R/9wk4lXHV5540KWzjTjf+Gm+onP+tQ9h17qNT7p6Tq95vlFvReLm127O1sws/x65eDNlnvpkU+/qUfx0l9FHA/qFbHTeifb/franx/3Z/j0yiyS4tMr/elruKWZ85KQr5upToy1dDlbVK8k0pE+qzvF/6WceHhwvjZgZbf1L+o6/adHdX+kX3q59eqMndnlVbfSXxlx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", "force": "H4sIAMq51moC/+2ceUAVVdvAb324k5iCG4gQCrgV4gKCcGceFMl8FUtTe10o0yw1yyz3RFHcQFAUd0XABdNEsVxKQ1ss8y0s9w1cUUNyz97047vPTOcwc+45MyP55zf/6OX+eJ7zLOc562Cz/f/zJJ/8pAt27eeMXQ8judzggTou8NcSO48r2rlN9/PszFYSj4u/9UlkhTipC5eztaip07vzPzUlK+0TcbWSG0hPUh77iOyIrRQtWeEyEt0MueU5xyRefIMGttZxPcf+rnwuSuyll9fvke73ni6zyfivBN6GHJFnxpH2sZzPl6t0XMNJc5Xvc7c8LxnZQTg2HizXokWW+jn7SmRFuKI5q3Vc2Bs/K5/j49+xG3FEns+YNLsVeWYc8R9rr0gemy+Bi3dzufxKt+1WODO9xF42r9adm8f33+CZOnlVlwzlyjPLA8I59Q8m/0i+xNtXRlriIMhuJU9Zv1SUE/lZJC+/UR9DjsbDRB7h2Pp3dXxtu5V4iLjYL7pb4tj8cyrzAk4rr9bDp2RR3os4tk6KuJsRgYZ2kPpX63gVQ4728+vr7UYcie+DgymG4wrhrPrZjBPJE+WL1bix/Zx9iF+k9QXW5DF1VySPracizkwerQcMJ4qvVY7tl6K8etIcO29iHzLum3GXuj8j8/SK5JnlC+X+nGyY98SODNt2S/1tA8yWnoRewtUfttRQL+HM6iTh4rJGWeLM4kvsZeeTFc0/kvdG9VQ3HhnUZ10/YuoBy9F5IlMPRPnMzidF8szsIJzVOm4mj8TNLE9FnCj/ApcvtCTPVthestJ/WTtEeq3am9tmgCG35GwlSxyJR2r0VMlKHc9/1E2yMn6w47lwvmFSD6yOg8QO27QXJSscq7ei/iOc2fgmWpcJ7SjJeyLy6HjJzLNFXMalbyvEieZDtfxsluZNRS9GWKp/GR4fWJJnVq+sclbrmmgdIPKfVY6dPwvnYcx895/mi5mf6TrZJG7EL+M6rLDmP2ZdK6wHBusKo/WqaJyxKs+srtF5jkncHtcOq/Nds3UU4di6Jlz/mtRJmi9HJklPMh5m8yu63qogJ4qHbfubT4Qj/oub+IP9Yr10yXTdc/0p6faPH8qm+wLBvlLnVzbJZv0X7S3xPCObjoOrj9krDX4gH/IYIhtx2C+Rqxm8RjasBw5u4Oj78q8r/lC47zfkCvOl6pvVYWVwDcDPScNTuftXOA9rdKQmbOzqoXDuZ3K53LB62ZLUOxA8XgxUuC+/dpW5fnGsuwd/GwR15BYK99A3TMgFFLSDaZdbcTkyL8b1h/1RBwgZE6Rw0wtkmecX5Hr2D4fznm0Urv3mSFm0jgq71BHsb4WrftnkL5cWu8i88bLlnhC4XU1WuNyJbnLhH39JvHmTFwTDvU3RCtfyw3oynWNo6hXmC/pFywVFX3DicP/q8KLn4Y2y7gr33YfVZLrmZdZl6LvURz25HPk/tm9V+2DwnNdH4cZkXZG0dtB1qKNuXHivBVx58zWFq7/hgbQ77K7sNM9x9KOooAA4m9+LcgcmPwW8/lHHtTF02qHam9muUMdp6xVy89xVP5eW3JEa3azqJA/t1XIoryIc1fvsYrtvXmNYl6m2b4ZnZfnbPpX/MVeaFKV892iNl/xuehUnDvcjLg7zAumgZMjheIRcZlM1T4+n1JKLe9iAt35rO7A+rA4JpZzWXpIvKM//DU/YdfUFyj1TVtlJnu3q7IirnzbSyeNxKK9FCz9oOSqUxu2nQWWyU/45xt9Drj6wtziCcld6/Q/w9n3cJz5H/Ydx83//jsxbv5WuDIagY1E0/7R+IdyGZ1dKOetCYY1/L0POlpgsZTaKhF6tBtH41nRz9h/O6zrti6Ic2jG7ZRVnzrHer+8WBT/8tz/lWnrUoNyr3zxL96UwpxpM7GPIoV/6TetG7RDpxfV+wDe9oUnMK8p36Xu+08nT2jFn/lR4a2Nf5bvbLoek+W1rA28eO+SvOTCia38aDy2nzZcvshZBzRsDqZ+DUzy43OVja8Cz2QDKDe3RwEnvzfWVpfHZ66Hn0dcM5RXcDZMCf/sEErIG0vZ13+gF7LoR16urMnPhy4/iqL08vUEjPaSPO2zTcTx5yPls2QaNl6h2uHTaJtVa5+Mcj5XDpY1jdkDVVf2NOUe9r3t7B9hCXqVxq5LZxInDeeee9Xnw5dleVN4LReUc2QfBeafb95sh/V6s+l3aUh1HxyPH+OsXsBGC336JcoWnAp31JrRSuI4pXbgcicf3h+dKG3bkwNV1nSj309GWwDtHHHbgE8qNq7NcJ6/n2FGU6xCzET49q9bxE8Nm6DhHhtJ1lJZj20frqWN97lf6iVAereOOdbclzlGHzpzLodz2c311nGMOScejcR9lw16XCMpp46E9j6pdkAlnakdyOe15sm/eaqiztoPy3bTW06R9l324fjnolaHjtPlHOKwv911XwdM/hVFOq5fEA/2HXN9/R3DzinDYvndnr4a7eyO5eaq1A7nu7SQuR+RhPFDvlrxwQ73IRfVdDp26hlFO6xd6fumY1w25vFTH8eKB8d2evQRO/ivUMO9xHESO+FkkD5/Li9Jhr1u4KRc7Kc1c3tZg++Wj83Xx5XE4/qYlL4Trg0Mox8tn5AJ7pIFvjVBT7qvWqVArKILL0Xxx+MUKh35euncevJ3ckduPtPHoNyGZ+o/lqDxH3i/8LlXIafcZejZOof4T9XPpYVc7cr/famdYX3C8jK2SpOO09U87f8Zx1YzD/aZTd2bB9zHtaPuMuC1ftOFy2vnppjuJlBvx1EzpQUGQM+fw369dZyrtm3gzQUKu7EJbJ704bqFe5D7/eqYTR9YYOM4QeYuieknYPh6HdahV9gyap8iN9ujA5W6/VM5hfLV2EE5ZV6RNB1ybmXHYPhFH7fWzScOuzRTq1Z7/ukck6uLB47B/IEfiy8aDzptwTW6RmzszgXKYV/0OteVyl1ol6PLqv76tudzsM1N1XN1zodx6dWfLFJ29Wk67b/bB65N08njtyx1cwzGvizflbAmDJNQr4uj83sHFRevtSHOLcJo3Icfq1XJaeffaxOvs1eapdn8S/Uc4zBctR/QqebBosk4ezw7cz/nQPsESp/Uz6i05EMqdT8q/TaLyMF94XIFXlBMn8vMXU6fS/qHUySOSU9zee7G9EjdS/3C+S/ZY2HxBec2uhdL5AY/DdZTWz0Yc5r22fUkunYG3T71+wUzduMrj8NnZLYnOm9r/lMDnXEdEbnk6hc7rUN6AshhuPUB519va6bz49f92c25foxz7kIfJ0Gp0uCFXVOwpVbk7R8hp+y/Ke7i5I+V47cNnQE65XrRXxKFeIg/9LOKiYTZU3hZiymHdvVTdXJ5HciKdn+4dvUXHae9FPg7X/VE5J8qDfnOnU45tn9bPkZdnUI7NA5Yj81MRF/fnOTvLadv3uFz8hiEKF3E31JDLCElSOO28mNc+rOPPhU035fLXB0gT7s2Hj6uH0PyrdaqP035szNZG8qv1syiH+9TjRr/szJ2ZLLvU3U37JcrrvjLWaR8z1u2C3HbEWR3XYEGc036TS2kwZJXcgOQzQZQ7MWky5cbluSs/T9wZA18dK4WYG82Vz3ivtLJ7mhOHz8VzxXA911fH3f7xntKuqYn1KZfgdxHmvqv+3oKNeVLEwTXy1vv/lhc+f1LedCGYyt7/wyXYVqTu0TU8tkO6+eYn8thX9sq1X3SFzk27U+7GoJtQOP7pv8ePtdILwZ/JgwIK5CFXDklTwsvnjN/VewTn7x2Tibyveu6X7y5vCLhPTuoYPmXP2KIeTtouEzs6N/1JvjpIgt5vesvLTvSg3NYLD+CXbWspN2/EeTnv15cd3/vIJ7/oB+ej1ym29zt0AxrdnCWT+pKff1dumvcqFO9uIs8q6QvH887ai6Ju2DdfPgokBrOKmknY1vfDo+GFjo7fXfYvZd6K+xE9ji6AKTUCFA7vx4b3niPn/hAJF966J1dqNxDe/2aFZKu30t5yaw/IuX9VIhzODX37tIcFa1+CgrpTYVTnLcp3fQdkyfvfOCiR8zz8edXhLcE7Mgn2hS6GhDt+8tP3PBQ/0f0OB3fH5TN7jWUdYMlXuZC9PUfG+fKmLisc7cqXtPLwVu/HhRKkue1ROCVPQ95R4kP2E5DDzx28u0B1/12w+m6eel7UZYCMd5618nL8f5PcLwOc2rYdGo9bp54XucXKeF6GHPoJOfS/dzDAitGfQsLH+9R5RE5nmaypzsO3lMO90thNm+BC6W41r2IaUG5R4iUJz62Q89wcpuO83FydOGWfxDsM1jbfAM3r/KxwO1uVSUZcxI4j6j7cX+XytOdlOJ4ET1zH5bCPkXNYHN+T/pMNB4MPKNwY9+o6jsg7HdoW9vlmwdBZhyn3UfYC1cdZ1WQiD7kB767RcUp+OR55qKtM7mfjGRDWMcJt2HpSIpz2PI9wW++fVrhhtz/TcaR9Zf2dOZ5fCoc+7+hvmabcEneVG3/yPOVw3cbjAjesgh4Trqj3/w4cduRUV4k970auNLuce2/Upzp5Wq53x9XwxqASLkf09lraHEbMW0m5vrsypNf2DeVyzQuXw0e1blE7eFynAc1hYugyysW98ovODi2XlrqEckH+J504bC/LbfjgipNf8HPeFX9o1k6vVxsP5FAe4Vr/cf/v+fhEJ06J7z5/uNFgKeVuXhups1fEFX2wXieP5OmykiaK3sOf/6XeF22dxo1v7wNN4OK7i6Hvz+rZCtrB00s4Ii/O/5hSf1g/I4ftI/J8Tg8R6o3onU7lZTS+YufFjegl9qK897/xcuKudQyE0m8XUb1By6K5eh1jEfjlLKRcjPcFLofytFz+8RncvFLkzU+DFE91XGb9LOJij83k3stAvXHSonIuIpnLBbRqrpNX6+0cLrf4M3/4/eJ8ysX/PRaata/gtixsn5bzsTUXytPqFd1nJXrrvq2eicW9nGsXcSdCU6m8OO92XHl4v6HbtSQqL96tLFLE3UqZRznR+ymot7SkXJ7NPzOSx+2P9IVZaeXy2Phq82pIx1TKpa7Qv0dE+i/q1XL51YZzOZSn1cvGl3Bor5Yblpup48i4hRzq9a1bSdU7bwbXDpazXXpNGA+tXvZ9CMIdHu5vyJFn2iB/GF08n3I7+ywQ5inKwzs0PD9b5bT2HvUq51i/EHlYd/NemQue3uoZNK7feBzWtRPrZlOOjYeIK6oWKuxHM6vMopwoHug/LSeyA+11mavncB5sxuVGd5JwHsxy/QpaQJugOeV2JP7F9Uu9uy3Atq3cXlHckPul9ix6P2rJonRu3NJGBir2Ek7k514bA8EzZgacG/6MwgW+vpwrb9v4APhx8jT4ZacbkPkLT97K4gAYNn8ajOxeV83nwtNcrn+NZjou6PNKMk/vR+uaQnHKFCjL8lS4JscbyqL4duo0mXKx11243JRKflDSJR7ufNVYHbe+e8htX6S3qteM+zG8GZy+MRnqbKmncBmTi7j+Qw73X2fMUu8i3Ox2iCtv/byWUDJ9siVu7nsToN5QD0MO9UaHTKDtG7f0tLB9V9LHUC6o71UuF1c/EDoUj6Wcz/6qXD+n3WwKAzePhe3zGhpyI0/5K3pFHNGLHMormdBI3R/e0lQo76muoy1xK/e/b8qhHdufG2GJOxM8CvAOF4+j/fJv7vhDX0MO4/FzzdGUa5veRsihvYS7G15bFtUD1LvopNq+pPm+wvahPMKdmeDGtXfGKT9DjshLSfJ5ovLwDp+WW/hOXS6H/Vxrb6jX/0oi7sAhMUfa1yOqPrj8MRhSznkZ6r2zuTqseNQbGs71VvvlnhuC+9Quyp45qVcirrTYBcb+0gbCrqv1oGqvb7jckT9cYWxXb9g1UK33gZsLufMhm80dBt6qAe79Y+g6gGcvysI2vvNI3UvdFFCNG7fgbh3hUttKkGFX9//jHvDr89BZPSGjpiuM/1S9x3eoawSXqz0qGk5cwjV3kHrOtPQ5Lne4ZSNIv5Uuk/2lTUO8+P18R03YdbUzfZ+4auqfXP/hvqPnvXDKqeO5M1dnTIJ81idEyJH5JO7Fvbamvqzdb+e1D9fKuP9G77bWO8CdH+Ae4rHRreW3UqvLZtyCxb6UY+dhxA7k8D1rqpd5X43ms2PugwyxY2fedCGH+4rkbjD7voaWw3W79l646J7+Hk+38v3DOeFCDu/IEL1xC1yFHO5tEi6+4xG7FS4/L0r4/r5OHvMeBvveBOEypq218/uleheE2IvnZSIO7aX39BlOf/9eolxus2ckKxyec4o4bdzw/MOIE9lRUc7K+y64P0zaZ8ShThGnjRty9LyHaR/LifzC5gGNx+AaljjbmQ8jK8KJ3sOwypm9f2QlX3R6EwZZszcxmVtPne7bXhspifqHlmPfPxJxrL0sR+9FMnETybNqb367l635hZEn8jP7npdInlm9elyOfc9QxJm1j9yrZznR3z1ix0GWm39DHe9jV4da0ovnxFa41N0NLcWD1St8z9qiPLM8oOfxFjmzeFipf1ouqG9NS3qLpnxtSR7eQ7GSV+y+irAfmeQL5bYGW5LHvrdota5VtH9QeYXW6i77nrXIz2Z/F0f03jZbn7X3QYz8LOJE8tj2ieSZ1XGr9V7U3/DcniuP+Xt9/5RjH7wPZ8UOvF9nRZ7o75vhOZ0Vjs1T0d8jY/NUxBX4NbTExa3pKRnZS+tQhn5/nPWfqP6J8spqnTQb9x/7/e4RyZbe7zar94/7Hnjo4CRDjqwb2XypiF6830LvCX7+kHv+8X//4dLGiFQAAA=="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0042_20261007_095223/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0042_20261007_095223/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..587e43607fbf4aa65235f99bf26a211662a6e73e --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0042_20261007_095223/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0042_20261007_095223", "meta": {"split": "train"}, "act": [11, 553], "T": 554, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0043_20261007_095251/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0043_20261007_095251/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..f772e1d696e2a6549a8bd3e9584ef51772cad7a2 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0043_20261007_095251/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0043_20261007_095251", "meta": {"split": "train"}, "act": [13, 570], "T": 571, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0044_20261007_095318/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0044_20261007_095318/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..2084682b98be75d6213c7a19137be68c11cc960f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0044_20261007_095318/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0044_20261007_095318", "meta": {"split": "train"}, "act": [16, 530], "T": 531, "maps": "H4sIAMu51moC/+3d6WLiuLYGUEsykKmGPl193/9RL0klWCQQMHiSvL4f3aSKSoItL/aWhd00hSSEJsT493Fqmvj+OB4/pfvvNjQx/H2cXh/8fdik17Qp7eL+6xTCto1t21SeMNJzZVn79/UYuWvHBiNhOK9C59Xrw0G8Sts28coo5RWvZvcqNJ1XH1v/a33FK17xildjeJX6eXWor+I3/eAKvBJenf2bwKux66vYeXX0lOyPwja+Pe2vUeHQEaYU38jafHgV4iZE9ZVRyqtzXhkSvOKV8Erk9JCM3UPDb9Wo9fTKgJEZcqNXYYBnSOHM2cXCK+GVyJBeGalr6AgNAVnqWD3pVTz3dTBa1+6VyFKLr49BzSteiZTg1dHQDmYveCVSiFen/1J4JbIYr+JHH/i6cItXdXvlFLDwSnglMrpXIf9vCPpBXvFKCvGq4VXtXo3370Sm98poVV+JVOJV9teuI8YrkRm8ajqvLg3mwCteiczv1TVg8YpXInN59fZ1dgG4S+sZDGZeiczpVa/6yuYrRavQs7i2zaQ6r4RXInN51XfIOj/IKxFeCa9Erh+ygVcr9kqEV6K+EuGVqK9knV5115MxhnklUkZ9ZQDzSqQMr4xfXonU1A8a4rwSKdAr8+3VeQUwqasfNKIr8kqkMK/evna/CV6JFONVc7VX+sHCvUo2jZTtlWZPfSVSo1dI45XIvF45P8grkeV79WU9g/HMK5EivHJ/51V55cMNUrlXRrX6SoRXMoZX4fO+U19JNV4ZtqvySqQwr47fWwOv9IMiC/dKT8ArkcK8cr9UXomU0g/2m7+yXpRXIrwSXolc45X1DLwSKcIr69t5JVKQV/pBXoks2qum8+riYcCrkryyXlR4xSv1lQivhFci13l1zWHAq2q88nlnKdmrqw4DXvFKZE6vwi2Y8YpXIouur6RwrxKvhFdSYH0lUnI/6Hp9vBKpqL7CGa9E1FfCK5EeXimgeCVSTH3l+u28Elm6V03nlfBKZKFevV2iL3ReRWTxSmTBXnXlFax4JbJYr0J+v9Rg/opXIouvr3jFKxFeycK8SjaN8Ep4JTKFV4evu/l2aukHRRbn1fEYxxSvROrximi8ElFfCa9Erhmy5tt5JcIr4ZXIOF41xwschFciC/Wq4VX9Xll/JYV7dWL9lfBKZKH1Fa+KJCnwStRXUnF9Zf5KCvYqqK94JVKQV+orXonwSngloh+UAbw63vm2mRRSX1l/xSu7XJbvVXPaK+GVCK+EVyK8El7Jqry6fH0Go7oir0QK9Kob464vqr4SWbpX6ite2bNSn1fCK5GF9YMGL69EeCXL8UqkQK+iflB9ZcdLuV4ZvLwSWZxXRzypr6rzKnze1/pBKd6rpvPq4mEg6iuRJdRXTZ/1V60tWbhXIrwS9ZXIKF41vOIVr6RSr4RXIsvoB50f5JUIr4RXIsP0g03nlfBKZNH1Fa94Zd9LaV7ZNtV7lex8WY9XRrX6SmRer25afyWleyXCK+GVyAT9II14JbJcrxpe8UqkyPqq17fx+UFeiZTSD/KKVyKLrq9cn4FXInN51dzeDwqvRCb3yv1xeCVSSj/IK16JlOKV68nwSoRXwisRXgmvhFe84pXIUr06+trncarz6tv7O7uejBToVcOr9XiVbBrhlfBKZCavwFWJV9fvfJGyvAq84pXIwr36WN8euvODvFqDV+bbpY5+UHglwivhlQivhFfCq+bMqHa9Pl6JzOjVNYcBr3glUpxXwisRXgmvRM54FXvc31kq8kqkyPqKV7wSKbC+Mp7r9sr7kVTjVeDVmrwyfyUle2XY6gdFeCX6QRFeifpK1ulVv/tNWN/OK5HJverG+OUPECq/eCWyEK+uOAykGq+cH5Qi+0Fe8cqeleV7Ffq1g6UevOH0iwzX/IsyD2X9oFTuVcXX6zulTwhnC4xwrFqtXqmvpMB+8MSBGep9yTF1Dw4344uvOWyF/eNw6l/wSoRX5yqgob51PMC0lyid9iodvIpvX6zBK5ECvTqx/ipMc0id+aUG9mFfLX18x5Qyr9ruB6V9Dl69Pg68ElmoV1/qq8l/j8yEEAZeifqKVPcw7wdj7lX3uG3TmH7qB0VucyLM6FU6MiVr38bzKp+Zyl9tzH7+K1flVyG8kmq9mud6fe1pr+K4XnXz6pFXvJLivGp61FdD3h9nc8arsBnNq9fvHk4+pcm9anklsvj66vLnnYcc1fN4FeMVXqXidzKvpEKvmubm6zPcnW3eG6YzkM3kVfhug/FKZEleTTJ4z3q1HdqrfHYqXtLnzavIKxFeZdnxamCieCX6wdV7Zf2VCK+yxxte8UqkRK92vLqKpMArWZdXc65vP+/VsL9H9hmctw8TLs6rMS48xiup1Kvr3rCr8eri09+8OvqwzsjVEa9EeHWjV4lXIov06u/X868Xnc6ri987Tu9VnMcrkQK9air3KuZr2q/1avTrX/FKpFSv2qm8uuLpNdVXXy7byivhVX1eqa9EeMWreb0yrS7Fe/X1EOKV+kpk4V5NCtYsXplvF1Ff8YpXIhV7lV+Wr215NblXY68X/fRhr658D+bRhFdDemW96NBeZS+ZV8Krs6838Ep9JfV4Nc96Bl6tyiv1lVTq1bBNy5FXVzydVyK8ms0r8+28kjq9mnA+lle8Erndq+aiV2PdL3Uyr67tByf1yvWvRK736ushVG19tUivfH5QZIx+MBvuLa8K7wdFKu8H1Ve8EllMfVX7+UFeiRTrVeDVRa8Sr0QW41Xk1cz1VeCViPqKV5d3/qkvRBbpVfO5vrJelFcipdRXS/DqQX3FK5FLQ3Yh60V5xSsRXt3kVTLfLsKrNXoVTiCVX8qOVyK8WrhXg35rXgmv6vBq7vODI9ZX93tlDkvK8Srwqmiv9IOivhrvHVg/OOIbBa+kWq9Cd9gEXq3XK/2g1NUPjuZVWoxXaTXrGay/ksKymH5wLV4tqb7ilRTGVejHlX6wpvpKpOD66iqvuueMdz1kXqmvRIaor3jFKxH11Tq8sp5BhFfqK/WV8Oqb5w/pVTOmV0F91b0YXkkdXk09ZEvxKvJKZOF4jZ92jV5N3A+Gz7s3GeYivOKVCK94NWg/6PygrLNlvPv8YP4N2lV75X6pIs0IM1K84pXISKaEW/8hr3glskSv4m26leNV5BWvhFf3ZbL1V4tf384r4dUY/SCveCXCK17xSmQ0r+4Or3gl0leg+PG0MK1ZvOKVSP+K6SavBj0/yCteyWqRit3DcIVUoTuCJryfVynrGUb3KkbXvxJF1RWDsK9XQ4ZXvBLh1Tde3XA/el6JLKcfjLlXE85vTOhV4tXHi3F8SAml1jcH6EznB3nFK5GmR321Bq/SkVcXv3eqySvX6xNefeGmJK8uffOJvMqMiukmr769yp/6Sng1qFdT3c/rs1fxzD0X4lxepXTTC+aV1JBb119V61WK6aBD/LS64YRX7fT11Whehc9PT19etkhRXsWv6xkmGc5TetWnHyzFqws71vyVVOdVc3t9xasCvDo7UyBSg1cTjue55q94JbJUuno8bRXz7RfvnTXH+UFeiQzvVX6w330Uz+TV5fZxkuu3TzPfziupzasT6xmu+Xel1le8Ojk6nDGU2ZEKNx4Ak3g1y3rRK54+vVdRPyhy+/1xJnmzneXzg8v0yvX6REa4n9dY1+trFuxV4pXIFE5d+XkcXuVP55VIHfXVkJnwfhPp7DG7hM8P8ko4NUp9NZpXaTyvEq94Jbwq0asYeCWyrPT8PM77k3k1k1eJV7Lm4iqePCAv4bYWry48XX0lMkt91fSrr0L/f3RHZjk/uMjPD/JKeNX0nr+atsCaqr6K6iteSc1eTV9f5SbsrGeYxCufHxRe3VRftbzilfDqquu3f3A1Xz84qVcnnpK+6wfbUXaRflDkjvpqxV6lVXqlvpIC6yterdQrkWV51fTrB6f9JafzKvCKV1KBV3+f/OV+XrV5dfmezrwSKcSreQosXvFKpLn5fvRh0vqq5RWvRPoUV0fPnra+WpBX1jOIlOZVwyteiVTp1bD3x+EVr4RX05RHvCrRK+tFpUCv5rq+KK94JcIrXvFKCspN6xnCetcz8IpXUphXs66/annFK+FVH68aXjk/KLJwr+Z5i9UP8kokFOhVwyteCbp6FGO8OuOV+02I8IpXvBLp4dXx9UX7Oacf5JXIEAdD506f6yHzilciy/Xq75O/XF90cq+sZ+CV8Oo6er6sv5r3fvS84pXw6kJ9xSteiRRYX02SYrxyflCEV7zilQivVulV4JXwilfqK5HFeBV5dexV4pXI4r3qSq11nx/klcjy+8EmqK94JVKIV/rBt8e8EuHVN161q/Mq8ErkHq8mnm9PU9VXIfT5hrwSWbBXc91vYt1e5f1gGl4RXkmdXi3jxihHXg0tQ4xnj9nw5Y/j9OuveDXsS1/c6Jbrdtk5r/IJq3hU6cy1fzcTepVOH7+zeZV4NZpX6slikx12bYjtx57ctulh/v07olcpZnffSftssld7oCIdWHr1Kp1tXEfxyvzVCM3FePtOJvZqG+Pm48uHTfsUrx/P4wz4dns4vGIc3quu29xj1G6yTXKQqZ3YqzCJV+Fz3cErKSTb7uFL3Dzt3kfw8273e3MY2X28GnDst9s4mldt2nRC7blquw3RZl613aM2jTbmQ+fVqYfqqxG9MpdVllebAwQ/4ubl4X0HPj0+/m97/c4cx6vNLvPqYdiDqk1ZfdUe1Vdtt/LqUFPFMbwK2YPwla4RyrczO/Ts8VvnwRzaUKXV+b0Wjs9+17QfO6+azcvj+w7c/+G/i/IqDe/V5oxXKfOq6xg3bRqeD17NUmtV5NWZijyGTeX7MbVPTXqvr8LrvPs89VU859XjuF41uVeH3yLzqm3Ln/Xg1duhnDbZNCGvykzbPjbp+a9X8fU4/WcWrzIsRvVqc9QBpnwuPT9lWpxX35ZlvHrb2btuLrSNadR9EMLRaZPvP7MQwymGrvYqhu7FtOHpULHHKvfndn/8xqfd+9HZpLf59nc74iWJhvOqPVNfxVm8SuvwKq7Jq016eBjeq7zDzAZTittsRKdN/G5Phf5ehXyiKnVfbJrn9sQ+HGV/huw3n+7DMNt9T/ThVbtAr56G5WLTZne4adPRZ5vPeFX+zCyv3rx6fMi+GN6rfKZhE3dZY5ad8D6ue+55f+q+Tdutgd57tQkzeJVef4vJvGoOXu1/5j+bwwGbpvNqk32Hbb57B/aqzb26vJacVxU1Es8P2RfDexW23dqYbXrc5m/AaXCvYu7VQd9N+/RxguqVk82Y+zO+lhPd4buNE+3G/Wt9n796Ozbf5q9m9aoZ06uUexWv8Crxqors2peH7It0z+bM2Mh6uW03P7Y78qrZnfnU151cHH5yt6aw3b5k43s75gZNTThsxf0rf5xm3mT7urb9/fxg+/r4bT1DmzVpaSKvmiV6lXhVSR7aH6N7FTKv8hG9HderuNt2Xv3I5jt2Y27QtombtvPqaRqvdvufGV/XX7151W4e/93N4VWbf4d8enLw9Vc9vUq8qsWrn4/dF5uh+sFuU7UPz4cv2rjLrXjJ8ApD3yE8Nvsm8DDd8fjP9nQRMIJXabvpvHpuJ9mN21evfj2/Dd69V+3Tfw+HbZp1hXFkr46VyI6XzbDHToyZUfHk2Zgcsdenl3/w8uptaqfzqt1uRqgGHn9m3/TpKfub3/lFBIZu0vZePT8e3l+n8yrt67dN95Oe2ol2Y2ri75e3wbtvftqn/3s8zF9l5wcn9SonYuCP/4ajM8YnvTp+whif51u8V1Umm7/abDcjVM2PvzOKfvzI/ubfDK/8AigDebV5+fj+MT3+b5eJMmb2P3fXdj9povmr14/KfXj1dq24N6/idf32SF6NeCr2Cn0+e/Xt31bjVZU5unBZ+uiaYtxuxygGnv7NeqJfP166v/kvwys9D19z/Mg8/N/DZJu3zTfjbhqv3hZyvPaDHxds/+/hFBJpSq+Gf43nAfreRl6V3QPm78vx4/zd3qsxlrc3j39+drXN718/M6+yx+ll+Dmd3Kt/dpNt3nZzdFmq6d6EfmZe/anPq+8qrIteNbyqohvsPiv66tUYL/3xz6+utvn9+9cZr36M69Xv6bw6unZ3mm40Ny+PWa+9u35oFzngw1mPeFWzV93LjuPUAo9/fnfH0T8r8CocTTRPePxmK3+bw/mFKy59XYlX+UVdLgnEqwq82vBqcK/ihMfvSa/61Veu5b9YlF2foXKvdjN5dfzmPYtXMauvLuvJK/UVr3g1n1cr6AfVVyvafbHzqv2oJ+v26tcKvHriVV1aBV7xatp3RV5Jmf1gdxW3Wd5/ecUrXvGKV7zilVTn1cnpDf0gr2b2KvCKVwsPr3jFqwq9qnT91eElptG9is4P8krUV4XUV7ya16srhjaveMWrg1f/6QeXXF/xile8WppXU65vn3YQdw+sb+cVr3hVxJ4Mh/t58Wq9XplvvzeJV1N59cQrXvHq3vrqD6+mrq9uvj6D6AcXmDRlfbW684NL6QeD+qp4onjFK/0gr3hVZj+YluLV8PebWHF9FbP6Kl68HJ/r9fGqlPqqYq8eeMUrXqmv+v2oxz+/eDVxP3jklesh14KX84MpJV5V7VWjvuIVr3jFK+FVdf1g4tXMXukHq/Lqy6dT1VdDe/VwxqsfvBrHq5vXX/FKfaUf5NV89ZX59jXVV5WmuyVs51XDq5rqq4317QWSFNRX37/Eib36zauJ6quN9Vf6QV7xqrh+kFe84tWtXv3klfpKRvKqqXyUTz3fziteCa94xSv9IK9W5FXgVZ31VeJVuUTx6rxXLa/0g7Igrng1t1e/eMUr0Q/yile8WrtXK/o8Dq/q8cr6dl7V//lBXlVUX932+UHh1brrq4ZXM3vVpx8UXvGKV9N7teHVCr1qKh/lE8xfPV3lVRzBq5+84lXhRKmv1lNf8YpX+kFejVFf6QfvfAd+9WrXff17c2rQ8opXtXgVQ239oPmrxvnBdXnVVD7K1Vf6QV7xqkSvxqmvEq8m80o/yCv11d311U/94MT1Fa94VeUof72+6McXcc76ynqGYeqr5j6v0LUwonp65fxgwfXVT/2g+kp9pb7q6ZX6ilfCK/3g91790A/yal1eraEfjKP2g8l8+/T1Vcy8sp6hcq8Sr3jFK9EP8opX43n1uOv+lFf6QfUVr5bulfpqdV6F+l86r/SDvOIVr3ilvhJe8YpX3w7W8165PkPdXll/xavCvdq/3qvXXwVe8apErwKv6vHqN694VU3S8uqrZ17xSnjFK/3g90ObV7zSD/Jq0j15c33FK14tfpSHA1fp43x34BWvhFfqK17pB4VXFXnV8GoQrxpe8Wod/WDklfpKeLW8JF5V7pX5K16pr3i12M7e+iteVXs9mZPrr8xf6QeFV0t+V+ZVZfXVx5/+cn0G/WDd9ZX17av0Sj/Iq8WPcvNXlfaDjX6QV/XVV9N6tVNf8Up4VY9X6quRvLpiaPOqGq9qPT/IK/UVr3jFK/3grF5Zz8Cr+rwy384rXlXoVaWZ+Hoy6qsFe2U9A6+K7Ad5VYFXjfqKV+qr0b3SD/JKeNVceImTrG/nFa+EV8N6pb7ilfBqgZn4+gy8mt6rlle8qtor/eAq6yvnBwvYu2v3anXr23+uwaut+kp9xau7vVrA9WRq9ao56VXMvIqBV6vxyvVF9YPleHX6+ldBP8grXukHF90POj/IK16N1g+O7tV2ZV6Zv1qjV5XmMH/VdPNXvOKV8Ep9tQivVtAPPmRe/eQVryr0Ks44f/WHVwvwynw7r8rxqum8anjFK+EVr/SDvBJeFV5fvfBqfq/MX/GqHK/mnL/6j1eDwsWr4ndiuNOr6tdfzdoP8opXMmR9Vfv9cfYP4zq82lY/ms1f8YpXvFJfCa94Ne18+wrWt/Oq2r17rVeVhle84hWveHXRqx+84pWM1A/yilfFwMUrXvGKV3V75fwgr3jFK14Jr4ZJJ1Oc4PpXP67x6olXNzLVw6vVDviavQr1v/T5vPo5l1cr+/zgjx5eifpq2dnkXoWRvfp3Jq921d5vIhu3oc1eMK9qfTdauVfb7jW2p6quIUu5I69+5V79m89ZPfLqlmEcz3j1wiv1VTXZzebVj6ym+l9eUz0Mj/Jz9j2rmr8649XlflB4VWIe0mQ/qt2+PHTH1ONDVuc854YM/xul3SYr31JFA/dUP/jpfvQ/N9d/P/Pt1XhVaTW9m26Ipu3zrrPiIe9ZHjejehXbdLID5hWveFVU2uleSkzbbLVXmxuyufpAieGmwzqcPsZr8qrNvMrryZce/vOKV9KVOdkGTflh1PJq0GyzjfvUAyFe8Uo6r7LtlvKNm8IFbg4rLWK4wZv3fxO+PC50tB7+Hy485VN7jSNeSY+GMD+d1curePhX93l1oC+c3+vfHPthGaP1olf5EE6G7Rq9ktm82iOVeRXX7lUcyyucleXVl5HJq2E38+1epcPDyKubvApAUl/JFF6ldPAq3ehVox80nnkl03gVc6/6g9UZ9enxyrxSX/FKenh19qj69khqc6/C6r3q9cvwildy42aOw3jVe7fU1Q/2nL+KxjOvZFCvvs/dXtU6aq/CLXbPtuF4JVN41R68SimtfLfcVl/xal1eWS+6jPoqJfWV+opX6qvJvOo7I8Srgeorb7PqK+FVKfWVYcsr4RWvZMqdrR9culfm23klvCrPq8grXqmveLVgr+KRV/rBW72y4dRXMrpXqfMqBl6pr9RXPefb5R6v+m7Wz17ZFeorXl14koNkMfWVXcErXvXxyhEzn1eRV728CrzilQ04pVchu3cxr9RXvDLfzqv6vbLhatnzvFq0V9mV/pwdvK0f7NcWtDYvr3h1m1cx9yrYKbziFa+WXl+9Xxc0aGxu8spm45X028zhnvrqe6++vYvqjddDDuGW33Z0pHpdT6bh1Tq9cn5wRK++aBPO94Phrvt5HX7iFTsxhPxpIZTolfqKV3Z8mV41WX3VFHq/iZu90i/wSsb0KoclXtf4Xfbq6NteK8Sy9vvt9RWveCU92DhzH8AzXmU3Bs3WXAUbv8cg5NVKvZKxvQqfKqJDORaOmsAALF7xildLqq8+/eGxV7Ykr3ilH5zKq6/sXPJK7vcqXPTK9uaV8Ep9JfrB6r2S75Ea8/M4or6ymXk1hFe3r7+ylXklvOKV6Acr9OrrYRN4xSsZxiv7l1e8kmXsd/UVr9bjlfODvBJeTZ/bridjPNcDFK94VbtXridTk2XOD/Kq/vrKVl6PV8Ir9ZWor3jFq9G8cn6QV3Y8r3glvOKVXNyKsduMl7fbCa9s6zV4Jbyqwyup1augvuJV2f2gIasftPN5VYpX7ufFKzv+3s1tS05fX9nSvBL11SLYv6K+CoYtr2Rar8B2s1e9hm1r8/KKV7yauh80f8UrO55XCyiqxqyvpHSvZDCv7mBIfXXP5weN5zqaf16VUl8Jr3ilH5zaq6AfnMYr1xddaT/IqyH7wXD/t+BV788784pXMoBX6iteCa8W2w/akvpB4RWveHW+vrKxeSW84pUswavEK15V1A/a2Cv1SnilvpJS+kHhFa9kSqJ4xSteSRlHDq94tTqvrGfQD8otXgWb9W6vGl7xile8qsmrpodX9kWtXtmzvKqvvsqe6fqiJXpl/dVYXn3akj4WOKFXgVe8knu86v0tpO/1RbM3B5uuon0feMUrXkmBXpm/4hWvpJR9zyteLS+3XQ/ZsOWV8GrxXkmFWt3qlQEzLTa8Ul9Jw6u5Nrb6auAxyyte8YpXvBJerbIfvGN9O6/uvz6DTcgr6elV6HPQ8ep+rwKveGXf84pXwqt1eeX+gz0G6m3rGa64V63PD/JKeDXpmB3m2VK6V0aBfrDAfrDPSFVf8UoG8CrrZVwic5Lrt/OqTq/kto0dsk3Mq37bjlfCK17xile8knu9CtndjEJ8+2K901hhvPs75xs1GaUFemV2aglexRg7r1JcvVc9R+f196M3384rGcSrj3/y6tX+q8grXvGKV0V4tf8jXvGKV7xaolcppfdpq/1z2xTXXl/ddv2r0Jhv55Xc79Wlsxph8+ZV/PAqhZB4Ne71+njFK7nRq227Nyq+e9W+Vlu8un6Auv4Vr2Rir9q9Ubw6bDL1lUOIVwv1Kj5s4iamv15tNm0bUmvP3H4/L17xSm7sadIVXj1u4jam9t2rzb47bK3kdT1k+55Xy/Rqm3YxvHu1ffVqw6sR1jMIr+RUj9LPq6dtekhh8/p4yyteCa8W71Xk1V2js9+zzV/xSm706nmXHjuvtrwav77iFa/kNq/S85ZXU9RXPo/Dqwnz/74SD+gQuQkA", "force": "H4sIAMu51moC/82ce3hNR/fHD4KEJpK2CXF7UxQNJUHUpXL2jMvTBpG22p82ReQVfaUIEdcISc7JVW4SiQglGve6xCWqisSttJT0RwUV97vSkNSleL1nzene2WfePbPnIc/z++WfPMf5ZM2aNd9Zs2ZmbwaDwdDzTAPsvNIDG3R+gKub21KIy3XXt3fxj3o4+EoTXc5rwCUU9f5tVFPc/4ufkWaj+uOh1YVGLSzjUVtJhCvZE2rz7zdmvKrJBQ25KMRF398vxHn8Gmjjn8fOJS/FRQ+WhPpbumS4EOdsTn0hbvmnz7TjfD9WqB8l7kYhe/T4eo3wljTtnd1q8/en/hWnyUV7n/cV4aSBvxhrkqP9S7zwliaXvzJLKC43nq2XROy1984U4nrcyRPiLmztL+QfzbF0RbfLnOfHOkgi823Y4Dghrv0v84U4r8uNhDh63Jh5iJofLC4gMETMv4HJNcrR/jHzmtsHQjqg5wczb7inGmvSnkdwsJB/ovON1imLi+4cIcTR+mP2g4ozc/0Q5AoNQdKLxE9Uzyx7QYbJYnoZ52qsSe6Cf64QF71nm69I/qPXfRYX1OWBEEfr/mXteTXwElsXBPVn/+3GGtUzndfY+UCsPjAUBwnVidLvp1+IY+U/UY5eB1nzkl63mPVf5LgXqptE12nmeFD1C4tLzNlaszoQzC+inGF0RB+heU7V2SyOrrNZnBTR5YXsvey40XmNpav8k7XE9EflPxZH56GX1YFofhFdf0XnB52HmHmXqq9ets6m96Gi3NZzwzQ5w67lRpF9FG2POY+ufd2nJjl6f87SVZBlKgnFRZCj84Fo3mD59191FIOTpjQVirPXkoZiea3nUCF7dB3B8o/OQ8zxoPQiyrH8q+joUKP5Lz+kwf9JPhXVAT0vmfV9i0SxvEvloZddz0XrbNH9oChH61SUY67nWat8ReyJruf0uInqmTVutP6Y50PUOs1cj6hzC+Z65L9dzL/IzBdaL1l1tuj40vUBy55cb+wN9pO457Z/z9/rQV8hvXxQaVdk7PvYE3PzgaUe6hV8TIpePpLPWerJ9kMRev3zdC4H/Rj//Bxa6jtPlxvc+gYKeZKsy/XYZ49b+qZgng4qTIek2m3a4YTSND53eYhkTvHBn3bM0eVKx72ryzmfj5TaXPPDy/cXYL36qs6+D3Q50NXGnZ/ocjC+qyNG46RxS7HePLrW6gucUW8xl4N6svTZWOzntghP2t9cYuahxt7GPOcwHFonB+9q1ojLvfHZeHzgfKaFS5d45xHOTUJxyLsZhGPmScv6C1xMsZXjrdMZp8bhWi7p3HYL7dylvh4ThDjnr8PwRI80rn/QjzOnI7D3pBT82Z4xEq9eO+YUTjheu8CBf5OPJ3HbJTlm/TjcZH+yYq9Dh4L/sgn3Bu+d/RfOfzNRiZ8WV+I3RAKuW14Cl4M4n20/AfdwT+KOB6wzUe9/qdjj7Wfqjo/AAb2Tcf+P1km886H7y6frcuAfcBAXHmfImm/87U6UEmfmOZdlf34yKlKXg3lZFjZH0QtvXq48EaPLQX5pvsOky0FdInOgP945Q3EDE+kHj4N1S83x8poIl7/3uPFqpFmI+7NNAp5kmsvlYH8E3KxFydx+ADdnS5wuB/H7IDhWV6cwbmFOcdj0TbyuDi7+EY+b/jhXVwfJ9ZJw9/ppuvYCAlPw/eJ0bj8M3zhJV1vP1eWgDkuYmozdJlnjzLzHablEqpOXSOLHiwvEDzg5Lsz4WfTiNjYB+0xP0OWmP6rmePEDrnRvHH+dseQ/9w1m3OuCmR9n/1xjLc94fLEOn4M6bNXlWGKPl1/gnLU80qRrb2JKG0nmwB5zP2P0ltT2eNzPrgn4ytsmbn6GfLDudCKGucSzB/nAf00CDi1P5PoH+bnFD/H4pFMyl4P924bcOPw0MgG3X7BD4tUlXk/Muhzc74N/P942c7mSWq2Mao513pRvKCEc6JnHBd1rTdqtNd4aFxYHej4bFU84sMecR78GSgt7mjDUxcAxzzGTTFLha7G45Jd0vGjNSYm3n946NxrPbzUf135uQMz6xWLPNGA2/nZKLvYocEC8fH936Rzs6rCIy8F+obi/GYeH8DlDnWXG5gtM2PP8IuKfHrf9vYXcfsB4DJpoxlndcyx57Rq33ljfNgZHfp3F5S4kfSX9cigGH3Cex+Xg3qBX51g8MSWVy4FOgYPxhXFjcVB33iqr5njrQtU70bi0VxJpV4/bd2Qul4M6NnNVNcfSM3DgX2ZMGre/sA8YXmXSjR+cM4hwoCsYX712PXxNUlJVnBA3JzceT/g+lR+/wlPGpoNNhOONG+gK9HLMcS533KAu/ucfsdizawqXgzi7LYsldbYe1+19E94/J5HrH5wPpTeLxdtWxXPzEMxzsHes1MzPVzunEXvA8fwDzr0yWpeDfTfEReaYOkCzjCJcgH9fab1/LIbajjuPLOs+cFtLY3Q5t6IYPOb6bMKx5gfEGTiDfTSXg3wF+VmPI/c9dtUcUwd+O43hT2bhgf4x3HUQ9Fy1O5L0l8dF3zngC/7J9ph1pyU/gz2IS8i1I8x6Um43plsk115+4UxJzTHrlzMTCDd51CyuvaBxD40QPz2u0KmrBPHTaxfuPyAusj1m/WfhYJ3R6y/EZeCx2bi2eRrXHuhFzTHnh0UHwAVuDOf2w+svV2KveNRUwrHGDeqS3TNn6HLQX+DK/Kdw/ZM52R5z/l6XpIcZYULctjtjcGjfSXydJn7sG1D2MfZ+ONVmXYCzLPq+wlSIcYF3PKlfZC6ywmTDvTcsRfI62Re3a7HEhrs4YKUNN7rVcPTa64Ow9HytTT2Uc8/N5nnmtR5FaOzAEDx63S4b/wJ+9bThLh7uhx1ywrFnxR6bun1Z9542XLuO/fCVVmZsMOzGcLYst1vu8Q7h4KwPfnd46z2c8XYm9nu4G09dnqnY+zB0BuFCrg0iv9eUt8EbZ+bg2M934qaz5ircwchd5PtpH+0mv/8c5IgX/7MAxxV9Y3Pu+MaIWjg7bD0qq1eH/LtxvRN+Y8wahZPtVa5vQPzI+325xZ4kbWrYEj+dsRE/ObfExt70vG2oV/AQFLYtD8G434nxxAUjNuGr2bbntod+PoykypmopCQC+ebekz6tRHhP8814YoHtefaVseVoi48JTQ//EGWuGIhcOofg2Y8LSV2n5lyD3rJ8Xo3WXeqCOg/5AsUXROPeKdNxfZexNu262vviLZsvob3BP0nnZ8Siv44n4tSzXrjLFMnGXkZlJ5x33JXsxRduj0Wt3kzAme32otddPW3sncs+gJw6dcagwwpjNBpn2YNm9x0qTR7QG6k5F1NzfPZVX+x3xAOdaD0K/fYjIue6ny1rYnNvcL7Egejoy9/GIzuXt1HI9M7kXPfudTsbrjDiKHJNtexfjs1Hax7ckEauboK37UuQQMtqblLvH9AAnIRfO7YJgY83h9bFH0wLk2DNorknUWn41znFCidrUd2Pbp7F6I/L8wgHfgEH9xr0vYvMgV3g2q02aHLXAg+iurOtHPgfl2yH1XNfzRW1zMEbzpwj3K1nDxDNgX3gPlq9gHAwv/68XhvT90Iy52ZahPsd/J3JlW0pJ9y07IWEg7/zRtr2Rh85hJwHW9uFz0Pc62Ct+yjZv53PLlrGKZrLBfZeSOzBOKzZVw9r3VtBu008F+EPD10l4/7mX3W53IS1FZqc3C5wEBe3tla/lvnZ+ifrADjwr/YXVr8sf8zkoN1+Ofbk+z6Pnblciy1We/9YYq/JtTi+B5nL8rBU+Rfpx8+Z9TX7AVzRwcW47slKwnX9uaEmB/kRuFL3m5qc3G7S0Z2E+yGqinDf5tVhcv3cv1L6Ydz1CpOD/srx23fGtr9yfbDkuyLUZ2Q2vlVm1d1NP5hHPpIWB/qT7bmMcbCZb7IOZO744odIyz85LuFvLiecrJcCHwdNDuyBDhyX/psbv+ZjlhJuwI/WOPdfqh0XsPf99VzFPxb3fPsGNPJRjtIuzSk68PmGcH1znhDuQKwzk4N2I3xrke+buThyucH9reP/eICjtk7/5nbWq6vLgX8y9+SUA5db950d+b5x89qaHIybWi8++Q24/snc8EB7brtQJ8Bn9+l2zHZBL/b4EYlz1Kv23PHYbnxOuOGd7DR1L/cjFd1Hsu553Oih9wjXuLEjU89qruw0295kh2xc3uMp4UI7PEG8fvRLsNeMs8yd9clFm1bMx2URVn2uvHIPsfy7VZSp6CDz3mPEsgec43PrPE+PcmK2e6nlPPyo2BrnNz6vxRwPNZd9qRGTa+SYhTu/a+3vTf+GzDgD91P7BpqcnIcIFzdPiR+rXZrzu/GMGb/5P2Qo8eNx6jizOHnc5HwQ6nlakwtsNo/oRc4vuOp3pKUrsAdxke3djqilqT/XTzcTbuIBa9wut66rya3am0o4OR/kTKi0aVd+XgXsge7l/o448RCx7AEnz99eMXc1OdDB06ZZSn7+fgi7H0Snf+fnel8+RywuIyQTL0i12pnSvjZmtQv2ZK7/sdqaeoF+qLlrjncQj5PHI3jrXSHuz0l2zDwJnJzX7u6v0rQnx6WkpEqX6+qdrrRbL/o2l5P1d7R3JZeT8xorLnI+cH5qbfdk1wdMe6C/540MXE6Oizx/Nwy7inh6yXd6hcvJ43Ei3JpfNt/9X+a4QT6QuZkpJ5gc9HfaWy6anLpdsHe282tce9AP4MoPuRHusxXlXF0lnbeuV77pZ7j98HvqoNlfOZ8W78gi9mQu3uewDZebZX2vZdP8eMLdWV1bk5PtQb0G7cr5PvPUEW5c5HGjOXU/6lclM+0p+9+HkSjof+Yq9ta2PcDl5Dypx+nZg7jU7ZeqcCen7tLkZhxOtrHH6gdwg26m6LYL3JayNIXrNXufEMezd3hqmjLfuo/foTluwNlfSlHyJN2uzKExKWTc5DzEaxc4OQ91b6ndD9mevH6EZh/VXLdAf1VHzLhRkHW/MKLnNk3uijkHDes0GwcUW/26NKdQsx9Lq7agRQWTcXTH7eT7LvkLNDmnjw+jowmjsJ3LevK9+f4MTa730SuopONADLqGzw8eLdDsb2raJWTe0xr3cEog3yfPXqvJ5YWloTXLr6KDUTGanDwvF635BN1aGY4+2e9Cvk/Bn2vagzOoprMaoov4AFmfFnfx0rQHZxxh/TdK2fFXCFe6JVRicXD+J5+PwvmuVrtwJgPnR108mhGuiYH5/Dh5Lg7Ow8g9U8A8SSvOwCUE7JKK5ncg3PY7K5gc9EO2t7pNqWY/gIN+XN/RhnDt651n+WcAe+r7NxEu6OBPLP8McA/DssfiLnRayGxXfaYN93lCHPX/QqjvNeBcTX1+z7IH2hKxpz4/pDm1PfU5Hq0DNac+98jfsUCIo98fVMdZzQ0zXGPag/NSlj01d7lxjhBnMJQo3/XYvI/JyWe0RAd9jjO5K/6OSP18BIsLGdQUqe/tWVyo3UAlb/B0VT9kApNTxxnO42Vt8biKoemINb5qLvzJLCQyvgbDRIWj84G6H6+7Vt/lPNrxPVPP6rsh3vjCc0fK+Mav5ulesQfPlbK4OQ3bCY0b3JHY3E8zuJFrJYXjvTeh5uj3TdXcv1fsZeY19XsTsla0/FOPG49jtct7z0ttj/dem5rjxWVN29sKJz2qMor0o8LJibUe2eR7+r0sNae2R3OsOMO9M8s/9XPkPE7tH+99PzXHm5c2cTm/Q4i78XSJ0PrG04HaP948sokzla/U+UB0XVVzQU38hbgLzULZnMo/3rxU95fHqdvlvaeu5uA5BRGO1tXL2oPnLdRcrHesJke/d8fi6PxsyFqozVHr9IXkpZp1kyhH50kWB8+BinDwnJaaW3kuTZOjf1gc/T6xmoP6WfGPen9f7Z+6LjbsCmTaexGOnpdqjjd/7XPHvBRH5zU1t21f/er18uI7zPkGzzuIrIPyXkZrH2BTxzp+xOTU64IoZ+pkFuLgeQulfqH+Pzc1t6z7d8z6VM01fn9/df1HvZeq5sa9Un12Cc/lsrjRX1WfTdPrlpoLHl59Z0SPh5qb2a25wtHvr6q5d1w6KFwFnsDk7o16F7P0oh7fZjF+WEQvpxwCqv37Ryum/tQcPH/FstfS7xOFg+cOWdwNaSBm1Vdyf/8DEccaw/hSAAA="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0045_20261007_095346/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0045_20261007_095346/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e8f3d8b837893604d000eab1bead72bb68fa993d --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0045_20261007_095346/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0045_20261007_095346", "meta": {"split": "held-out"}, "act": [10, 655], "T": 656, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0046_20261007_095417/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0046_20261007_095417/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..18b653d4ef5259123239c583d6be83962ab9b464 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0046_20261007_095417/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0046_20261007_095417", "meta": {"split": "train"}, "act": [13, 593], "T": 594, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0047_20261007_095445/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0047_20261007_095445/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..cb8d082e8b054b87796b33630f223865cad8e016 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0047_20261007_095445/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0047_20261007_095445", "meta": {"split": "train"}, "act": [12, 602], "T": 603, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0048_20261007_095516/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0048_20261007_095516/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..dbf60716a514cb0024521b4590d68a9f6e8ec3ac --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0048_20261007_095516/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0048_20261007_095516", "meta": {"split": "train"}, "act": [13, 573], "T": 574, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0049_20261007_095545/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0049_20261007_095545/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..2d0219234fab40a74cad6fad2dc3c057111a0dbe --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0049_20261007_095545/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0049_20261007_095545", "meta": {"split": "train"}, "act": [11, 659], "T": 660, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0050_20261007_095616/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0050_20261007_095616/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e8afe0998b389265bc6d100cff08cdcbbdb52398 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0050_20261007_095616/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0050_20261007_095616", "meta": {"split": "train"}, "act": [13, 611], "T": 612, "maps": "H4sIAM+51moC/+3d21LjypqoUWWmbCio4zys7ot9saOj3/8ZGwxYabBBknXIlMeIFWtSVRhTxvrqT1mSm6YSITTh8EFsmnT4/+fffPnvyx80MTx/Wjj8KtzH2IaQnn+xSzE9/eHhD54+PPxv93zDNoa7kHap2bIYswfx5A/OP87jfjoNcKZXYXivWr3qnRW9gsV7FfVq3Hw1+qcDTNereKO9mvvnYsyC63uV0i5mvUpvvYovvQqvvYppt+2tbOB6cIJqAdNI4alXTw4z1VOv9iG22/4bn/YqfNWWkeURLGCiXsW8V2/j6ITTVbf/KkUPOXBVtLI5aPp94yH7QK+AiYpydu0Wr1zp5Z8SrQyBJaav9wEK/frz+imtXgGL9ioMm60yrUcSKKBXfcqlV8DSvQp6BZivANadr072tzueAVi3V83nveqO6dIrYOX14Kez1WuvDvOV40WBUnvVvOuV+QqoYD2oV8C6vRp6fLteAUv2asD5OCHvVdArYNFencxUg3plvgKW7FUzqldeHwSKn6+arleNXgGL9+qzX3/RK+tBYNH14EB50sxXQOHzlV4B6/eq33xl/xVQS6+6+cr1kAG9Apjq/XFavQJWm6+GnT9ovgKq6pX38wJK7dWH8wf1CliyV2Ovz6BXwFrzVU+uzwDUsB7UK2C1Xl2I0Ref4nrIwPq96rce1CtArwCsB4Et9Grw9UW9vzOgVwB6BeiVxxPQK4CJrn+lV8C6verB+TjAKr0Kw2ar10+zHgT0CmDCXjXZ/naAlXrVt0PH83EAyp6vXntlygJqma/0Cii/V0PnMYC51oNKBKwh9O6VyQmob74CKLNX4cKfAZQ6XwHUMl8F79cMlNurMOL9Ul85vh3QK4DpemXVCOgVwIS9cj1kYI1eNXoF1NSrgccznPTKyhCopVfmK+Aq4eIvPu1VM6hX3h8HWH6+GldDvQKK7lXQK2CtXo1fbeoVsN581f/9B/UKqKlXB0mvgIV6Fa7slfkKqGW+crwosNp81e/4q+P7O5uvgDV6FXr3qruVXgEV9Mp8BSzaq6HnD4a8V8Hrg8DC85VeATXOVz1un68H9QpYtFcD6RWwSq9GCFnl9AoovFeNXgGrrgeHHt9ufzuwdK9cvx2oZT0Y+q8VXa8PKGI92HMt6Ph2YLX5ajirQKCKXrk+A1BRr7qvpVdAJb1yPAOwWq96ne9svgJW7pXrIQNb7pX5CtArgCnOH3T9dqCSXgXXkwHWXQ/2ZL4CKupVY74CVu2V9SBQU68GXv9KrwC9Apjwesh6BegVwOfrwZ7N0itgtV7l12/v3yvX6wPWna/6rwf1CtArAL0Cau5VGPVeN8f3mwBYpVeuJwOU3atGr4AN96rRK8B8BdCzV/2OF/X+zkBNvTpwPg5Qy3ylV8BivbrUrk975fqiwMq96tmsvFf2XwGrrAd7OvYq6BVQdq+abL4CWG09qFfAFnulWoBeAXzs1YjzcV575XoyQC29AtArgD69crwosJFeha5Xrs8ALN+r0H0c9QrQK4BFe9XoFbBmr859/PmMpVdAJfOV67cDK/Tq3VzVJz96BRTRK8dfARvpldcHgQrnq9b1RYHFe3WhXdaDwGZ6Zb4CluxVGNMrx18B5ffq/SnR3n8QWLBXYchs1egVoFcA/deDw84f1CtgvV71pFdAjb3y+iCgVwBT9Mrx7UAdvfJ+XoBeAVgPAhvp1cD3x2my618BlNwr1+sDVl8P9l4b2n8FFNCrXiXSK8B8BaBXgF4BrNQr+68A8xWAXgF6dalWricDrNGrERzfDlQwX4XQzVdJr4DSe/UqeSSBgnuVf4rrMwB6BTBBrwCK6FVPr/vbvT4IFD9fuR4yoFcAX/Qqvx6yXgG19KrXa4R6BdTSK+cPAiv1aqD8JnoFrNKr/tdn0Ctg+V6NmLXsvwJW79W4JSGAXgF6dfV60OuDwAq9Gne+s/kKqKJXrocM6BXA4F6FzxaCx14FvQIW7VWPSp3t1eEXSa+ANearXmtB8xVQ1Hrwi2bpFVD+fPV6I8czAHoFMP168OVrWQ8Ca/WqV7O6468AFuvViLWh40UBvQL4oldZp2L/9SDAsr0adf320fvbL+zdDwtf8OF8b/PfDRe/U6CIXo24/tWw83EuFUGvAL3SK7j5XjXjehVT9+EuxEvf0/yPQDj3e/naVq/g1nuVsl7dpRUfgc969Tw+hrdeBb0CvdIrYJlejTz+qu0a1T6+/66WPpIr3w0XYrc6jbFbDL50K5xNF6BXegWs36uY9+r72r2KJ72KxzViisfVoF5BYb0adD3k971Ko3v1Y+1epeyRiM+OveraFbIXMaVqyOj65q77AafuDPngPC4WnK+6rzVkKz7p1c/lenVyvtFxdkqpO7wipq5XbfZxsFld6a5LVPdqS9Ar9Eqv9Ipt92r85j9m/1X8pFfzHd+Q9Sp1W0rbpqxX6WyvuPZxv8uW3N3DrVcsOV+N3d++Tq+y7zGfo3ZZr9qTXnmXssked71irl71/mczjNzfnj7pVXYs6dR7trv9503brUp2d+1xB3Bq2+PW9DR26dXcvbLQZo1exbG9Sp/0aurnctafp5nq7X72990lndNud7z753Vi8izRK7ayHhz5/oMhdfeQfry735V71eqVXlF8r0bIe5Wu61VYplddVU/Wg02+Hux6lZ57lX8P6jX+ibY/16ugV4zv1YDrIYdrehXzXv3svpXw8oez9eFk/1Xst/9Kr673/Kjvzs1U3fnkUGyvwoVexbl71X3Ydq8P7vZJr/SKGp5GY2+avUVhur5XsetV/gp3O/Fi4aRXqevV5fnKenDyfxhjfmqTWrF8r5pr91+99WrX9SoU2CvvDKtXlLMeHN6r8Narga8PZt/Eu17FxXqV9EqvuKleNRX3ynylV+jVhL1Ks/2F9UqvqPhpNP76DMOPby+sV7ukV7MKekVB89Xw1wcv9KrpetXM26vsfOfTXiWvD87dq6BXbGK+0iu9goG96j9fjXt9MK7Uq6BXesUNrgebrFf1zFenf/nsen1dr1L65Ph2+6/GP1nyc5zzt/FQLLbSq51ebeaJplestx48PX+wueL49vi9ufj64G6R9WDeq6hX5isq61X4ul/v56vxvfqxXK++nq/0Sq+oo1cj3n+we7ZVMV/pVSG9inrFovOVXgF19arRK6CWXnVfa5JeNWu/PqhXsOqOg/O/mLxXQ/ZDnBzPUHSvnD8Iy09Q4ctjiZedr/QKmKZXI6a3ge/vvH6vQterdL5XSa+gkvmq30Y/7tXoUE2vvN8E6NX6+9v1CgoTPu3VlMdfDdt/pVfAp0PTsF71+uojexXboo5n0CsorVefH88Qbq1X5iuotldNs2ivmlV6deH6DHoF5fu0V3OePxirOL5dr0CvTteDSa+Agb1a8voM+XpQr4Bre9XD6OszlNCroFdQda9C93Hsf/7g8Ou3F9Yr+9vhJuarY6/S2Pfz0iug6F6Zr4DFe2V/O1CFql8fNF9BzfHRK70C89XYXu31CphqvmrmPb5drwC90iu4vcRZDwJbn6+yBjwW26uoV7C9XoVrzsfJehVW6lXUK9h6r0bfqrT56lKvol5BFb3qVaIwba9Wmq+iXs0rXnrOjHz+wIheTb7/Sq9u4LmlV6zWq2bSXjV6tfnnVsgaFfSKiXrV7ybXv99EAb2y/8p8hV5V06ukV3pFvTP78JvoFXpF4fPV8YPt9CrplV5R23w1ZH97MF8BK85XA3uV9ApYa77qf6t28FpSr4AFexX0CiigV8F8pVewuV5NfP7gkr2KegW30quQ/6fG+UqvYBO9yj4OvearKXsVrAeBaXsV6u/V23ftfGeouVe9Pz3Uu7+9yXrVhSg+R0qvYFu9aubqVdArYLZeNZX3KoWsV1GvYKuJO/ZqsvNxVuhVPO1V1CuoaL7qfz2Z6XsV19jfbj0IelVwr5rsesiN68nAzfSqqXA96HgG0Kt+vQqr9+pkvrq0vz3qFVgPrt+rS++Po1ew5V61I64verlXq7+fV9Ir2Hivkl4Ba/Sq903C2F41Ja0HL7/fhF7Bxnr1unHrFVBy4sLY/VdF9cp6ECqer3rvvwobOf7K8Qyw9V5dcT3kEo5nOH+8aDRfQVW9GrYefB1MquuV9SDoVXW9sr8d9Mp6EFiwV+E2epXvb9cr0Ktie5XCaa+iXsEW14PNBnsV9Qo2OV/N3KvdKtdv1yuoqVc93y81/8M4ZD6rab4KegW19yrkfxhm6tVOr4DJejX99WT0ChjXq6ZHrxq9Airp1bTXF12uV41ewW306nVEqff1wTHzVfQ0gVp7Nfv1GRbqVdPoFWygVzdx/JVewfZ71Yw6Kl6vAL3SK9CryzcJ1oNAJfPV6/tNmK+AtXrV+yZdryqcrxq9Ar3SK2BDvWr0CliyV032fqmDmK+AtearGs931iu4sV5VfL7zSa/ipV4FvYLCe9X7fOeqz8fp0yvzFeiVXgFL96rZQK+iXkHFvep9k2C+AvTKfAXo1ZBeeX0Q9Ko57VVegrxXi/zNL/Uq6hVU0Kv5r4esV4Be6RXo1eVejTl/sNRehXe9sr8dzFd6BeiVXoFefX0rvQL0Sq+AK8uTX/9qI8df6RVsN3JhVOvq6ZXjGUCv9ApYdD14A68P6hVspleN+Qqoolcnm/KQ2+sVMFWvxlxfVK8AvdIrQK/0Cm6pV1u7Xp9egV7pFaBXegW33auezcp7lfQKWKtXA29ifztQ+HpQrwC90itAr/QKttmroFd6BXqlV4Be6RXo1ZmbHK9/5XhRYJVe9b+JXgF6pVeAXukVbK1XofdNXt5/0P52YI1ehWbI+c6HXjnfGahlPahXgF7pFTBZrw7x0StglV6NuL6oXgF6pVfAFL0Krt8O6JVeAXqlV7DNXvW7PoNeAeYrvQIm6pXXB4G1ehW6j0P/Ww0vhF4B1xYrDSrR+Pkqu5/24XDzs3+4D0v8vfUKil7xheyDblsN+UATh37NOXo13yMQw7FLberuMqauV0mvoIhexWymiq8LwKf/u+8+p9+oNUGvvl3u1d3Uk1TM5sGuV7uU9Sqd9EqhYJ1YhTNNePq9Xdv16jFmV+H7Yqy4ZlP+0Kv83toletXG42Owu8t61aZjy1PSK1hFPN2nvet++37f7Wz/1R57tTtblxl69XDaqDl71c1OT71KXa+arFft8VOeexU8c2BtKfvg+8Nre57+7++7Y6+yfUftV71KQ+8/+4q7b4v1KqRjf9qUzVf75mQ9qFdQbK9+PHa9+qfr1d2SvWrW6NVx/9W++7voVfVP7XMvKzXZkc1MtDWdvHo173212Qd//XoNztOG+p+H44EJD91nf/0yXRh7/5/36n7iRzgeH+OncmX7qU6OZ8h3yXuWV6cN5/9J9ZOcYgs62ZqyQsSvijZhr353vfqvx2OvvjcDxpwpe5VW6FXQq63YhbNzf75FhSU36+MvQljgnhf8i+Uv8t+fXWFN92pV3qu//7z+YMOhV2/7m3/O2at8f/shSvne/e7j+4lfnstfcQgn7TqTtPzVRMuJGnXrghS/d0+5XZpq6w1nt80Y7rrP6HZBbK1X2VzRPJzdZZSmeqC7PKSsV0/+89jsXh74+z8/B2Tjip9D+/zDbc/36i7O9nBf6FU+UsWw4D/JzNurH+00vcpWQSE73CWG7lnbxn+7GWS/T8fv4emXEz2PwsmeuXDm45Dl8eV7DtNuQPl22jzO0ats4989nuvV7rlXL49vuP/713EO+9YO+NKDe3WvV8yiWxfs06/sVZz2mo0o61XqNsgUuzvbtf/v+ORO8XjoUEoxtXGiXF3oVcx61R1KGdNUT+H8299lu7V/dNts7H67va5X6Uyvnvzz18l8dfeyL+vhn1/HO3zYNV+8BnjFjyEdepXvv9qf3ekwca9C92PO0pX/9uk/Yrb+mnv1rf2d7WW45jT6fK9N6p62bfrWPWt3/79LVzpu1u3Tp+/iNM/eeLIS7X7RZk/qrFftHL3aZ7uJfq7Xq5ddZ+nQq13Wq3auXt29+5tls1bb6BXT9Kp7Ln2boVe7NuvV/n+yXh0366de7fZpmmdvyhp1oVfP32jI9zTFiXt1l70a9nt/uj2/LYzn6lWb9arNevXy+4e9aXP16jAv51d3yPbjpfl61QzrFXWvB9s/7TTz1clTN5uv7h66rXT/v13QHn687Tl7PmxmqruOF3rVnUx2+CDka9g48Qb0rSt08+fu3DZ75eYbL/Tqz7leffvr53G++pZm7NXH+TZN9mX79CpkjQp6VbnY7UO4S/tsz8Lv7Lk01dgeu39ad/fp+9vv7fb/++vt47b5/Wt37NXh3Lc5exW7Xp27usGkLvTqmsPIL0alzY4GfT5e9O1x/6+H15kqHY5neF2m3X9551M+IKHn1eMnWA+G10a9nQ0erAHn+DGu45+TNvya4y6yF8Aef3QjwO6//2S7dv7NtrW7bzM8uvlhTp+kKTbTPrXvssf3+xxX1szLt+++8ceHt+8/Nn+9nfscD4cZvN5k+vNxStieGr3aXq+yp+p/8h0s+eGE8/TqV7bf5j67t4e8V/sZehV69mpq2fR64XiGCXvVdttkt58/NMejVMLLXu+Q5Sic+XLBls2lLepy+sOINwsosVfZrovvv7Ot60f+Sf9m+152tfcq+2Hlr+nfzTGx6BU326sfc/fqz6Ve/ZPNV+0cvUrxfLtmnprzg8lm71X88KA/r4ra/fEpFLpjNl536KV3X0mvGNmri2/GdPUTKevVv4X06t9N9ersYrjZz92rj/d72IvTnu3V4cHJDzw5BEyv0KsevXrUq6t7lf/+J71qul6Zr9CrAnsVs17FtXo1x92O7VVjPchce0Hm23/1z0mvvs/x90h6VWCvzFdUIjvVJq3Xq6hXZfcKyrArYb66wV6Vtf/q/W2O+9vh4hYVjk/w46lPs/8Td9KrO726xV59vPlk50+y9V6937qW69XferWZXp0cEtv1Kh17FUJ6P1/Fs18JCupVu2yv4q31qimrV7t+vQqiRf8tajnZ9VrSAr2KZfQq3OJ81egVtdOrW5mvwuD5Curp1ePMvXosoldpo726cP6g+Yq6XdzfPkuvkl4t1KtwVa+8Pohe6dXKvTr+uJ/Tld4v//QKvSqlV01ZvZr7+ParexX0Cr0yX601XzXmK/RKr4rvVZv16vj2sHqFXo3r1Sm9mqdXz13SK2pWxvEMS/Sq0au8V82nvToez+BALPTKfHW3fK8Oj8D+eCL9oF7JFnqlVwv2KugVejVwky2hV+k214OjegV6pVezz1dJr9ig7PXBvxbuVbY9/Phhvpp7vmqyXn31+qBeoVd6teZ8pVdsaj2oVwvOV0uej/NxPTi0V/a3c4u9inpVRK+CXqFXelVMr6JeoVdb6FW8tV6lq3sFetX99sP8vWr0ynzFlnq1N19tpVfR8QzcVK8eNtar25qv4rT720GvVurVLexvj18d365X6JVe6RXolfXgtb16v/8q6hU19+rPar3y+mCpvfLCIOYrvVqjV435itpl5zv/2fR60PEMJ71q9ArrQb3SK9ArvRrSq/RVr6wHsR7Uq1J69dX+K/MVeqVX5iuwHtSrq3sV8vvVK8xXelVIr6JeoVd6tZ35yvk46JVe1TJfeT96qu7Vb73adK+aEetBvcJ8pVe19ArMV2v0qtGrk/t1PWT0ynxVTa+GzFd6hV7p1ZK9Clccz6BX6JVeLTpfDetV0Cv0Sq9K6FXQK/RKr7bUq0av0Cu9auo5XlSv0Ksb6lUop1dNM2x/e/aBXqFX5qu1ehVOd081Z9eD5ivK7tW+iF79cwO92q1+/NVXvbo0JYJe3Vqv9nX1Corr1S+9ml6p+6969Mr5g5ivzvfqu14t26ugV5ivzFeVrAeD9SB6pVf9erWrpFfChV59sR7M7jrdT78uK2E9uFv9eFGvD6JX5fbq0nw186ZYTq/C6fk49l+hV/X16mbmK71Cr67oVdKrBXsVDo/ArutV+KxXoXH9dvSq1/52vZqtV/uevWr0Cr26OF/p1TLrweN89cXxojqFXulVMb06s/8qnTxarn+FXt1cr5pienV6v3qFXo3u1WaPZ8j/8iX1qtEr6u7VT/PVrOvBgnoVvu4V6FV5vVrueNHSe+V4BvRKrwrpVRjSq0av0Cu9WqpXcXivkvmKinr1S69uer461yuoY776Nse93WCvzt9v+a8Pgl7p1Xq9Cs2Q468+jIegV3q1Rq+CXrGtXm13/1Va9PirlXoVzVeYr7Y3X+mVXqFXw3q15Pk4qYD1YLtCr5rTXoUBvZIt9OomepX95dc9/uqlk+H4XfXqlTELvVp3PZgKOH9wtflqQK8cf4Ve3VqvyloPtnqFXlW8HryV+erleshZrxzfjl7pVXHzVUhX98r+K/Rq0fVgc6FXGz1e9MLxDHqFXtXQq7JeH2zX7lWwHkSvhvSqvYleZfNVXHd/u16hV9PMVzdxvOhKvQofe9X7+HbQK71aoVdh6HwFenV+PahXM/YqXdcr7aLEXv26wf1X7ZLHM6zbq9P1YKNX1Odut3srVvy1z19l33Cv2hvoVfjYq8P9NsN6FfSKQnv1+6RX93PcXRnrwRvrVcrmq+xD8xW19yot2avszr7nvfr7Jno1QwX0io2773qV/pz06m6WXqUCerXW9fpCFpEUlu7Vy/k46aU+PXoV9IryetVmvbpbtFe/s7dqeTjpVbbrLN1N340lexUK6FU89ur5RcG3XoXPehX1itJ79ddJr/Zz3F3q7uDhpFeP2Sf9lfUqbrRXce5effj4pVevhzGE073wH3sVrQe5+V7F1O1nfvjZ3UPKc9X8ydaAcT99N1br1YWPz0Z18l4d1nixOdurl5C2Z27u9UH06tCrH5d69ftuhl5ls02Yu1fhfK++3osdwvs/GdSKC73Kvnjbfdn4YZx636tovkKvXrTfvh/vIbb32S72u19Zr9Ju670KS/Zql/Uqy1PSK2rrVVy4V49Zrx6zAes+Wyjq1ZS9Cscfql6hVxcalX98PCJ1d/9wHKNS+yfr1bcfWaNSO9F3Ec/vv4qnG3a4sPnrFRTgLrVvT9P4Z9/m7zg1S6+6+Wp/d+xV2+YvCd7fp0+3u3EJuXDc5rvX6cKlbvTt0/sd1GN6Nf6hfl+gd4/AsVfh3f6rz3sVJYtSxO4Qgz+79vyZZleuxPLN4DjapF13dnWb8l1WzT5OMeRc7lWYulef5WvJLT3v1YcaHR6B/ctRC+HsfLU76VXQK4os1tsHP9r8X+U0Va9OBoyYjVptF437vI5phq0jXJh5wtleVboOujBfvT6eT3/T50bFk16F7rNje6FXQa8o0EOaZO9Nv1KE2F16KoaTt19Y8JJU27ow3afrwcPxV222HvwwXyW9oiJ3cfbp4nSy6XbhhFENcRXMi72KZx+tkCUoZkl7+RfC++HA8NjEvi+Q6dW7x+PThy28+/fC20mAXhXRqy/+tOdpNvlKMOobZS0nFt3brFd6BRubr774ElY1g2qWHfQV9YoNFaSQ+eqrr2IrGjN99b/sghEWPu1V0KtiEhfMV6BXMwxNo2ahYD0IeqVXoFd6NUXaht9Er9Crq3uVPJLL9CrpFXqlV2v0qvfxV/a3g15V1qugV+iVXpW+HtQrmHAj1KuFemX/FVy9EdqKzFdgvtIr+9vh663E/qu5exX63qT7WVgPgvmq8MSZr0Cv5ljk9b/+lV6BXtXSqxHrQb2C6zbWoFdfXLT92vnK/iswX9XXK/MVXLsI0qt514ON+Qqm2UitB8embUSvzFdgPVh4r54d3gk6upI76JVegV4xba+sB+GqLdD+q9l7FQ69cr4zXM5Q/17ZimbtVQjmK9CriuarRq9Ar/QK9Aq9Ar26uV41egWT9KpxPeR5ezX680Gv9GqqXg29SXC8KFgPFj9fBfMVmK9q6JX97TBl2PRqgV45vh30Sq9Ar5ioV409VzDlRqhXM5UndP9pVQv0quT5KjTZ+c7Wg2A9WEevGr0CvSq5VyefplegV3oFt7QR2oqu61XocRP7r0Cv6ulVo1egV1WsB/UK9GrdXvV7jdD5gzDRFmh/+yLz1YH3o4ere+XI63nnK70CvTJfgV4xU+L0CvRqlfiMuIn97TBBsphlPZjt4nK8KOhVLfOV46/AerDoXgW9Ar2qYz0Ysl6lQ6880qBXZfbq43zlkQa9KrRXr5/mfBzQK70CmLZXjV4Bq/ZqxE0c3w4TLAlZpFf2t4NeVdMr8xXoVdm9enn/QetB0Kvie9U9vnoFelXBfGU9CHqlV6BXfNUr7zcBerXNXrk+A+jVeo+UXoFe1TRf9buJ4xlAr2rqVaNXoFcV9Or4gV6BXlXRK/MV6JX5CvSKqXoV9Ar0ynoQ9IoZ5iu9Ar1a65GyHgS90itAryZ9pIJewRpboa1obK+GXJ9Br0Cv1lwP6hXo1bbmq0avQK/q6FUwX4Fe6RXc3CaoV2N6Zf8VUFWv+tzG+3kBqwRrTK+cjwPoFcC068GOXgGr9WrI64ON98cB9ApudCtk8l55f2fQqzUequt6Zf8V6JVegV6hV7DSFnZh89KreXvl+HbQK70CvaJ3r3rdRK9gzFalV3oFdc9X6BXolV45fxD0qqpema+g34YV3v8C8xWYr/RKr4Ay6z/oJq3rtwPVzFd6xbz+D7zd1gXANAsA", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0051_20261007_095646/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0051_20261007_095646/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d570004074df5f812cbb1195f160cd6a609fe097 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0051_20261007_095646/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0051_20261007_095646", "meta": {"split": "train"}, "act": [18, 591], "T": 592, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0052_20261007_095716/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0052_20261007_095716/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..4bbe3dda14fbc56fde49465fc38dc347d349f519 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0052_20261007_095716/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0052_20261007_095716", "meta": {"split": "train"}, "act": [13, 622], "T": 623, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0053_20261007_095748/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0053_20261007_095748/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..31c55c3ec21e27e17eee97f0bc97d7c9b0db9faf --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0053_20261007_095748/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0053_20261007_095748", "meta": {"split": "train"}, "act": [15, 611], "T": 612, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0054_20261007_095820/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0054_20261007_095820/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..438542c8469a82ac25cfef93d17a03bc9e24a6c4 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0054_20261007_095820/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0054_20261007_095820", "meta": {"split": "train"}, "act": [11, 591], "T": 592, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0055_20261007_095850/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0055_20261007_095850/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..394a4a20e1fff474c1ddf5b69251494a4da03ef1 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0055_20261007_095850/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0055_20261007_095850", "meta": {"split": "train"}, "act": [11, 555], "T": 556, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0056_20261007_095922/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0056_20261007_095922/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..13f27a0f52a10d8b4931deb826f9c7e6b7a6761e --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0056_20261007_095922/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0056_20261007_095922", "meta": {"split": "train"}, "act": [10, 545], "T": 546, "maps": "H4sIANO51moC/+3d63KkOpaGYZYE5MnHOuyqvTsmYmZ6fvT9X+HYVWW0sCENiSR0eL/o6MiKbTsNiR6vJQQ0TXURaYy8vTTGWPP75UteXsvbP4Yvev2X+mbJcIsDfa3fbyaEzHglyivBK39eQRYhCXll8IoQEtUrc9Wrpl6vCCEErwghBK8IIXhFCCFJkyWx3ogQQvCKEFJuDF4RQtIpr+QaJ2b8lbcv2QArQkhcr17+LbcZxPp2QohHuJZ5RX1FCNmdKbwihBRTX039G7IIIUl6JXhFCMnDK8ErQgheEULI5vj0ihASofD4dZcrY9Strsxk0VBgAeFtvSghZCev6ikzPs6337helBCCVxl5BW+E0A/iFSEErzxeP4hXhNAP4hUhKSiRzJFOfaW3j/l2QhL2SqivJj8GtCJENx9Tr5Pwqtr6qvCtJYT6Cq8IwSu8iuuVbJy6AjiCV3iFV4TgFV6xnoGQUKzE84r1onhFSKIQ4tWwfXhFCF5RXxFC8AqvCInBRCJHOvPteEXICq5Y345XhGQCFl4V4RUh1dVaeLWrV4SQxLVk/gqvCMErvCKE4FUgr5i/IuRTMfAqb69aDmJConslv+nkehzqK0Jyqa+4fzteEZJ8fVW1V8xfEYJXeEVIQT0g/WAyXtEPEuIbOLwK5xUhJGW98MptH/0gIYnTh1d4RQhe4RUhpTV0O0+cfPTK1OlVg1eE5FBg4RX1FSGray28witC8OoaV3jlwSt4IwSvdvNq5V0q8IqQPbyq834yeEVI+l6ZX2BZ7n+10StCSBSvzNgrw/XOeEUIXqXtlSivZNX20gwSglcRvWrwihC8witCCF759kpvpGAQIXiVtleEkLy8qnf91aYdybFECPXVPv0g60UJwasy6yvBK0LwKp5XQj9ICF5l5dWN92egviIEr3LxivqKELyiHySE4NUHr36vbwcrQvAKrwgheOXRK/pBQvLxSn7P4eAVXhGSS31V8/1FCSHUV+V7RX1FyB71FesZ8IoQvErfK9aLEoJXeEUIwSufXjF/RQhe4RUhBK/wihC8wiu8IgSvUvZq047kWCJkB69Y345XhOAVXhFC8MqHV6IelrqeH7wiBK/i1le3rxclhOzhVc3nB/GKELxK36sGrwjBq4y8IoRk49XbjHO181eEELzKor7auiM5lgjBK7wihOAVXhGCV1l6xfwVIVl59TaKOT9ICMGrMuurFusIwavYXrFelBC8witCiGeveF4qXhFCfYVXhJAgXrGegRCCV3hFCMErvCKkJq8MXhFCqK/wihBCffX55n14MecV5wMJob7aefMErwjBK+orQghe4RUhBK8+90p9PX4Rgle5eGU4OAhJbUALXg174mavhHaSkH28Kul65zXnB6Xwq7sJwatyvHr3ZYhFSHIDevH9ZLLsEq979a6no74ipBSvCqyv8IoQ6qt8+8EN78SxRMgOXnE9Dl4Rglepe8X9kAnBqxy82nT/dmwjJKpXtd9PhvtfEZJBAfLBqzrXX+EVIXiFV4QQ3/1g7euvhPODhGToVT3z7e/B2bLheEUIXsX0ivqKELzCK0IIXnnuB/VOWLkX8IoQvMIrQsgwoFnfPnzlaH0790MmBK/wihBCP4hXhOBVnV4xIUUI/SBeEULwarNXhJCkBzJejeqrW9+GA4qQfbzi/gwIREh6feDglbx59frQ0KK8apZ6hVCEJO9Vc8Ur/cTjP19ddH21p86qvFVv3e5xXIyOEUYKSdirK/1gBV6ZXbwyr7+HGX4hq36Lg+edricpRx+4q0PVp68PCmE5GknOK8GrW73aApzRfyq0V0fPO91MemWt8kq9fWsn/4gRgldpeCVvXyxrAfLnlY3ilXapVb+BevuuNZNdKnQRvAqzmc0tXjU3eOWFkd9euXc+Wb9vZD/3qnVf03d4RVL2qsGrXb2SX165X+sSxasOrwheZdcPNvvMt78y8nZ69p1Xd35PEIqzqLVm+Mi7btqrg/LKtoyZbX82RQ29j8cn8e1V4etF99w++7bPX3/dgF6ZduSV+aS+0l4ZvNrolcErvCrMq1//UPNXD36VsCOv3Gv1Li+1lvOqt3jlzSuLV/69Km59e35e6VUED10or+xo3YLyStdXvTV45aWw1ceZGa3IZefg1cQvjVdXvDJ4Fd4r47xyf0Xxin4wR69G1Y7Zs77qwns1f5MxWfA6V69E/UPoB/Fqm1ey5VZ9G/fN2Ks2klduvl2vb6e+CuyVse66zF+9IWThVeT6KhOvzIxXZtqrvsMrf141zqthd+NVOK8KXy+61att1+PE8mr4mLVX7dgro70SvMIrvCrMqyY7rxrtVYNXeIVXeJWaV+pjtpKeV6M5xJLm2/VlVa/XFeAVXl0fAXi1zSvBK1/1lfZK8CqUVwWuF/UGTjZeTf7KeIVXGXtVz/NxfHq17XC2+sriYF6Jndvi4WU3c34Qr3x59doDqqe9uI0rdfEZ9VWoftDsdjjv6pWdqa+6CPXV6L49724eX7BXDV5RX10rmsI+fzATr2Z2Dl6FPghVBfBr2pD6ivpqo1dNfV6ZKa8sXoWur/Aqhlfl31/Uw9tk6ZXegDZyfdXMeVXUMF0yfzW78XhFfeXZK7/1lS3fK/e0x1GFUcv5wVUFJV5RX033g5V6ZfXNLy1ekVy8EuqriryanG/HK5KwV8L5QTc+TLPP/El9/WDT4BXBq239YGVeCV4RvMKrEvtBG+WgxCtStVcr17fjVTSvJnY1XpF1Xo2eNzF7thWvSvPq8/OD1vfngldkzQFjlFcGrxqZ+aNfsVctXpG06ivuJzM1PiJn7FWXTD8YwitRXgleEbzaUF/hVRyvpnc5XhG8wivqK1KuV3WeH5RU7i+KV3hF5rzS5wf/zKlzfwbZ8k5ZehVvvp1+kFBf7d8P4hX1FcErvCq0vsIrsq0frM4rb+Dg1c394JxReEXwKlx9tSU1efX+3bnemWzziuelVuLVp/frwyuSmFcVzV/JVa9EXY8jkfcCXuEVucErKc6rZml9VeF6Brwi1FeJw0U/+GGLdz0/iFcEr9Z7te/9J5L3KsgHwXpRgld4lUV9hVcEr/BqTRuKVyQvr6Ts+zPgVWr1VbPQKLwieHXdq73uh5yQVwavSCZeXb8/QxVeVV9fmdj3Q+b8IFnhVUN95b4Cr8J7xfWDJIpXec/CB38+zrYhNfKqjexVi1ckw34Qr5J4Pk5dXr3rEvGKzEwgOLekKd6rJnQ/6PF6HOfVfZT1DHgV80Az1j3LTMzEXcDKmojBq3BebTlIyvGq0V41wbyq6X59I6+M8krw6oa+EK82rr8SvMKrFV412iuCV5u9krWDMMh8e8leVdsP4tWWw8fg1fb6qsErvMKrfeqrIq8fDA1OMV61eIVXiYM13ol4dcv34hVe4VUUrvCK+gqvdveK84PUV/G82hY1axTSq9GEnVnjlcErvErIK8GrXeurdLwyYpRX4tur68ciXuHVaq/q3EG7zl+1eIVXeLXYK73j9MuKdtuu60UjeWVHXsl0Pxj3/lfVeSWsb/frFfVV3V412quG+gqv8AqvavXqw0gcGbWgVcIrvMKrOuav8Gp/r+bmrxbNZRG8wiu8SsArXMKriF5tS1LnB/EqtFef9oMEr6iv8Gqn2AVe5brB4vUPN17hFV6RCONGmuk7mfn9+PBqag9X7dXUelGuHyR4VZBX27KDV2ZVfcV6UbLmLxFepetVm2V9NfLK7tcP4lXeA2VSJryK+ucBr/CK0A+W6VU+69tn568+9Yp+kNAPluJVk/t8e7z66tqO4/wg9RVe4VXSXlFf4RVe3bTfRzdZNTd+bLl7ZekHya0HPutF8SqQV2bP+mr6bCxe4RVeRfAq7/ODHf0gwSu8yt4rw/NxCF5l4ZWs+94i+0G8Ih4+WZ9PWcSrj1stN3xvduuvduoH8arUcTM6P8h+CV5nVuCVXeRV0Ofj4BX9IMm7vmpS8spSXxG8wiu8wiu8IiH2e9xvNxV5tbgnx6tEx4ms/iI+vrK8Sqi+sqxvJ5FQI3j1WSG3oL4yob2a7bzxqkyv+PjwarNX+91PBq+yHhz0g6l8JFWtF8Urgld4lb9Xbv2V4XkT5BavSNL9YPbPS9VeGbwiyXo1cT2OqfITqder9/PtrG8niXtlTKFeyfL9sOETydMrmaiv7Oj8oOH+V+QWr8KtZ/hTXxnqK+qrSF6J29vMt9c0mnwoRj/4thvcwJAFY8TvCZCE6itDfUWS94r66tf5QeeVwasg97/Cq/q8ErwKuodv2a0ZrmeY8yro+UG8wiu88jOrsmZ4+B1CeIVX5feDfn4yXvkYEwXVVxH6wQU1Ll7hFV7hFV6RTUd3ausZxNz8a1bklfrytjCvIvSDeIVXeLVTSvNqqr7yv75dPj6dAK8yaO8kBa8M/eDtexiv8AqvdvOK+mq1V4JXPjoLvCrTK/H6q+DV5vpKmG9ft5tF8CrXwUE/mLlXPko01rfjFV7hVZz6Kkuv9O/vvLJ4RW4eNz5XsuMV9dXn9VU0r+T6UY5XBQ0t8fmz8Aqv8Ir4rK9C2sf69mq8mu1n43s1ZRNeldkPhj0/iFeVeEV9RfZt7vBqN6+yWM+QjFefDwW8ArUKvZIAXxnWK1P2/BXrRXMfRqxnSMWr/Ta8tvWi7vWcUXhF6AcDFUgb17ebPbxyH/mS502wvp3gFV7tV18t8crSDxKf1uDVXl5J7l7JKq9MyPqK84N4hVd4daNXEZ5Hj1dki1esF8Wrifoq5PwV97/KdHCksP4Kr/b0yqT1PPo4z8dhvShe4dUeXpV1/ysbab0oXuEVXt1OUH1eGbwiYbxiveiNeznY+nYRvMIrglf71Vdm+UOe9Qdit4+nes4PDntW3r3EqwIGGffri7nf8Yr6iuBVPvt9sVeeb1NWVX3VcH4wZ5R2/TAEr1R9pQaJMYs/wTZzr3aYv8IrvMKrz8qgz71qbvEq0/pKeYtXJCuv9O1pi3r+4EqvVH0lQd7lc69MkvWV4f4MeIVXqWA1UV9F3d7kveJ+MiSJIV36851ltGVmpuGzavvkM/sC7IlIXk1vUqsgM9aIm2+3ca53xityu1dlzbcb5dLYKzUCbTvplVkwlPyUPSOvusheWbWlYl191Xr0Su9dcV7J/B+FebzQC69Mof2gGKsosO+MGjqfXm2f0VXXVDX2Z1/5Y/3Fq3bGK/G/S65+onoPaa88v+/wBK8rB+aif5C40yp77/vi66tWUWAVBb+Mekt/Vp+EG7H9tG72g1cbi49Zrx78eiVm1Vh/8cp690pGr/EKr7Z6ZYqav+r7gyPqeHT/4Xhyr89PavscHRf1Yw5KOvthd2wm3hnVj7zy23fqhvh1nmpUSsp7fO0LWOL7sP/w/0I/WItXHj654r06jLxSRp3O7vXledKrO/Vjju+8avx61Suv+kBeGdvY8QTeqJQ0eEUCF0f0g5/leDiptk8bpTS6/6bGijPicVq3QxugDEzSK+vfq48wFerV+99fJhrihlOf1FfvvDo6aQ4X1eHd3bvXD39NtmbPqmNU33kMcNZOedUqr7zKaG0z+GNf0g5nJU1zfDvNYt1MnbHW258uUeN34k7IkwdmAV7JR68MXuHVfM4nJ81xZJSqnh5/Tnr1TVVj6jtP0by69+uVcbXTS+lkXevbtqe3j90adX4ikFfOIim8vhK3MWbwSkylXkWdv8q0S7ycXd93enhw/+HpSb3+W32HG6/fVceovvPc+z/O5rzyudeveNWdB6/UmVBjjPeDdemfwNEVFyL5DXC8wqtbvFKV0UnXVCu9evTvlfoRXRSv1NzU72tw3ja1606hvRK8wiu8+jT3d64yOiuj7r+o2an+H9UbuvGqZrUelG6XXOurZV51U0eCN6+uDlIp2CvBK7xa59Xl2anz8FV5dVRePdlPvepCeqVrrT28asN4NV9YSXVexZwWtgkNYbxa0A/eDZ2cvXt2Rt1/U15dpr36ccUrz9FeHfb0qlFeNUl6lQlYiXiVUi2HV6u8au++aK++qCLsn8nvVV49PpfnlVvP8OKVKK+GhsXnAT47SE0NXkVdzyAz9VVVXmXaaGuvLsqrh+/Kq0fl1ZfhE36c8eocbL3orz3/Z0H+6/6+7+J5JSl4NVr0MH2g5+OV6PpKLeGQaF6JuwuJXL1a069FcuXiT7y67sDl8jZ/Zbvz0A92/YOur551Pzho9PjTnRR80F41sbx68OqVUTa83vOqtc6ro/MqEAkbvJJsvWoS8mp0DYv3tx1dOXFUf9Hbbu6mcxs2z22LXi9oTqP7GZj8DpqmP5/uB6+Ow/nBvr//qrz6orz6r2H7n35+nfTqFNirYySvXLnVtcP69tGdKKLUV5/PX2V5yeAcuLLXfLsN6tVJXabbf1fXul30Tdy6zvdW9e4n2u6Lvj74om+pYjM5arrT8W6Avh/WX/W9nnsf1Vf/7pxX36J5pW7/oC6tfuh97mftlR151bb9MIpS94r6asWwfns4m3k97+tWwFnxPhv96JY5Xs7/+1XVAt/UeNE3SPHj1dn9xP7wj1rVff/Y+3dSf3C6VvR3ne2xOw9enYa1o4fmos/4PfxrePn9Pwc3f3UYarD7J/d5HNv8vTJ/HhD7VqYPN1g16lD2ur69tqTgVeteuNsU2SBv++zmTu7P/1ZXsn39ruZPTifPG9ac3Gg5HH6ouu5O13Wt/yGr71veHPwNlAHgtmtOb9tz6E+XR7ftw3qGc/PtP8MOePjeDV5dHtyuOIT1Sv0Nuvfq1cezvaL/9dErMVyN660fnLlkMmx34V4cBq/aMF656ZXz6X/UlSFf/rrTTZrv+kqNlkP/Vf34853uB/0PWas60ebkb5zm4ZXa9oOzy++V1fOrU4y78ejrS5GJnozc5JUor2RHr45jr7zn2RHVHb+r+urxh2rSTvcBver7R30qTHvV+J+/socwXg0Mvng13Lvv0Pf9xX1+/ffBq+b/hm0+P7hVDIeT2/o2wORdO+nVMZZX6g4veFWmV21gr9TFIN2z/i8/9G3kHv0cx8PZyFYNzLY1qk1pLiG9kpd37tzvc/T38zt3EwJ3qcuhf13SPXx+3Vfn1X8PNeXxfD/8Gvpu7wEmddTZYN0Xd5FOa5jR4zZgKud+UC8lGFYztC9etR9LeY95+jH3X76rieLzs6cDdhgvvZsRs1ZzNfbK/5htdX118OiVVV51Q9H1egrfefW2S182/l8XZ5Tb5MAnRPXyN2WXjTTf/e4hEHhVmlf2ZUTZoPXV00916k+fex95dfLjVae8Og8bZkyv55TOgb3S79X7+/mDV7Z1XnUvObhSsX8rU1/2+V9uzq5bvJI90G2DzYbRsvS9ZOI18e9VE8kr/UC2t6Esr16ZsF79rW4u+VWdTR95dfTjVe+86s5DP2Jep3mUjW3Qz7fV5we9rZO07XBqwFg7bE7b9voJEkfVVj+cXMV3XFrpBFqoFLrQwauivXpbffXqVWcl0FTOb0Ge/37olVdqjv0vNbj6Rz/v5vqR7qzqnOMhnldWT2O34u+nDtNiyiv7ziv1J+B+8Eo//iubBbIkTa8kVj+oVzEO14EadQAHmWM4PP+8n/NKvW4fvNdXlxS88rZe1LTDeRExrTr11utnex3V8wfd1/Rq7axecGauHiSEJOKVu3jQuNdhjtXji1eHaa++a6P8rGeYq68OEb3S88weG6HOndkT3eD1eqntUT1/8M55dad/zGTFjVckba/e1lCYt3PAYkL8Asennw/HaF51orxS80gHPQd+DNsSSZjLMZVXowmpg/bqoLy6uK/XXvV4RTL2yq3/ClNfPf396AbUV3V1TmCv2t28kn29avCKhPdKYs5ftaN1KkG9evz76fy5V/bB06iuzqtRP9jjFYnmVdjrnXV91YZ8oz29cmnPfZ1e0Q+SOP1gDV49zfSDAeorPd+OV3hFPHsVuB9Ul0q00Z4rFNmrUX1Vp1f0gySgV80e8+3J1VcB5q9O9IN4RfBqVU6PP5fMXwXoB08dXuEVybYfnPbKhL2p6XGZVy1e4RXBq2mv1GUi+r6x2Xul11+dKlx/hVcEr7Z5FXM9A15t9IobG5DrXulDpV6vqK+8edVQX5GyvBJ1pAaevzpRX9EPEvrBbV6lNn+FV2nUV/SDZHl9FTR7eUV9RT9I8Gq1VzLtlQnqVdz1VwV71eMVScGrHa7HSc+rYusrb3sUr0gKXu0+f1VDfdXjFV6RXL0a3VYcr8ryivODJIZXhvqqwH7Q24+lviIpeDW8DH2oJO0V5wepr0hOXjURvYrZDz7RDy78tn28Erwit3gVcf0V81d4RX1F8Ir6Cq9I0V4Vfn5woVf+n+eVhlfefuwyr57wiuBV7vXVAa/mvGL+iqzxarT+KiRY9IMVeLV+PQNeEbzaqx809IPUV4R+EK929qrDK4JXEbx6POEVXhG8Wve+eIVXeEU2ehXOigq9Kvd65/VedXhF8vWqin6Q9VdrvWrwiiTppD4sVRM6eCV4VVQ/iFcEr/AKrwjZxyv6wcBeBZq/OrhNm/PqjFeE+gqvUqiv1nl10T8Grwhe4dVOXh1W1ld4RfLwSvCqBq8a+kFCfYVX+faDeEXwCq/oBwmJ2A9KaV4Z6ivm2wleUV/hFSH79oN4hVd4RXLxivkrvJo9SAjBq7BeMX+11ivBK4JXS7y6D+CVwSvqK1KCV8mtFw3hFdcP4hWhvsKrKrxivp3gVcVeRe8HBa9Iqf2g4FX29dWR+opU4hX1VXH1FV6Rarzyf7jiFV4REqYfxKv8vNL9YPeIV6QQrxr6wfhexZ2/witSk1fUV5l7tXV9O14R+kH6QbwiBK/w6r1XrL8ieOXLqyeuH0ysvsIrkqZXzUevmuK84vwgXhHqK19ePeAVXhGSSX1Vm1fx1zM067xivp0sPp5Hr/GK+gqvSD5eBSVrzitbgVcH6iu8Ivl7ZVLx6s63V92prdArzg+Sgr2S4F6dn34+zHj1LYRXpnqvqK9IJK/Cvu9uXh3T8upUtVc8j57gVZJe2Rmv2pr7Qbwi2XkleFWMVye8IjV61ZTl1XmRVxL08w3m1RGvSE1mRvPqGa+8pMcrgld4RX0VdHcQssko+kG8or4iyXu1T32FVzn1g9RXBK8ieiXUV3hF8Cq7+upUslcz669avCJ4hVeZ9IM3eMX1OCRPr4T5q0y86jz2g3hF0vTqs/ODEbz6gld4RUgm9RVe3b5pnuavuD8DybbWYj1DWfUV81eEftBnfaWM+nZXjleSTH3F8wcJ/eAWr+5T9sr/zo1yfvBwc32FVwSv8CquV7f3g3hFsu0No3v1OOfVJW+vmv36wRP3byfUV3iVllf0gwSvWM9QVH3FegaCVz69ui/fK5Px+iuuHyT0gwn3g3hFfUWStyuJ84PUVysSZ/6K+ookZlQt9dWJ+mq9V9RXhH4Qr2J59YhXBK/wak0dS31FSIjesA6vpJrnO99QXzHfTqiv8CqX+gqvCF5l0w9m49X4fjKsFyV4hVf0g4TgVbJeMd8+e5AQsqdXrL/CK7wieLXcqye88u0V92cgeJW/V7vdT8bsN9/eUV8RvCq4HwyxnzNe345XBK/wCq8I2WxXeV7J/v0g8+2EbB9HVXhV7PwV1w8SvMKrXLzqed4EqbEHxCv6QbwieJWqV9yfAa8I/WCKXslKrwpb345XhPqq4PoKr/CK4FVK9VVTq1eCV4R+MA+vTtRX1FeE+iqTfvBEfYVXBK/WefXMfDteEUJ9db2+4vnOeEUy9qqptb6iH8QrQn1VQn0VYj/jFSEh7KrSq3Md9dUJr0jJXjUl9IN4RX1F8Cpnr+SKVybAvo3s1ZH6iuCVP68Oe3jVn+3glfTxvZLm/f9H8qr56NUZr0jeYCmvgh6ue3r19tqYkVeXiF6tbTrnvwuvCF69HLJhR++LV3e98uouqFe98+pwGV4ae1BjurlXQ3ZqzPrsB2/8XCa96vCK4FVwry54FbC+6rRXFq9IwV79OviDjt7zlx/qRNWXhzmvzn7eznl1dAKa9qS/5qEPusWBstgrwStSmlYyfdQGqK++/FDrM5/vVR319ZKAV20unxlekVpjtFcm6OHa3z+7EWLOmqhH3aT1nrZs2LBeD+OD/prA8+3BvHL7UT/fuTnq13eqdHabqb7C2g/bbjKsNkml3eCtJ7KWpj1d3CAwepzpBY1+6hzRk0e2dXaJHqVNZ4JucSivWreLWn26s1fUH85qi9w265rKfNh2vCJkKG1ObjyIHlvN0evMmfyiStzKMqu8MpqoNruHgsovf90ussquUYfXH5RXZmrnTlhtMtSbkDBDre3cIBjXOa3POkd+e2WG+kpEihl98ttfh47ej/p126kl9WbBj339P7wiZCBKlTPjOsf67EQ/eNWU5VVj1G21jN6PYsZ2ObEXepVjd0xIbK+M15mzd/1gcV7NXpZo3p0wkcW7FK8I2ccrwavVXkEUIe+9sgnUV2FumZCmV42s2qXUV4QoovbyqinHqyZgfUUImfNq6ibyeIVXhCToleAVXhGCV4u9MqXNX8kmr5i/IoT6KhevCCF4hVeEZOiVwaugXjV4RQhe4RUheIVXeEUIXuGVbNuTEEfwCq987MhAXrGegRC8witC8Aqvlnoli3aqhL0fNiHpjy282s0rWesV9RXBq8S8yn9MLvRK8IqQXPpBU7tX41KS44+QdcMMr/CKELyqph+Uxf+V9VeEUF/hFSF4RX0Vzivx/UaElIkU9dVOmuEVIb7qq4b6KqJX9IOE4BVeEYJXeIVXhOAVXuEVIXiFV4QUNILwKievCKndq/F1JLP/wCtfXgEWIXiFV4TgVSivaumM6AcJCeRVM/cPvErDK7gjNdOFV3hFSJ5ezY4MvMIrQhLrB/EKrwihvrp6fhCv8IoQ/17djMzvb2Q9g+AVIRl4xfor6itCfHr16fnBQF7l//xBvCKklvqqbK+MR68IwSvqK7wiJDuvPrseB6/8eoVehOBVwl5J0WcXCCnfq5JLDuorQnbyauMbUV/hFSFrRxBe7d8PCl4Rss2rZu4fYb0queLAK0LCeDX+Ku4vGtKr27YW4Aheff519INpeEUIXs0Qg1fUV4RQX9XcD27iB68IXuEV9RUh6Q8mvMIrQvAKr/CKELyq2StCqkNqb68Er/CKEOorvCKkyvpK8AqvCMErvMIrQrz2g3gVxKsGrwjx71WAN8Kr1/8ZtY0GsQhZ0xAu+jK88uOVjL0SvCJkjSMxCzlj8ErwihDvXoW4X9/IK1NnPyiCV4Tk5pUxtTTf3rzCNoJXSwZHWK8qq6+s8spiECEevPL7NuYXWFYhVa9Xa7a26EedEYJX5XoFWQSvYvyIV69eekFr8Yr5dkLwKhevGu5/RUh2XjWmln3M9YOE4FWeXjXjftBwDBKSsFd/Kgzm2+kHCcGr1L0arb+iviIErxL2in6QELzCK0LwyrNXf1LteoZmi1fMXxG8iuVVS331/n59eEVIBl41ld6fAa8IwSu8IgSvfHrFfLvIti3EK4JXe3hV5/r2rfsbrwhe4RVeEYJXeCXXvOJ6HELS9uqt6qhk/urD9c54RQhe4RUheBXIq3r6wfHrDfdnACuCV7G9qn09A/UVIXiFV4TgVaB+EK/wihDqq4S9Gt1dVPCKELzKpL7CK0Iy9KrsPUw/SAj1VZZesf6KkFy84n59PC+VELyqpR8kBK+2/Qiz5Gvwyks/SAhe4RVeEYJXw9vgFV4RsptXw3e+eDVL1tsXaa9+/9N5pddQitT2fBzEIiS8V6K8kitemd//7ZdX5o9XcsWr4lZlidf7ixKCVzf0JniFV4TU7lUF669Mc+s+53gleBW/H3z9P+NeihqOBV6l4229KF6RsPl/enFJb2D/CQA=", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0057_20261007_095950/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0057_20261007_095950/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..2d19fcf88f615190f4e3ec519b69122bf0bc90e7 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0057_20261007_095950/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0057_20261007_095950", "meta": {"split": "train"}, "act": [15, 552], "T": 553, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0058_20261007_100018/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0058_20261007_100018/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..2cd3915f70555ddf1690098f30987bd33a5372a4 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0058_20261007_100018/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0058_20261007_100018", "meta": {"split": "train"}, "act": [13, 557], "T": 558, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0059_20261007_100048/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0059_20261007_100048/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..ddebde53f7f9a63ff51fcdfaa7d892ff9759d839 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0059_20261007_100048/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0059_20261007_100048", "meta": {"split": "train"}, "act": [7, 554], "T": 555, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0060_20261007_100116/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0060_20261007_100116/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..57ee70b42a319a3758bc34c3b5d7eaa5340c7966 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0060_20261007_100116/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0060_20261007_100116", "meta": {"split": "train"}, "act": [13, 486], "T": 487, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0061_20261007_100544/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0061_20261007_100544/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..63e49006af5e5ad64dc46f1b60521fea790c9104 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0061_20261007_100544/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0061_20261007_100544", "meta": {"split": "train"}, "act": [16, 520], "T": 521, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0062_20261007_100610/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0062_20261007_100610/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..917bcbbfa7c3df2534561740cf757b312714ccea --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0062_20261007_100610/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0062_20261007_100610", "meta": {"split": "train"}, "act": [13, 478], "T": 479, "maps": "H4sIANe51moC/+3d60LbSLpAUVXpYm6BpJOePu//pIckYJWIHWS7Ste1f/QQBjDI1uIrWRZVtZLC6bdj6P9Rv/4jhBj/+D923ev2CKc2YhU+3c6S8noVecUriVe8kmQ9yCtp76Ve/XzTTsgraQ3zFa94JS16zwy/+7Xb8WrgVXzbDqHfGLyStJoxVZJ4JUl0kyReSdq8V8mHNDaYJF5JEq8k7ccrSdftXcOTRMUriVe7IcrWlMp6hSxJvJIkXknilSTxSpJ4JYlXkrQor2wESeYrSeKVJF5JEq8kiVeSeCVJvJIkXknilSTxSpJ4JYlX+vsWtQkkXvFK4hWvJPFKkngliVeSxCtJ4pUkXkkSr3a/0W0CiVeSeCVJvJIkXs22RW0CiVe8knjFK15JvOKVJF7xSuKVJPFKkngliVcqttFtAolXvJJ49ZdP/vXf5D0nd8rw2dfZmEJh9Ec2HoLSGr264BN4JfHKfDWJV5J4xSuJV5LEK0nilSReKcNGtwkkXvFK4pXGKxQq519JvFp08brzGXgl8WrR85UkXvFK4pV4JfFqu2xJ4hWvJF6JVxKvJIlXknglSbySJF5J4pUk8UqSeCWJV5LEqy1tUZtA4hWvJF7xilcSr3gliVeSeCVJvJIkXknilSTxSpJ4tYQtahNIvOKVxCte8UriFa8k8UoSrySJV5LEK0m8kiRe6VhjE0i8ksQrSeKVJPFKEq90c463S7zilcQrSeKVJPFKEq8kiVeSxCtJvNJxi9oEEq94JfGKV7ySeMUrSbySxCtJ4pUk8UoSr3TdFrUJJF7xSuIVr3gl8YpXknjFq5M3l/4juk/FK63EqzYUu6Hk4fLzwXPi3cO3PSjEq4USNfGmjOf+cYhTeBXPeBXZJV6Zrz5WD/6RGPVQF/u5Eozq0D92QuzfTr8TXolX2yDt5r/nNZlXyZeO8ShW8/r28d11D1lTB16JV0awYc05rx7zehXqlKvEq/6nicnNt3U4vTZ074tX+20qr2L/5er6tFch9aqJpz5V4tWea8959ZRXibo5Lvx+evX+dpvcYtPUiVf99xJ4JV5pSq+a9vjFm6Zf+bV1f2ArJEb99MqDSrwSr8Qrrd+rpuR6cODV+xfvmv4Zytgc3/3Tqzq6d3LsoVXyLEUcnKq7w6dgebV2r+rkzuuPMWX3KqZe1Se9qpMDWLwq71XklVboVZzXq4ZXJYspQfXAq4pXWp1XcRKvqvTgVO/Voa2bdMlY86qwV/2sxSvx6havGl5lr04Jas55FXmlVXv1ZSKvmuMR/6blVRmv+nt78KooXolXY7w6Hqi668551fCqrFc1r7Q+r6r+JIKqSc5uyO5VfzC9e135nfaq5lXumrNe1bzS7Vt0aq+aabxqE6+a9v2G7u/a7njrXX8wq23TV+coj1d117sUEq8Cr8Z+8tnz1kK1k004r1dd1TQL8arlFa+W7dXbl+DVnPPVzF4deDW3V9Vult68yr9FJ1VxDq8OqVcdrybzqurOHL/a0d61GK82AlzI8iEZ5qvMv3MH89XxNu8Pg/nq+P7Xt3hlvuKV9SCvzFcf5yvHr3jFq+F+c9qrLvGq5dU885Xj7aM/OYdXb+6t1qs5/57XhF5FXs3sVZN6lWzfyCvzlflquN8Mzm8/etXyaqb1YGM9KF7d4lWTelXzile80oeH8yxeHV8/2DTdOa+8Hie7Vwde8YpXY6rTPaX3KiZeNb1XjeszLGG+2tx+zSteXeHV0SJeWQ/yaoXF2R7OM3gVeTW/V86/0obmq6dyXiV/ef7u5yuu371qeWW+4pUWNl8lXh14xSterXmL8opXpbzy/CCvzFe8Ml/xShN51SPVH/ueyivHr6b0qjl4PQ6veMUr60FeafteWQ/yamqvPCZW/XBuecUrXolXvOIVr7bd5OczTORV5BWveKUtedXyilelvXK8fcXzVT3V8XZeLcAr51/xakteFXz9IK94xSte5fSq5tV+vHJ9BmUgile84hWvzFeL96rhFa94tTmvcm5tXvFqd15ptSPYgrxqeMUrXpmvzFe84pX1YDGowuiP3Ox8VfNqJq82vdl5NYNXOZvJq/DZfMUrXvFqucVleBV5xStenf/kX/9N3nNyn/1sfRQu/IRFejVPC/LKenARXjl+xavlxite8Wpar678BM3n1afPD/KKV7xawxblFa94tXivgvXgpuerwCteLcSrGz7Z8audzFe84pX1oPnKelC8WpVXV36CzFe8+sOrHZx/NfN6kFe80tk72Hy1yPlqK1uUV7ziFa/EK17x6hav3r4Er2Yr8opXvOLVStaDvOLVzo63V67PkJGofXjl+gy8msmrilfmK15ZD/LKfLUdr4Y3aj3IK15tYoPuwivzFa94ZT1ovtKNXm38+NUcQ8GWoQrTmnXWq0dema94pZXMV7wyX/FKvBp5/CryildFvPInUzMSxSte5cz1GXjFK17xynpQvOIVr3hlvlqWVyOOt/OqrFcNr3TVxGQ9yCte8WqVKzxe8WpWr5x/xavlxite8Uqr98rrcXjFK17t1SvX6+MVr8QrjfWqSbwKiVeRV1rHNGY9aL4yX4lX5ite8UoTefXIK17xSiuZr1xPhle8Eq94xSteiVfK4JXrych8Nc6rjlclvXI+A6/MV+Yr60Feactehc/nK68f5BWvtACvAq+W65XjV+IVr3i1EK8Cr3h1rVevRh29CrxamFeb2695xStema94taFtxCtezVZ9qVebP97OK17xile84tVWvapOelXzan6vrAd5lfejw5mjE7zSX72qnd/Oq3UPbEOvmmm86hucz8CrAl38ehznX+FlLevBxny1e6+c384rXvGKV7zSltaDkVdlvXK8nVfbmq/qOeer6HwGXk3glev1rdmrmle84pV4dX6LhE+9irzKkvOvrAetB3m1i/lqk693Nl+do+B4p4fMu3spr9rleGU9mH++uvj8qw16FXh1xpRiXpWbr1z/atPzlfNFf5/bzqtTXoUVzle84hWveJUVo9V7dfb67f7eBK94NdemSbwav4H28fe8eLVYrzZ+fjuvJp+vNuCVv+fFK14tjqwVrwefeMWrTXoVeHUzL2Fw3KD8nhr7N9quf29yPkNur9IvF+uTXrWJVw2v8jTm+leVTa1PvUre//P3W32SrkLzVXIL7aF/+9AU2woDr477yt0vsnhVTbLd09ezh7pOpo69zFe6Hqz0ENfrY6cd7VXe37rdXf/2XTPFLbZ18347903PZcer4r8nQuJV3KFX1oMXPmSSf73uwXfd2+PjdT99aY6Plfqyr5jRq7bYRkgk7HqX7rsu8ao93vyrV7jK9svy+Gsx9ntqiKlXgVf6xKvXnfPx/u3fr29/746PlXjBV7xut06nncSrrvcq9/Grc14N5qum98rpogUG/ASm1Kjh2wUWFVMC8Vd6eXU9Fa8+ffvy9u/Xt/+7r9730YuOhMabvomBVzFO4lXbv676Pn39YNsfUmmc3p5x5w3HQWvgVSg5Xw2+TFzKixJ5dWmH4z55qOL3r+9edU//PVb1n159ujS8zqvkk+r+eHtMri2T+/yr9Afpnx/sEsjSNSCv8oAR+sOk4Y8z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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0063_20261007_100635/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0063_20261007_100635/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b764127046d919fdb900eb3a134eda94104a4e31 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0063_20261007_100635/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0063_20261007_100635", "meta": {"split": "train"}, "act": [11, 533], "T": 534, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0064_20261007_100702/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0064_20261007_100702/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..515e046b5c2384241a41d5cbe53f8737fa20bb75 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0064_20261007_100702/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0064_20261007_100702", "meta": {"split": "train"}, "act": [17, 568], "T": 569, "maps": "H4sIANm51moC/+3di1bbyhmAUc1oZAMGAkna0/b9H7TmYmsMGHzRbaT9rXVOzUmKY2Pt/CNLclUVUtj9a/tPbG+/3Xz9xSrG7a3w9kV8/2/Z/1mSBvbq8ParS3uvQu5V4NW74l8/DxCXeMUriVe84pVUkFfxs1evN3d2VdXeq/j634NNk1eSeCVJvJLEq1DZqSdp8mq1SH3rVPjha0kaqnDmr/NKEq8k6YrF4iefAq8k8UqSeCWJV5LEK0nqwKv40//XIfOSSpmvECVppN4vxtN+HaMnRdJ0vbp4fDJsSRpzvmKQpEnPV6H9OpwxUrFN0oS9sh6UxCtJOsOr4IhRSVP3ysQkyXwlSeYrSbySpAK8shqUNHGvHN8uiVeS1PV60PuDkqbZx+tfRV5J4pUk8UrS0nL8lSReSRKvJC2oo5+PUwVeSSrDq8ArSVP3KrReEUtSEfMVrSRNNl5J4pUk9bkedDyDpInPVwfXk4meFEnTna/2XgVeSZr6etD5zpJK8aqdrzw7knglSdd7FXklTbrweq5cfsLccs6dO+qV4xmkQryqFutVxSuJV6V5ZT0o8YpXknjFK0m8kiReSdIVXmXn4zh/UJpcjr/6yivXQ5Z4Zb6SxKsuvIqh9cr1ZCReFTNfWQ9KvJryfNUidZ5XbJNG8qoyX5mvJPMVryTxqluvHDEq8YpXkvryKt9mZ7j9Hv18nJfP8+KVZL6a9Hw1Z50lXpmvJPHKfCWJVz96ZW+7VIhXri965nOQ6CbxynpQEq94JfGKV5KG9Mr+dl5JvCrGK+8PSuKVJPFK0jK9sv9KEq8kiVeSFuVVbL/ilaSJe+X67dKUc31RXkmz8Gph85Xrt0u8Ks4r85VUiFc+L9V8JZmvZjtfsU3ilfWgJF4d8cpqUCrWq4Udz2C+kkrzyv52E5bEK15J6m09yCtJpXi11P1XvJJ4xStJ1oO8knjFK0m8mqRXc179Srya5XzleFGJVxP3KpqvJF7xShKvOl4P8kriFa8k8YpXEq94JakorxZ3Pg6vJPMVryTxilcSrxbtlaPbJV7xShKveCXxileSyvBqqccz8EoyX/FKEq94JfGKV5J4xStJvOKVtESvvD8oyXw1ba+cPyjxileSeNWRVz4vVeKV+UoSr3gl8erTVut6yJLMV7ySxKszvfL5zhKveCWJVx17lT0JMZ7zJHodSbyarleBVxKveCWJV+d5dc6zwCuJVxOer3glTcwrx4tKMl9Nc77ilcQr85Uk68F+5itXk5HMV+YrSbzilcSrhXnl+qISr3gliVfWg5IW7ZXpShKvJIlXkhbl1f5853Dm9RkkaXCvAq8klTVfVbySNHGvPnwtaVot+firwCuJV4XOVxWvpHK9cn1RSbyapFfWgxKvzFeSeNXTfOX4dolXvJLUl1czz/mDEq94Jcl60HpQ4hWvzFcSr8xXknjFK4lX1oO8knhVjFeOb5d4xStJvOKVxKtlemX/lcQrXkniVSdeHXyeV/TakIryauaZryRe8UoSrybpVfIqknhlvpLEqx+8cvyVxCteSeIVr6Sle+V8HEm8mrZX9rdL1oO8kmS+6sircLJYwepR4tUUvApnTViOF5V4tUSv8nut+/hxf3E7WP5qFl7NvMm9PxizP9T65Z+OLxKRParY/sjrGNpP2ojtjz+G6ssXBdzEK14N6lVof+QZXYdeRV5p8l5VvBr0Ocj+FLE+9OpmmPnqiFc1rzp8wtu9BiF7YkNc4qBgvip6vspVSvkXt3Vvd5R7VWfvi2a/hVddPuFN+/ylmle8ut6ruCCv6rhf7qWUfVJsfcQrWwqveMUrXvGKV7y6yqtB3+BftQcu5Pvbb7b/3HXrVUx7gpoU6903b9btI4/ZtlTXLmLPK16Zr4561bR/iNvYuVd1ao1q6vb2TfYnSO1+4cSrq7cuXvFqdl7tiUir/R+iudve2HTrVbOKu61mvUovd/b2B7hr/wSrVbO/z5RSDaxOXmHhqFc1r3h1oVfVKF417Us5VZlXVQ9e1avWq2bv1ebAq5R5lWpbSCdehbppN7jsWY284tXl89Xp5zuX6FU64lU+XzW86ne+CgfzVbQe5NXl89UYXqXtS/l9R9HL67h+86K53f7rvmOvmqbZu1S3t29bMXOv6lev8hUhvTrwKp+vgv1XvBp+vrrqGXvx6n3f98sf6H3h0IdX9c9exdyr+qNXrkRx7tb11XMXal7xqtj14NdepZsevEr7d/9yr9Z31SojLfPq5T3EunSvPlySIny+efD6Pzw1LXR232edj0MuXp2yHhxeq9dZqqqzo57evVp371VsvXrZf7X3asOrnrwSr2bmVYwvXr1JULde1S9ePXS8wyi1GK3b/e03968nV7//jlW7v30eXn34GYcPNz++6AOjeFWaV2F8r1bDefWwbr3aLhTTTL3S3L1a7OfjjO9V7NmrmwOvblqvVnU+X1W9eRWq719mxhzx6gevLroe8tXtvaoyr17+534Mr1JPXh28mEJ1ZKDnlXh17nw1+P727Ky99sSN7r1KR9aDmVeH+9t7mq9O8Eri1bdejXN9hkG9qr/y6n6KXn0Yx458IV7xahleNUe8SplXcXpeiVe8evt6AvuvRvBqs8qOZ5j8fCVeOZ7h0/Y08/lqlVJBXhW7dbX/0w7v4djh7U7NMV9d5NVc14P1117dHXo1wfVg6RtZ/mKL+QbGK1514NVE5quuj7+q2zPXVnV7bs7t4F4tbSPLX2G84hWveDUnr6JzGC/xauYdf39w/l41x7xKmVeRV71scT/e5hWvTvCqPXc/nPFRC1dvxaN71XzjletfdTNf8cp6sHOvwiVelT9fHfOq5pX5ildT9WqsRzu2Vze8GnS++tkuXvGKV8e9WlkP8opXpXp13vFX6drnZjivsvs84lXiVe9bHK94Zb46d76qj3hVWw+KV/Odr0r1avfN1+vvvOr4eIZvX1JGCfGKV1+vB3klXlkPFu5V4pXK9GrmTcWrxCuJV7w6+oB5JV4t1Ksrn7EDr5p5eyXxilez8Mr+K/GqrPWg+Uo6w6vK9WQCr3glXvGqKK9c/0rT48p6cOTjRQ+9Wg3iVcq8Wg3u1ZHrt4cjn+/s8wd11KuKV4vwqp64V9UCX5EyX53j1TifP8grXqkDrxZ3Ps6SvEq8Eq/m4FVlvqrsbxevJu7VGFp9ON+ZV9K3YC3Vq4+P7sLPxyl9vlrxSuar8rwaa76arFeOFxWveGW+kjrwKlQLPR6PV7xS6V6Zr0bwqomDe5V4peK8ekeLV4uYr+L+sydWzRGvIq/EK17xSroYrJYqXi3cq5pXKsarilcL8qr+ab6y/0q84tXUvDq6v93xV+LV9L2qeMUr8YpXk/Gq5tU4r654sPWdd1tLvv7VNL2a0PFXvJqAVzrulePbzVe8Gt+ryC5e8YpX1oNzXg96f3C+Xp1yPAOveMUrXpXileOvrAd5ZT1YiFeObzdf8YpX5itema94xSte8YpXvJqhV7X1oHjFq/K8OnZ90VevIq/EK15NZz3IK/GqYK8Cryr7r8SrOXqVrn1uRvbK56WKV+araXtVJ16JV7zilTSGVwt6GnjFK83Mq1DNdeBanFcNr8QrXhXo1ZpX4hWveCWN59WMnwZeffbK+TgqyivvD87Wq1P2t/NKvOJVUV45H0e8GtcjXvFKvOJVSV6l1qu19aB4xavJetVkXq15pTl4NaOHeeav84pX4tXQxdafUMh81fBK4tWPXsVJzFcT88r+dvHKfDW+V9n7g7wSr6b0MM/69QV45XgG8YpXxXh1wvVF4zZeaVpevat1+rY9y6dhefMVrzSH+Wq5Xu0/pXLrVYzmq09e1bYX8WqSXg34LIy//2rNK/GKV7ySevVquddniO1XC9jfnqwHxauCvaqW5JX5SrwqwyPzFa/EK14V6pX1oIrhilcLPH/wouOveKVpeJW9HRbiHB+n+YpXmotXMfMq8mpZ5+OseKWSvArbl+aXXtl/1c1355XUk1fVguer0HoVOvzuE/eqSbwSr0rz6mKBSvHq0C5eiVdzmK+qRcxXkVfiFa8K96rmlXg1r/VgGMKre/OV9MGryKvzzx+cgVf2X4lXvCrNq3XjeAYV5NXh8aKz8qrsz/O6H+X6olXmVcUrTcyrwKtFe+V4UfGqwPXghce3X/0pDLySzvEq8Oqa+aqU/VeHdtnfrlK9Cl9en8H1ZObllfcHNQOwfF7qRM/H4ZW0RK/OFWfQ56C2HpR41eN81eXxVyN55XwczcArn0c/metf3Tt/ULIe7Hg9WPb5zq6HrIK8elfLfDW9+cr126UleXXqERmj7m/fetVkXsUhvGqyz/NaH/WqPvQq2V40Na/Cgr0a5xMnUpVar1JTT8Wrmlfi1fAP71uvQvZraaT5Kq3bL1atCo8de9WuAV+82t3PatXub9/+1/3v2f5uXqkgr+ZzfPs3DsexvNpL0Bzz6lfnXu2/d8q8aqom96o66pX9Vxp9K577+4OxfVz7jyrb3ti/PZZiuyHefJi3jsPXwfFXsV2ENa1XdWz68qpOsdk9Bdu1XitTdWBUPl/V+adRRl5pcl7N7P3BYxvc/vYqVvv10P0qn7fi19/lzav8Y7Av247rzKubYbyq915VGUzZI32bqfa3E6/Eq7G8qnOvdsuhdd169bCbtV6eg6Y+ol6pXqXtHe0fSKrbI7COPHP54lDqjJwrlnGzXw8eGxDSzqW7LVY7r55T5tW6ntl8dbZXdR1tXpqWV+9qLWI9GL/wanPMq9Us14O7n24d6i+++8EE2qlXizxtQv17Ndf3Bz9B9mZGvGve6dj+6ymdcLnCt2eiSK9i2nMVs2PqD/e357d5pYlht+jznVdVum3ejbpZrR/T50NHv/XuGq/andz5+4OpP69iqvNjQXe3m7p9AHEQr5Z+9pd4dRZS+zZNSrtt8rnJxpyfj43s0Ks6P/4q9na8aIZRyr1K7YP94FXs0KsTbks/c8Wrt343zW37K81IXjV1b16F/Niq9vqi2/mKVz1sWoe3w+ebwz78MIdn/SuvZv+e0N6rcHOf9ufrrX43q83+4NKbAb3ajjqrcb2qeTWUV3HAh3xwRwefgVW4V/ljmeXnTRy0pyjdbmJ6lynePtWrx/TVENaTV1l1e2+reoj1YP72YMoeQMh2Wdl/1QfQcaQ/Aq/K92p1exfT7WrnVVz/OsOrqsMnJ9vHvsoOKejeq/SFV9Xh+4O8mo1X8fCnH8tfQy3bq+b2rl7dvckUbn/H9XPrxnoCXnV//FXKDr9KX7x868HfH5zr3ocp+Hzw3NYz8urI5w/OtNWhV+u9VzdT86rbC7jUKZuvhj7+SsN36FWan1fh/evdFzP3qqru4s3D7iCG3/H2T/tLN8N5FfNjRFcpDeBVOn78VU/rQY3sVUohe8WV7FX42qt48Cn189zRcFfd/rp7v/388Pivm+xXLn1hXPCyyi7a3mTXGu3eq/0+9tR6tWoyr/KLzOBqTl41+Yupa6/OgeKqMWg3X70/wBjb5WA6VHiWr93b6u73w/vtx4enf1qlHkbyKrUXJ65+Nd0+2tQC1LQLv9UqWw/mXkVc8epERrLXSgqpy30ln7yKmVf5ITfNoVezvLTITXX399f7D3Zz//yfzf75fRrJq7pPr7JXbzs8HXqV/cBxVWD1wY6GDJR891Xn+6jTar3/lrdp1Xz9ouvMq9h6Ve8Xgat48BfsLF+9q+rm30/vP+TV5vc/9/vH+fucZzx2KUn7w75Pvb2WU/ZzbpeJLx/z1R7G6lSZAsv/wqnzYbnHoWf79/7maf8G1aa5u813uXT6l+6LV2F/OMP2RnuMTlPNf0HQVKu9V/XN896rePM7/029e5VL0r6w7vr7fJy6PYSlie3fwyn7O+rgp8+uYv4GznYttK+fVb/b8t3jPw+7e04PTw+b9peebr/+03X0Qt5Odhmbc7++ZKrqf+28qtZPe6+2dp3zJIcuJWmf83XHr7H8sKfWq9yojLHDRQOvCil7n6i5G+pO682v//1pjfr7O9ub8jffE7zp/q7vskf5az3fH2x8/9fL/vb63aX/3O28Wj0MOl99DUOffyW2n7CYvSX84TavyisTYb0Z6k6bzeP/Mpf+/sn+tv/XY/Yb77vfjDf5LDd3r7ab4f3tzqtU/V3vHEs/Xlqmy/MHj8DQJxJHvCJT4WUi3D8Mdaerzf1/M5f+PtVHvHrk1aVW7bx6Ob5959Vzs/dqfZSj0KtXQ8Wr2Xv1sASv6vu5epXvvUmtVze76yFv//1rd33RmF3h5Wevynw2eDV3r+6HutNms8m9+vOUBvQqe5TPN/P5QR6cZFu3Xq3fZ6qwdenlgghvXtXNlys/Xqmc9eBwXt3d//dv7tWx+ar7iW++Xn34YK/d5ziv05leVbwSrz559cCrLosnePXQepV4JV7xairrwZ1Xq/1neC3Kq+yK4i1dvOLVhV79ZySv0sMCvAo/e1Xzqvd7/vQFKzv1arjjr3g1tlfzn6/Gl3L3Ywn5j4hZvOKV+WrCXn11WyV7FXnVt1fhcq9sZrziFa8G9+r+ZK8Cr8SrnVe3vOrzb/Vj68H7M+YrDTFTGbnK8GrDK17xile8+smrB17xile8utyr38+84tV8vDr2C7wyX/HqAq/CWV7ZpjSVNrxakFfxovmKV1q0V4lXvJIKna/sv5q0V5L5ynw1plc/nj8oTdyrwT4fJ5mvuufqK6+2reovvArmK5Xu1WYwr17Wg3941Z9XsfWqybza7L3KPsrb7ild93r7+mY/r6tRvHqZr/7c82pYryKvxKtL5qub8bx6XIBXdf3TfFXzSte93n6ia07rwYl49XtRXpmv1ItXBx8ltwivnnh17YvnFK/u9u8PRpeLEa9O9+ofXg0/X/FKfbzeeNWbV80SvIqRVxrs9ZbfnKVX6dCr34N6lX3/37e84pUKagrzFa963L/AK83Uq8G23mbNq15w+sqryCvxilcFerX7MLzoJBzx6tQSr3glnd/daF5teDWEV+l6rywYxSteTdirMy5BLi3Aq9+8GtyrW16JV1d79TygV7949XIzXPfdJV7xairzlcSrqXn1ZwHHi756Fa7yynwlXvFq8Plqv7898Eq8sh4sZD3IK/GKV6V4ZT0oXvFqol6Fs/dfOZ5BvJqaV4vZfxXOXQ/ySrwyX5WyHuSVeGW+Gs6rKl8Pnu3Vqd9d4pX5qtv5ilean1d3g13NnFcDz1fXHS8qTXG+4tVSvAq8Eq94xSvJepBXHXvl+gziVdde3fNqnPnK8Qzi1RS8muv1+j5+/mCH85XEK+vBXuercNV8JfFq79WKV7ySeLVIr757rnOvKl6JV7zilcQrXnXqlfWgeMUrXkm8cvzVyZ1yPMP+B8wr8YpXo2F12nzFK/GqY68eeHWJV2fOV+EMAB1bKl7d8WqM+erE83ECr8QrXk1kPfjTfMUr8YpXg3kVvvPK9UXFK16VMl/xSrzi1Ty9knjFK15JvOLVT165/pV41Y1XjhcdwivzlXhVwHz1yCvzlXjFK/OVxCteneDVx/Nxjh1/5foM4lU5Xv3iFa/EK15Naj144FX1ySv7r8SrE6utB0fzquKVeFXMfLXI/e28Eq94xSuJV7zq1qvrjmdgmxbt1cv+q2de8Uq63Kv1aF794hWvpELmK14N5tUFx1/xSov2KvFqXK9cr0+8Ml+Vsx4M3Xx3aTSv7L/iFa9kvuLVtLyKvJL5ilfT98r5zjJf8YpXEq94dZlXricj60FelTZf1cHxVzJf8apMrypeyXzFq4muBzvwSuIVr3gl8YpXWfa3i1e8KmW+4pV4xatS5qvX9WBlPSheFe/VzUx+it4fFK/MVzPzas0r8YpX5c1XzncWr3jFK4lXvBrPK4lX9rfzSuIVr3glXnXp1b95NaJX0fFX4pX5ilcSr3jVhVfry7yydhSveDWcV9VV8xWvxCteDe5Vdny746/Eq9O9sr99TK98npfK7NZ8Zb7ilcxX5iteSearq7x64JXrM4hXvJqsV9ddT0biFa94JV3s1WYwr+I3Xv3Nvdrw6mqvqnY9uOKV5uPVYFvvR6+eeNWbVzG7xSvNx6uH4bbeD+8PHnh136tX6SHz6nlhXjW80ny8Gu7qdVuvngb0qs5u8opXKrW71qg6E2QIr9p7fn56POJVR3+izKvqnleOZ1Ch1a0aaUCvms2/f7VWPP/KDPmz+Xr6u+pRxoV7Fd7UzryqeaUCvWq32PQ8nFfp7u9j+27k08E+8NyrjjxZulfh3avVZV5JU/GqydaDw+2/Srd/7vcbT/X4kBn1nP8pOjrCIvdqk603n9Yz+THuj7E60SvX61ORxfbv2fg43LRRrx9vU0vIbXbP97khTUePMrt5m02Rj6slzVeOZ1DxXuWLrzTc3abbpr3r1SpzaZ2O/PE68uomm9/um5n8GE/0yvuDmo9XzXD7NGJ9k9q7blJm1Kru3qv9Wmm7Dtrf1/a/reay1ZqvtLjq4V7DITZ1u43V+T2neNp2eJ5XYff9QtzdcWhvVqW/B9bvfOX9QU1x1hrudbn1Kttg6nykqsN3W0q4aDsK7c7orVLt7bgArwKvNMvCkF6lU7x6/ROFfKOJ1XmrxfBKVebVIVIL8ap69yrxSrzi1YS9an+9zr1yvKh0oVfxO68ir7rxKrZIuR6ydI5XuVHxe69Ctq4JvLrCq3iZV9LSl545Swdexe+8uuCOeHW1V2wTr0bz6ssNcWle2d8uletV4c9nr15JvDrVq8ArXkm8WrBX1nhSCV7NZqcVryTzFa8kXeVVMF/xSpqCV2H89eAijmcIvJIuYSSe6VXgFa+kUTauvVeBV9aDUknzVfjWq8irHrxy/JXEq1LWg87Hkfrzyv72br1ikHTqxnW4/+rAq/DJq2i+Gne+knh1qlfvV8rilflKmsJ68Nvfe9XH9jhelFdSt/PV0U2vh/lqlltq515lv5zYJvOV+aqY+YpX4tWp20Ps1qtZbX0xG1lP+M28knr3KlTeH/xeEl5Jk/Hq8k9GnLlXw8xXEq+OWPHp2IY3r8xX13jl+CtpoPkqmK+698p8JfXnVXA8A68kXpX8PJ7oVcUriVe8knjFq069umY96Ph28YpXfbjFK4lXvJJ4xauJeuV8Z2kKXrn+1atWvJLMV/Obr5w/KF5Nbb5aklfmK+mcjeub6/WZr3glma94ZX+7xKv5eXXWd5d4xSteSbyaT8Ncn4FX4hWvSvFK4hWvulsHur6oxKu5zFeV9aDEK+tBiVe8mpBXbBOvTvYq8sp8JU3dq9B6FXjFK2ksrwKvRvXK+4OS+cp8JS16vgq86tarwCvpYq9+2CZ5xSupJK+8P1j96BGvJPPVLL2yv106dePa+3MCHrzq2iv72yXrwal71T4LIZqvJF6ZryRe8Wo8r9gmXvFqzPUgr6RzHDnLK/vbzVcSrxboVeCVxCvzlTTHjeus468qXh17btrH5Hp9Eq94xStZDloPFrMelHg1iFdVxSteSbwqzauKV9KlYFkPljJfsU28Ml8NPl9d+BzwSrwyX/FK4tVyvDrreAZeSRNfD5qveCXxqjiv7G+XrAeL88r5zhKvJr8e5JVkPWi+knjVgVef741XvJL686riFa+kUryqeHXkuWkfU7fHXwVeSbyawnx1wvEMvJIOCOHVWPNVMF9J5qtpe2X/lcSr4taDr5/nyCtpal69/d94dZVXErDMVwN7dclfFpJ4Na5Xpitpil5ZD/JKGtYrx7fzSirCq3Dd56Xyqrr283HoJl6dMRyZr0adr3glXp26pfCqa69cD1mapFfWg7ySeMUriVe84pVUpFcVr3rwyvEM0kTnK9dvr1yfQRrUq8p81cF8daFXEq8G8irw6tr5SuKV9SCvJF7x6muvQutVjF6CUsdeVR2sB3n10auKV1Kf8xWvOlwPvnzNK4lXvJLO7f/WHfU1MGsKAA==", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0065_20261007_100741/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0065_20261007_100741/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e072bf94ad4bd54bfb1edb635434f6ed312f3a4c --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0065_20261007_100741/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0065_20261007_100741", "meta": {"split": "train"}, "act": [16, 579], "T": 580, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0068_20261007_100853/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0068_20261007_100853/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..1b93e345e20f296aaa9a604f1f59dc3b8bdf30eb --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0068_20261007_100853/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0068_20261007_100853", "meta": {"split": "train"}, "act": [8, 624], "T": 625, "maps": "H4sIANm51moC/+3di3qbOqKAUSTAdpK2e8+Zef9nPbk0IBI7NjYXCdb/ne9M0p3WsRtWJVlAVRVSeP2/GP9+HF4/Dt+/pE4+aWL1+un7V8XY/U5J4pUkTeJVxStJK3lV3epV4JWkNbwaqHPNqzq8s/b3t4XgFbz9dV7nN0sbO5bCt4/jhf9chQu/LuRISxxHn/CE8wiFFK/4Mb769MphKKmUf/f369XHJDp8fxUs6UnKrDpW3Wpfv6Z3q1fGpZJ4JUnfvKq/eBV/9CoMP+GVpIXrdn+Ec+9URF5JyqZQ3efV9a0gwexR0opexc+vf/Mq8EpStl4FXkkqwavB1/NKUmZeff/abnk+XvUq8EpSGV5VxlfS4gd0eH8vbPCGWLjvD/r6W3M4iOOY+WByHmaItz1fXknrerWlyzrwSuLVJr06T9ctU0BeSbxa2KtkP4P1K6k0r4qf84zdL9p9Eu97GElzg2V89XV/e2V8JeU9vvqL1livch6EjdsvmnjljGdpk/PBkLFdC61fSTK+etyrm68n88Ur4ytpV+Or8rxKXgIXeZfMB3klaUWvch5rjbq+aOCVZHzFK0nGV1e9qkbMB4P3B6X8wbK//fPzO/eLSlreq7BvrypeSaXMB7/vvyreq7HXZ7C/XeJVceOr4PxBKWuvul/aztO7e3zlfGdpk+OrrPe33z++GrOjgVfS+l6NOFg3cP32Kobeq8grqaz5YPH7RUfuZ+i/JPJK4lVO46uvzyIZX8WaVxKvsvUqDLwyvpJ4ldN88KtXg/cHI6IkXuXjVfz2lbd7BS9pHbAuno9zvw+lezX2DjmSVvGq2pBX4+6P43wcqZj54J37r7bh1cfnvJJ4Vcb4ildSMV51vzSOn82cj+P6otJuvSpvPpi+Kn40JF7l7VXyfiKxpCzB4hWvpH14dYtdZXgVU6+sYEl5e/Xw9UU3cj2ZauT1ZCQV4tVG54O8knLkaqbrixZ4/SvrVxKvSpkP8koqxavul+7nZyP7r8bu8WebxKu1vAq8krY5H8zZq7H3HySQtJ/xVXZPj1eS8dUmx1fW26X8wZpuv2huuf6VZD5YyvjKfgaJV7ySVKJXm9wvGnklGV8t/PSMryRe8UrSml59fFh11y4fIrZ9rwKvpJLGV7zilWR8VZRXIVhvlzbvVXZP72avwsAr7w9K+XtVpV7t7fxB4yuJV6WMryrjK4lXpXiVfEIriVc5zwd5JfGKV5J4dTXXk5F4dXU8UxV4vnOIxlcSr0rzqnK3VIlXiz+9kfPBsme/Eq94JSlvr0Jy1IZij+Cf54PfbXO+s1SsV8UPNnglGV9tYz749aXglcQrXkma06v7+NnI/ehpJfGKV5Ky8Sq3xp2PwyupTK/2sJ8h3jZQlLRJr8q+XyqvJF6ZD0qa06ttNPZ8Z9dvl/Yzvsru6d3sVeCVxCteSeLVpF4NnmEIrn8l8Sprr9zPS9qTV6Vf/4pXEq8KHF+ZEUq8KmF89SGWpE17lVsj7+fVf0mseSUV41WWw6UFvYq8kniVr1fJpqtg/UriVSFeWW+Xtu+V/QySeLW4V8ZXEq94JYlX03nlCljSDrzK7und65X1K4lX2XoVKuvtUtFelf/0fvSqujS+4pXEq1Lmg7ySivKq+Kmg+aC0B6/CWaj2cP6g9wclXvFK0ozzwT16VTl/UOJVeeMr61cSr5Z+end65X5eEq+y9irwStqTV1s5f5BXEq94Jcl8cFKv7BeVeJW3V+7vLPFqxcZdv/2HoZckXuXqldGVxKucvap4Je3Jq7LfHxzzzQNN4tWaXvXr7eHq+c4BXhKvVvJq7PWveCWV7lV2T2/M+CrZzxB4JZXh1Yauf2X/lcSrzXvlen0Sr7Ly6rtfri8qFeaV9asb3h+UVK5XIcvje5xXFa8kXhlfSeIVryRejfbqzt8wc+POd/b+oLQnr3Jr5P1xQu9VdEUZiVe8ksSrx+eDvJJ4td7Tu398Zf1K2rhXW7mejPV2iVdZzwfTb91sUOJVtl4FXkm8KtMrs0GJV0s/vTu9CsZXEq8K8WrkN280Jq3gVUi9CumVsML23x+8//xBXklre/WID1WZ9/MK3cvCK6mc+eA2bnZ85/hq7DuEvJJ4tbRX/Zc4H0fiVdbzwbv3txtfSet7FZLD0f1xeCXxKsfxlfsPSpv3ajPnO/NK4tVaXlXu7yzxakPzwfR6fdH7gxKv8p4PVv34SlJZXmU0s1vGqzvng+aO0qpebeTp8Uri1ea8CtVgAuwKWBKvsvbK9dulgr3a2fWveCXxileS8psPbmR/uyu4S4V4VfGKV5LxVTleOR9HKs2r4jeNjrtf6mD9yg53KXOvHr3fRLleBV5JO/Oq7PulDu6PwyvJ+KoUr6xfScZX+XqV3n/QervEq2y9GnzPvJIK8Kqq9vz+YGU/g7Qjr7Zyf2f7RaV8vYq8GswHeSXxileSHvPq7fYKvVdxl/fzcn0GqVyvsuRndOP2XyWfbFWrMDQ8hiuvxPBlCz/fH03ilfHVtIB3r0NdVc3bSxCb6nBxlhzq5IVIP67c8Uy84tWs1d3fb2yaqjq8fVwfmuPwS9LXqElepbZ2xIhXGXhV5ehVM/kQJvGqbavq+AZQczw8JS9JM3xN2uSTgwNGuXm15/ulZrb/ahavOog6r04Dr1peqbjx1S7vR999fSbXZ6gn/yb6P7B+8+p9PtgmXtVN2zbdo7/+KPBKvFqqkdfr678krLaanCwRhTrO+TifEr16dUoU64Bq6rdvhlfiVY5ehSzmg4lXcRmvDqlXTepV5JV4lb1XK17/qm6S72chr54uja94pUK8qr4atQuvVh9dVU3v1fv60XwP1OlzfDl261dNe+jAauvXBl4dHTBa36uq8yqkXu3q+qJDr1Z7trHt3xRsqzjxfqe6W0uvju3hU5/T79Nz59Wh9+rQvHmVjqlODhjlNL76+HiPXoXBVy/wbNNHeL7o1bTfx9CrT31OfxKvjsehV3XiVcsr8SoXr8Ki95tI//yXi15N+10ki+mngVcviVdN4lXT8Eq8ynV8def9vMIj39rQq6p/h7D9em7M414d2n58dTz180FeiVe8us2rJvGq7k/SayffMHqHVw2vlLVXwXxwYa/agVfdoKqZfnzVJl4dEq+S9XbjK/Fqrad3s1fV1/t5zb1+VV/zql7Jq5ZX2olXRd/Pa9n7D573KkGqaZfz6iX16tB7ZT+DyvAqJEaFjc4Hv74U1Vrjq+dzXrWrePXjfgb7RZWVV3+HGdXezx9ceHx13qummhrNC/uvvoyveKXivMpyejevV8mTzMGrw7xeNf3+9l+37m/nlXiVy3ywqtZav0r2t6/vVTMcX7XGV+LVQk9v1Phq0eshX/XqbR8WryRefffq2/6rJb16WtWr48uRVyrbq71dD3nx++PwSrrLq8r9cf5CnYVXVe/V1I95yasnXsl8sFSvqsArXsl8kFeZePXK0mivGl7d/WPYb7gOg4+rELw+vLrHq2rR67cnGjWLedXc4lXDK16V49Uji7y5TSAfON957m8/uY1yc1rFqwOveMWrMr1K9zO8X110Fa/q/ortsZ3+DMYLXj3/MB90vwlelTAfvI8fXmXuVX3Vq7bzqv3mlft5TeJVlX5c8eoxr+JevaoSr8KuvDok9/PiFa8y96pyv9RPqboh+6Je9fOs5A5ey3mV3C/1u1cNr5S/V1kOl2b0qsrOq4ZX0j69qsZdT2Zpr2LqVVzCq7a/dt9XrxpezTAfjF8Oue5jLw+veDWVVzH9Pnk1uVeWr3j18HxwNa/q+EbExzfXxDi1WLwyvtqAV4FXfz9f0Kt6Xa8O5oOZeAWsx7xyPs56XsUlvbqw3l7zSrwqzasF74+zklctr7Qpr0qeCvJqkvGV9SsZX/Fq4NVhea/aur8v6qn3quaVeLVWd663L3y/1PqQ7GfovJr+cshDr/rzmnmlMr36+Lg7wLdzf+dq3PXbl72f1+pe9RcO5ZUKG1/tdj9D2L5XNa/Eq3yf3p1ejdzPEO6yI/UqXcDKaj7o/UHl7ZX7TSzvVb28V01zbj8Dr2R8VYBXX+6XGrPwauorIg/2M6ReHXklXhXmVXXv+tVkXsXUq8graU9ejbte37L3S113fJWuX/FKpXu1x/2iVW7rVwt59dP7g7wSrzL3qtrX+IpXKterR/a3F33/wQy8CjOvX9W8Eq949ZhX7bnxVax4JfHqkldxfa9C1l61Dhjxile8kng1+H6y9arqvao6rwKvpFu9ypKfPXn1/jW8kn72avYrP63kVcWroVefEh2OvBKvsu/y9fr2Nb7ilUry6u/9By8dxrvxqjK+ql+R4pV4xSteSZN41f+/wbr0Nm6SM+blWMureiGv4nmvDrwSr3hVhFcHXqkgsHqq9iNTXl41qVeBV9KtXhlf7cGrV4tu86rmlXhVClwzfPV3r5qFvIoDr2peiVc79OrR8dViXiUP2V8L63Dob9T1OkvklXjFq9u8qlOvpt5ZknoV+/FVe9mryCvxile8kib2ascvyw68ir1XbepVzSsV4VXFq79arbbeXqe/PueZBrEa71XjeBGvih9fbcqrpuGVivRqr+tXYdX5IK8kXpXi1XzrV7d5Zf1KvNryfLAUr25ab+eVeGV8lcF8kFfi1WZa7fqiWXllP4N4VZRX1Ua9Gj7ZzqvG+Eq84lUhXpkPilfmg7ySeGV8NcmTrXil8r2q7G+vdrFftPPq5vUr+0XFq/LHV4+ej1PzSnp4Pmh8tYxX665ftZe8qmPklXiVqTuxf+ohbn98dXU/A6/EqyJ4W3a9nVcSr8YewsZXvBKvSvHK+IpXKter3e2/WkyrnL3qr3/FK2Xv1Z73M/Dqx+v12S+qnLza+f3o471mF75+1fJKvCrrdRh+EkZ8fbOx9XZeiVe8Ks8r61fiFa/y9sr7gyrDq4pXt3p1+TcbX0nLebXP/Qy84pWMrwrFa7X9DM2612doeCVelVEO+9ubdcdXzeB6MrwSr4qbHi45H2zMByVe8Wr0fND7g+IVr37yKqfri/JKvOJVxl41vBKvitNqV/PBOvWq5pV4tWmv7inf9SteiVdbng9O6FW9/n4G7w+KV7zK2KsDr1SoV+7vXO3uesgXvKp5JV7xileS+SCvzAclXm3bK+vtUrmDzl2st1e8knY4vtrO9a94JZXmVdiFV8ZX0h69Mh+UxKtFvKp5Je3Fq/uO4qzGV/YzSMZXvJK0Oa+aIrxyf2cpo1bbL7qGVzecj+N+9FK+UhlfOX9Q4lXe46ur7w/GWHdeRT8tEq/WG1/ZzyDxqvD5IK+U23HqejK7nw/WvJLK52sn4yteSUVrxSteSbzK3auaV9KmvXr8ejLGV5JGQ3WXWI96FXkl6U6vAq94JRlfZeSV++NIvOKVJF6ZD2rvR6j7OyeH8ErXk+GVxCte8Uq82tHEkFe8Eq94xSvpca/2eX0GXvFKvCqpuN77g90nzRpe1cn1+vp79QyuL1rzSrzaxvjqYa/6T9qlvOq/5zaB6FWr7htoa16JV7z66lW9gleDWV5MvpM69kO9OobBadl+MJSJWrxKX4EF1q9i6lX8BOrYfP56vZRX6aOkPDXvePFqM8f4dp7Fbr0Kw1ciLvcq1OfsevOqWtqrb36F2I21eMUr88F8qpMn24S44FwspgOZbkwVj92vtivp8L5q9f4dxnR2+GUKK17xapmSNaKntu4mYL/r9rk/Oq/eyzieHyvd4VWyZBSPdeLVKuOrzqvAq215FTZwhO9xv+ih3+70cmi7ccx/msOv/og8zO1VTL2Kn14dOiDW8iryavNLH7wqqWPbDZ5+Hw7Hz0/+2xz/NKt4FXuv2tSrdX0wHzQDLMurjXY6/MUoPP05HE9/P3n6X336t1luPnjFq7i+VzWvtlPc2fiq++T968v26titav85nJ4/P/nvq1ft7QY96FUCwStXn7sy3434+Lipc/Aq8mob45L0naVY8PN4B+vTq/Dx6SdQITUsbsar6nDBq6d/2xGH84NeDf6s5tOr+KnC68cL7Sevk7/P10cN3ZuVb+O+EFPAHPflzinODu2nc+TiL4VkEPS4Gx9CfQ4Wv3gVt+lVv3xVPdWHp0+vfj+d/owQYg6v3v4e/r4ZUM/8QzV4J7T7Zt7Ox+kW0N7nqSGhKjruS+3l+NiP63VH+j/+76bjkPyHybz6OEq6ye3bm0Pdx6FJjfocUMbCZ8DDf3Zi2/3L83I63unVPQ0eKbbdGkP99+O3v/N2YiDi2enoIcbum2liTL3qRlcx+TeZWqVMI5KZ/O/eq8NUw/Z0RSz5eTrGw9+fk9D9oP39QZrKq/oTqRj7n8zYn6ofEpVn+nFdcgtYP8lpmth59fq/v5tFHv+7V03/Mi/jVZPs3KjrxKt+keqQrFfV7YXvXPmW7j3+039ybCY/YOuYePUUT4lXHz9on1uQpzl6U6/6FdY69F6F2b1K/wWf267eq7o6nI79cfhnXa+qxKtqXq/S+WByunVz4VIRyTdYWcUqzqu6/nOYeHwV0oM0xsMh+WSwCnxKHm6SFdChV03oYVpygbVNX8Z25jlH4lX1dOo/vnO9/VE7qnRVOywz8+r/7Cb5p6IO/cdNOgeseVXIJDD9h6g7pOJL/2Ezw19gPD4nf+rplPynZCZaxXaCx3qFsu4X3Jvun9KwqFeHU3vuX4aZD9bXD55P/cf/OdwJzoRLAQutDYb0iaRvBPfvFX45GXzZ7093DqqShcn+PI7medZTY2N9+pUMN56fk//27yll7coP5S3gvL0/2P1shrpu49kf07lf56fEitO8R2jvfOpVuNurEo/gHqZwwavAqwI7nfUq/eU5FjeaU7JAVv36lfy3/0vxunJgx1umqkOvYupVvdx7Qf0+qNeemlmO0PNePfX//d/D7X/YdrxKz4QdfNw/t8HHUCjDq/QcjdOsD/rq1T/JI/z6vX2vjqlXzzN7VR/Oe/WffXp1w5gKUmV7Ne8Ky7tX/QP/enlezqtm+16FxKvTfV5V+x1fiVfXvHri1RJe/bsnryrzQV5tYXxV79Oramfjq9u8OvMxFHi17viqTr068GpnXlm/4lVJXiUnDVZZePUy9/uD59bbt+/VeYDGjq+Uc8fzXh1mfdDJ5oN37L/awfrV+f1X1a7Wr7Rxr2Jyx5IYktNlZvi3p61P/6T7r9L9DP99SY6Zp6m9ik2zzn7RQ7pfdLH9V6E/H+ftP58/HyfySqV06L3qt4uGxKswi1dPN3lVz+tVXHCZdejVzLOhgVfJC/0Pr7QRr8J6Xv1OvfrfCK/CLfO5H8ZX9VpeLTi++rw+w9sO0M6rkF6DkVcyvvrRq7iaV+0lr2Zeylrdq4pXMr6a16tnXvFKuuZVO7NXp+W8qlKvKl59fDzGK+naj9yFlZXJ7eDVPr26+syNr5SjV2H7XtW8+sj7g+JV5l5Zv/ruVRg18+OVzv5QnLkkYuDV9rx6LssrKZdu8Gr6BzW+Ws6rp/P7r7Zx+27xauNetckdNpodjK+e7GcQr8odX2Xg1dP6Xnl/ULxa2qvx5w/ubnx17/oVr5SzV1XiVbWsV7+W3H/FK16JV6t4ZXxlPihe8YpXvFIpXsUNefVlf3vb8opX4lWuXr2d3csr+xm03flgw6sZvVpnP4P1dm3Vq029P1jv+f3BCbySjK8W8+rL+OpgfMUr8WoGr6a4HrLxFa+0Ra9a46uCvTpnjfUrGV9N0OmfA694JRUyH+RVMfNBXolXvOKVtCWvrLc/7tWp//V/eaUtehWNr4yvJF4t75X9DOaD4pXx1U7mgxKvbvFqkvUrXpkPatNeWW83vjIflPHV33jFK8l88PsX8erB+SCvxKspvLrtesi84pW27FW6fjXDg+7Qq9Z+BqlMr3a4flWYV4FX4tUa4yte8Uq78WqWg5dXvJKm8ipuaT74db9ozSvrVyqz9ppXWY+vytnf3ro/jjSTV5va314l88GQelXz6iavJF4ZX2XtVWV8JV6t4FUwvuKVtuzVzOvtzQ9e/Xf683HCLrw6JxCvtDOv5mhRrzIcXz0bX0n3tMr+q2W9st7OK216fMUrXvFKvOJVZf+VNK1X9aw/rLwyvpIm82pL7w/yilfaj1cz/LTu/f1BXklTzge35JX97byS+WCJ4yte3eZVCNbbVeT4qnivjK/u8Mr7gzK+4hWvpDnHV6WvXwXr7bwSr3g15nU+8Uqa0qu2/wm1X5RXvFKB4yte7XA+6Hwc8YpXvJKmnw9uar8or3ilnYyv5sj61XJenaxfafPjq3rWB929V3M8wrRehYpX4tXyXgXjK15p0/PBTd1vglef/eGVNjm+st6+8/FVxSvxyviqDK+8P6iSvKqNr3Y9H5TK9Kri1Xa8au4aX0m84lUpXpkPile84pXEq69fxas7vKp4pQK9cv0r4yuHiXh1watnXq3vlf1X4hWvcvQqjPiTJV7N6lWG6+2nOU7P5JV4tYhXv3jFK8n4Kk+vzAelWbyaI/NBXkm82vH4qjrzXHilvXllPljG+Go+r6RivLLevsf5INDEK+Orhbxqznv12/hKvOLVBr1yPo54xSteSVN4VbfnIJjnfhNP63nV7syrwxmvIq9UZIf0mFrqR7Spn5Zbb/96/uBaXrXreHU8GF9pk14dzh/uhXuVyfjqmHh1XMmrX/VdAvFKxXi15vgqzjC+avbk1dtfavK4L7zSdrw6np0X5Dy+uuW436NX9Riv7L/Sdrxac3w1iVd5vD/YLOhVSLx6nf/2X/J8s1fGV8req9NaXt39/iCvrnuVzA2fI6/Eq6y9+uH6oul7CTMflM2hWcWrpml6kXuv4phnzivl0nEtr46Lja9Sr17HG4d6t1498Uq8mtarEffHiaPHV0Ov4n69GrO9ilfi1fpezfLe54XqfL1y53mVUtyDV6H3KjYDr5Y7VAdenXgl8WqsVyuNrzLwquaVSvbqFNunrXpV73h8Vd/llf2i4tVKXtXJwnoWXi25flXX57yqeSVeFTa+qrfqVTzv1SkZXzU/zop5pby9etrh+GqrXiXPq+lfo9icQkd4sqwVeaQyalKvkqshzHy/qaY+pV79XnA/Q5161SznVZzdq3jWq7p/jerm2HEVkuHV1Vkxz5SJV3XiVf28Va/qzLw68Uri1fnDLQuv2rXGV/ETndSr2PJKxdXW3Z6r57rpvWrnnoZe9up/qVfPk4+vYupVu5xXYS2vwuNeSdl59VK3i3n1OiO6uN5+u1cx3ONVswevkl+ue68+L8ZhfKUtePWyUa9i4lVIvTrUi73SoUke6ziHk/d7de1V4JXy6MCrpVrQq3jeq+bzr/XNqwOvVJ5XTeLVoZdi9jtP3OZVM41X6YVj6h14NRxfBV6JV496deDVEl7V/SXb65B61fBKBXv1a1mv/kzgVSzJq/SMvcM6Xn3e5musV1IeHROvmuPLcg889Gpwf5zUqydeTeZV6E4lfF9v55V4dbtX7Upe1St5le4aaOPgrtnTzLeue9XU5oMq2qs28erp10pevVy6/+BEXoWLXi24UzL1qplDgAvr7f3L0LRVMr7qf1/glcro1B5X8up3sn3y5eUur+r7vGrX8SodUdWLedXPQl9fhtSrJn0dHQgqz6vn/Lw6zTC+ysCrWJZXxlfam1dfTvo4/U6oeU69+r+nmb1K5oPHBdeveCUV69Wv1Kt028J/Eq/qn70KId4ExcCr2K7v1SwA8Epb76ntthX8bn4tNx+Mp1/tBa/+SYyKh0m8qoZexSvzwVDk8Zm8FjFZz+8wers+Q3950UTtGOxnUHletX+W86o5viTTsqdj4tJLalRz8zF6ZXCXXCw4uehVu5mV5pCeNJiSXCfnO6f7RROvzs+qx9wAWlqk57btvfp3Qa/aU7qtoE0GW4M9URN5EpIhU0zenEs+LPyYjAOv0nFjf6H2pruZ96tXx+p2r6RMxlfdnpzqV/uf5faLNs0xWUZq02tDtTPMTgZebfLOxrFK7jNYH9NLfHV/w83huXs9kpXB9FY5570yX1Qenfqf1VPz72m5wys26TBnYMikI50PqMLgzbkt/kUOvIrHftHqq1fvL+8Xr66Nr3ilPDr0c4FD/eew3AOHlKXBe4e376W85Qt5xSttp/697BCOS+6evPhYcYxXV7/j8E7V9r36Akwciv3x7Ov68PnyDs/HiVf+LbDerlxKfhabwry65V/+716F82vs5R+TP6zLhb+/+vn+4NsA676fESkXr5a8z+/lPU68evxvMlyaM/Zf4O0/lf4DvwuvNjtauPq8eKVN/ZRncSSP8Srs3Ktxq0yxR4pX0uJeGV+NemVTr6xJSUs3zqvwZSa6rfWr+bxim/Y+DQ2P/87R+xl4xSupFK+qvXsVeCUt61X6Z4Trj8Mr4yttB451fi7v9io9c5lXvJJW9erH9wfDF69ueJwfvNpZd+9n4JV4dY9Xo68qbL2dV9J6Xg24GTcf3L1X4T6vJF4t71XFK15JC3pV8ephrwKvpCW8iryaZnxlTUpaYHw1jhheGV9JvOKVxCteLeKV+aC0qFe3PY79DLyS1vFq7DXqeMUraT2vxg23tu3VuOsQen9QmtqrMJ9X1b692tdoUspufHXLEc0r80Eph/HV7r269RkEXkm84pXEqxv/mH17NS5eSaV5tef7efFKytkrnfHKfgaJV7yS9u5VMB80H5Q24JXu9cr1GaQJvBo1hmKZ8ZVUilfilZTVfDCwjFdS+V6JV1JOXolXUqFeOdAe9sr9B6UsvXL+YOJV/9RD5JWU4fiKV7yS5vLq6u+869F4ZT4o5e6V+SCvpHLHV84f5JVU4Phqr17ZzyBN5NXc7w/u80h1PWSpRK8Cr8Z6xTbxildrzQddr0/iVSlemQ9KvOKVxKsHvLK/nVfSfF5d/Z13PZrxFa+k3L0yH+SVtKRXIdw/cXM+zgResU28uvOLja8e9sp+Bmm0Ij8KFC588V2PxCvjK+kRRm4/Rng1wQvOK6lAr5w/aL1dGuvIzZ4FXpkPSnl69f5feMUraXfjK/FKWtgrw6MpvbphP0PgldT9+P8VKNxwuBhdrTC+Sv5zwyvxile8kkrwqpvh8YpXUv5mhVsPF15N7lVlvV2a0StmTeGV+ztLWXtl/9UFr/wDIPFqi/NBiVe84pXEq3Nq8YpX0uRefZw/OJ9Xrn/FK2lSrypezfvyewkkXhlfSXpUR17xSiqDqs3eH2fUd80rqWCvNvDs7vLK9ZAlZQ4bryTxSpJ4JYlXvJKUc931GYLryUjK3CvjK0nrTvPm94ptktbyyvWQJW1oPuj+g5JKmQ/ySlIW46s3gca8P8grSWt6Zf1KEq8kaS2v3N9ZWqiPLd2bvd/EEvsZeCXxaj2vxu0XrXkl8WpJrx4439n4SuLV4jkfR+LV5r2SxKuFJ468knL3KvDK+EriFa8kzTof3NT9B3klbXp8tU+vrF9JhY+vzAdvx07SuuMrXvFK4lXeXrk+g5SrV8H7g9+eOq+kIrwKvKqst0u84pWkSeeDO76zMa8kXvFKEq94JfFqj9nPIPGKV5Km8eqvWrzilVTc+CrwyvqVNI0vA2p4NeXr6voMEq94JfFqGq++/VG84pVkfLVVr9gmLXOc8ioZWBpfSWV5tef97XeOmNgmGV/xStrKgTT/+lXY7THJK6m08RWveCXxalteSVp/PuhllsQrXkm8msCrTe1nGPUKOd9ZmvbYC/0nxle8kswHd5rrX0lzeRV4NfHr6nwciVe8kswHeTXxfDD0XkVeSbzilcSrObza2f5280FpHq8Cr/Lxim0Sr4yvpM3MByf583jFK2kBr4yvpn1deSVNYcrZj6f0Knp/kFfSxGOqBbxyP/rK+YPS4vPBWw454yteSUt4NWZc9rNX0fiKVxKvyp+CSxpz8IS554OpV5FX7jchTePViPX2eNOfzSteSaV6Zf2qcr0+yfiKVxKvjK/W9UoSr3gl7cmrcPWP/mk+uNfznXkl5eiV8dU5r1z/SirEK+MrXimz/h9YbPqesHELAA==", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0069_20261007_100945/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0069_20261007_100945/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..556d08bf5e08d090dbe73edc97994a2e7209fa10 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0069_20261007_100945/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0069_20261007_100945", "meta": {"split": "train"}, "act": [9, 591], "T": 592, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0070_20261007_101013/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0070_20261007_101013/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..42e5f424bffe06751ea39486e45c3d22c43a3277 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0070_20261007_101013/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0070_20261007_101013", "meta": {"split": "train"}, "act": [7, 587], "T": 588, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0071_20261007_101041/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0071_20261007_101041/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..bde6f1c25f3a6ea0b941ba2884516317f5984985 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0071_20261007_101041/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0071_20261007_101041", "meta": {"split": "train"}, "act": [16, 730], "T": 731, "maps": "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", "force": "H4sIAN251moC/+2deVhWxffAX3NHUsQ91DAXXHAJXFDL997zgiISgoLihiiIS+6SS2piiIorivsGihuJCYJoPmpY7ppiYUpuuJRmWphbpeaXc3GG++LM3KGHP35//O7z9OSDH8+558yZM2fmzgwm0/8/xfpE9+4kg9mlTFWk5Lltl+KUOIdi5SL8usu9X2xSsXJja/sUKxc8IcvM+7uLqVfp3ynJy6Q4WXmyXO5XFeXsfdL8/zSXc/ddufZoPEaR8YtIr56LmG9SirN9ZeXlVm8nxYn6uaw8/ZNp41GsnKxeWS5m/ydSnNvtLcWaN3Jc5hfr+zkOjDZLvZ/zgGKVF1yugpS85LGucvneeXixcrJ6HRu+MBcnpxx0lupHji5KseYNWS55hI2cvBs5cuNRiYdcbtyR2opMnkRu1dXSKv75RPpMIbc28UfFaFxFrlXnm0Xiks8uF3JLHvytyPRfIk+2n8vmDcfjIcXKFXd+luVEcYoP8XNxjft+k/4wjAN8SFyJ/Idx79Ezv72M6gPC5TxfIuSI3uLmjPKLDKf3n9F4JMPp9RrJI1zGrk1SnNG4T/KLdPw1qyXVz404WXmycUo4UZ7E561XJs3eePu6Uv4zkieTd/VcK7NTscrLSOxplmkP2bgyqouJPCM7SHv4Nj0gpTe+6UihXhIvJ5pfl4ort9+SpeI00/ynFFeu7MFi5QIX75Xql0brDDSuFq6TkmfEFXVcMJr/kv4mqq/07ZZxY6hZxg6jR5brdaSyxinV75hlOKN6l+g1yrtUnsF4RLiY4EFCTqZO1Msz0kvkidYF9JzR+CbLeZ1xVGXqRMIZ+ZlwRv2oqP1NdlyQ1WtUj8uM08i5ODqoMutXxC+y/cM0K0kY92qYrVqUcT84KkWun3ve6CTzfhkDg4R6K5+qkS/PYD3Wo6ezKtO+RJ6v/+piiT8iz4gj8saeqCUlz6jOofFnMF+QidP/kv+M4oXEs2lHc7NMuxnVsUSv0byM+M+II/YWN2e07kj6m1G9RttDME7r84tIr35dQLQOordDtl8a1ZOEM6ojCCcdfwbxLMsRvcUtj6y/PCq1xyzKp9v/WqD9/5vBXoqw/qN+jldE60jEfzRv8ubJA6PNWDPW+eGwkDMtze1UqvJO9cyaUiCsN/LG/b2Jv6vnZ7RkcqSexPplcmpVOFSpo5DL2GJrXnCrHgxf+AGTo+Nl3nh0wrMNLGr2Jqf3M8a9u1cHGNuWzdFxOo+rGa1C8p7WQnn5ec0fBg5sI5SH3z+GdB8GvhkuYJR3Oy6YDBsevm8ozy02Ei5fdQVR3Y7yvq8TxeWIvFVNZyuqZQ7cu9tWyGGcjoucD0ermoEVf6R/oDx7y2J4PMAdRHVOxKMA5Z+khZBbpQuTI/GL61d1l86Hn7O6Cjn0C9oxJM0bRHkS66sfd0RBvF8XYTyj/w64RTHfTz8fJBzr/QrPt5K+mgVbN3UGo3WGl00imZy+X+J4iZz73s5Ce1Hvv1UimPZaxV8e973DdJjl4yW2N4+LcJwGHfw/EnI4XiIXkust5PApEfipMZfX35BzD+gh5qJ7d7LzmmTM5T2WZ5Nh/PruQi7D3M+889OJhvLif5ptbjRuAjP+Cq8PITfppTheMO4T7o8y5LDeFXFkXcDzpwQloBGfo3m3gb+S6TiCy+nrzh+HDTfksJ8P+3oI5Iz0MpS3u3kwZB0uiKsOg88phcff+KVLlW+PDIZ/vX0od/3Zc+UNvbN9lbLNBkN6jD/lHBPKq2+Mq3fmadytnADKDY2xUVn1bnxcHytOPxej860xf5lHevWH+YG+8MYchsGtuVQQf4++a/7G+yHnad8HttXoKWw3tDenfi9wDw0Q+znPDpT3KD4QjOrOgLG9IORhX6E8k7rVzJNnNW6VeGjuXr4/lyP9SOl00twiph/XDrqOeaCDMrVJP+g4oJdQL3I7r/U15NAvC84NMLQD65fPPfpBucQ+Qg6/pyAX9bS3IVe6TZAhh/0D7fC9yPYLiTHMB2gHT56+3g1pGWgoD/Ui51a/r5DD9p2xMhC8stl+JnHvGz1Xa7fnCf5gNF994DyIy5G4x3Uu5Fyn9RDqxfrl9Ilg2NjInzleEnk4bh2YyOf089DjlwYw+6VeHvoF49mnRQ8hh37++0mgVb4ScSVvsfKBQjmsc+osCWJy+n6OddPnq/txOTLOIHe2WRDYf+wHou8Lisd+jePJK8ytmCzmcP0UuQu5bP+Rehy5K26BTE7/HQLjqkapfrB5XoBQL45bfhf6cuURDutd5Fj1kNV3l6ejhByt6/L0Yv9dHtudyTVrlkDrq3fGhsDAFd5CeZjHaw0Ngc592Rz5rotxhfJY9YteL3IdegTDKvfuhnrRjpiXfkIO8+71XX3haoa/Yfz5HhsAFet0B9H8F+e1yLHkIUfswPoZ8zPr/fTykOs2MsiQQ3tl5OG6Cr7f2/fYHKnXsH3Rfyx79X7B+gX18vxCaiiUd69hIFOvvn21em1bgKHejIhwM08vi5s+pSuIvmvgd5wt2YFcjswbcb6V7dMPOnRmxympyXBflYijevM418xArh36OhblGXHx3k01//H00nEhz16Rn2U4/bgQrx4xZ7TuCe9t6irkMF+hn404tHdCp57c+SXhMK9h+7Ls1cc96pXhTFEbhfFM8hXqxXbjxfM70/K/Q+F8ATleexAO+yUvDqzyxo0cM+YDQ+513mDlXRbH8wuNqzw7RHqJ//K/a/Rncn6TxlC9xC9GdmB+4fkZ5RH/IYdxxeKwLiHy8P1OhPVi5km9vPiSoOk14lCeiMP/9NzgVz5CDr8z8fJ9YXnIjZvubyjv2epB4LCot7A9sL8ht7jbmxzaSjhcR7dJGAS/hPYV6sX+YTnE16vnIj4JAoeNbO5WjRWUQ708jtqb13+R63dH7BeMv+flQrnyiL343flnZz5H4gDzy9PKoRA2T+wXHD9EHJGHdUn7rcOh7u1gobzgRTU17nyX0Dc4tIHIQ+7euZFcjuQ15R9H83PLGHjnhzAmp68nkWPJw3ej61Lo5yJwt4OHMvsv3e/zmqv6ayiTI3aYBozS7OXJ0+cXnl8wD+nzC/q51/FBnLrpdXvcmSLFYX4JbsbXW5hj2VGYm+fN9x/hsP/y2gM5Yi9yMZdGcu2geWO2r/Z+RhzWJe9HhnE5Gn959YbIf4RDeRtaD4XSbcUc9jdZLiNsBOT0CRLOUzDu0S+sfvlfOJxXvPxyOPP9Cq+3ox3VToUIOVyv49mrr7NxnoJ+NuJQHtrhMCVE2L7KxnlcO/R+NrW80mnKh/z2oHbkPTxO388x/nicPr9ocZ+X73EuL8r3uN6OcWrEYR2G46XNokAhh9/FRRzNL3nxMrPtQC5H+i/a2/FsqCGH48fR2L5g69ZD+H74tCkVwKyzkaPtlvfYB+AY6M6sD/TfNTCfsuZRyK14WJ2uMU/8KBpcw4ZAYZ0nNpWEyeN7UC6s+2pQw8dbcXN8DypORy6oB73b059PWR2X9+exVtwQ73dU5K6fLrCv6bBNgGsNeu6jV23h+t6PrPJi3LhNUPLmACvuu26BMLzbEOiS9Qn9+ZHjCaB8ZV2vjftiGlx7PASyLn8Oyy3+mm/a9d8CH9p4WnG+02JhcaUZsHH0bCBtkvZ8GfhXU6y422kxEN9wLvzUciLgnPqzr+eqqb80go51/1X1/TbgpzkatzktUcV+cmRdJ3XMyFnqhJlrKIff+ndsjdS4UT4r1IMOi5VLkzPydNupH18eZcWFNJkLe21XwrIW2SrGgq/pgoI/n1HBiXJoX/bxJXDqcgx8uDcrT14lJWdkthn3Cui/PfQ9HKaUqRoLl1osghpdj2hcMFRV8OeFY2BS3ViYPmahxml7GKJ7d0r/dg6T65sSAyOqp6hkr0PhvQz47+40joU5wbHg0fAsl8Pn1/RlgP+NOX9ZRX9GfOqO7Btcv8bL4f6s5XAu/Xo+t/Mb5n4zZG7aLoc6S66p+J1oVXgsc59Ft8rLoNL2VWD/w6389d0t+ft91tzfbLU/IsdpDTS9vhbudDfB1NxIxbf1VY0b6JRpxcWNXwvfXN4At3rWgYDQumpwhX/y65P05lZxFZSwBTJn7ACXno00zpTtock5UNe6X67YvxuOtdgPEQ+cYWPb9upc9xYaVy3Auh+t3rsXyu7MgCnDWkLko/pqg5Wqxu1+ad2PXCekw4gtR+HcIydYPvu2Mut0vryL4bZWXI7PNnhv00moF1QC9OefTKYTVva2eXczjL54Eo5Nf6z+efqJEj86VmvjxM1DrLg9GYnw76FjkBS9Vmu3v2a9q/391NySVtwvN3fBoTsH895ln4p6fz+7kLnvMWPVl9AqLhWqzP5O62+d751lcuEPd8Hw8FTwOXhJ47ydxzO5US92Uw713p/H5mq2T4Er0bs1DuP7d6dIITfq1TUV+6lInsfuFI1DvWGmWdz3Q1sIZ3dnMJfTy6t9PITJpTXZo3Glnt0Scg3DUq3sGNZoEJNTA9LA1GI31VuqnReXa3E6merlyUO9hMuqP0i9Hd7bUB6+37KMESqrvhnUcLfG+bjcU2e8+ky1jQxncigvuksK5VjvR+QRDuPZ80gQl4tPLOBy/VUmt2qxMYd5F7kSpVM1Dv8djjOFuYmblyrEL4RrEODB5Ygd6D+WXnyq30+l8nD8yGyqMDm9nzHP87idGSmUQ71Jtdpw5Wn97bVelr2Ew3gmenny0H96e0WcjLw5UalavyTvV7W/A5M7fbDAXhwH142vy9V7fvmXlKtZKlfh2Us4rT1G31B4ev1cU2gc/HWsDvf9iDzkkk81NLQD/ddgu4XJxT1OteJuN2vK9R+JexxX3YPcmByOg139kyl3JqQkk7vhkErtxbqO57/qPVOsOJ7/ZkSlWenlyUM7iDyMF7u3v+VyRB5yjnOzmdw/j9LAu35B++beZcu7OHQPVJ60q6D/SnA4bj2+U0rlccSOG523KlfCS0hxw4baqTw7bMvton7m6b17KJ1y6GdZLjShlaFe9PPdWvcVnjysOYj/ePZG+hVwKM/bqRr3/aZ/k1wkDtvX8WhFJtf8w30ad8XlDyEXOrZAHuYN27k1peSdSS/P5bDGIhyvPZBr1TtfHtp76WmaIuJo/zXQa9RurjP303gW2TE7bZ8Vx7MD/afneOdO+h9fClhjYb2Bfh4bdonJTXo2GzLfStfqRIcnHdV937Hz1YJSHjChx1GNw31qdhdvMuWtPNibclhn76vFPj/9Z8kQqDTiB23eg3N132drmdz+KtPhdFo21J6fP+9JXs2+H6RLk8mwKeoq5XB9l8UdnxEDG1rfoVzu3HAm18BzHXzR7Tc4szJ//3RO5ygm5xW6DdwPP4VnnVQg+3hY3MFtqXA63GQ5N/71+gin3Z4+OQ3vXS5pWfEkf3/epc3xTC6qXRZUHlnOQvcf8eSduAdeQW9bcA+XiFt4rbSlXlQly/z2+fM63vmK8NhylrikypayZ0YLuZPp9pbzLvaW1GnjhdyrxKoW9bC95eM9k4GshzHzUM1qlle37S1Pvp8G5LsLj/P+xN7Sud2nQL5bsbjhlatYSqTZWVwaTBVyFxQ7y7HKdpagnZOAfF8wMZ+yFr+XFS2mchFCP/+28hU4bLG1nHLpopLvUSwuM+AB2IdVsGz4qo3wPNiLnQeg5xIbyvHyWh/nFRC2r6wl5Wm//P1mcezxd1mLPpBVswyVx4tnk+kDsGlcktrB88uvHzSGFj+/AKKXf06pNWTteAxEL65jsiinzDaQkHIftmwLB5FfVl9rB+Pr3ITweuOAfJ9hcR3X1waHCpcB17DJdxIW5z7JFUI2nIfBzYYL2xfPcPgvPwVeycOE3LamY8H/wddwuE+o0I4fh0bA30PTYXGDYGF+afFpNBw7kwoHggKFesekr4GA1sl0HymuK7O4gEUb4OIPOyEq+HV+4eTTmmu2wM2KSZDTy18or1n9HdDAeQfdtyjiTp5LhIFO+fnPswl7Pazy4SQrbvL4RWy/tP1C4/5s7Cn0C3LOIws4nrxX73+pcW6t8/P4sOrsccttSTKUPLEVmruK5SHn2nwblcfj0it+qXFEXrmQWUKuSlVvIbc0aQd42m8HGxsxZ2v7hcbl+i9WRf0D5bV9UcDxztMV5nh6S4xO0jiyLs2LF3y/e/cSi41zjU6w0ht/vY0Uxzu/jxz6j3C887Vv7bHmch39mNyJk5vg6LTt4HUmPl/eoAXc90vYuY1yycGhTK7cg42wEeQ4lPf97i3572f/GZOr2zPeiuP5GeWh/yjHyc/I1QtLpByvHkK9eo6XT7MXx2lcjcc7hHr/UDdacbz6pcvT9VYcLw4+Xr9WittaZo0Vl7mpkSIjL+Zv9vsVlsfLp/3Hr9K4eneTi8Tx8lVhrlU9ZynOMeMY0y9xOSuKncM4JRzv/tPh01ZayePVp4S76rhXyN1vsNyKw/MpIo7ojXC6yuRq3V2u5QMjbk/dFVZ6eXmN+K+oHK8+QDvM/l9Qjhf39ZfEWsnjndsu7D8eF9JrmeYXwvHOqctyxM+E492zQ+SR9uBxKK9zr60F/YNzfw7hiF5e/VeY49U5Fx4vg3NrN1O9vLxWmOPNK/CbKXIkv/D0Eo7k54xvtzK5eWWiwabNZjoO8uYpk9e0hfId11GOpzc5/Kz6r/MSuP5sqtDeCtBbxT0EVx3bCTn8FlmnTTMg+7h58Ydrl1PerglkvwXvnk/8huIWW5J+u+TVTciFXo0F/DYomkchF5g7j3K8eZS2J3xcb8rZ1ZjF5fy29QeyD4XnZ+QulfelexR481otlnQcfz5tMuHeZLr/mRMvmo2jLFSeW3n+vSknd3pSTiTPdWYH0J8T5nG4Jkb3GQnurYj8vi3lRPdgtGrflL6f6P4SXydXKa7247bUfyK9/Ss0kXq/Xyc6U72ie8b0nOgemxnb61OOv85QNE5/TonHHfZ0AqP7J/P3gDQGo/uWkNuc1sSQw7Vap1fVQX/ORoYT+S/rma0U51ytAuV49WRhTnQvcZmOb1FurG1rrrxBdcpSjjcP0Nagf6wC+vNW3P5mfhfIOTlRnCJH+q+Ie5XgQOWJ+vl6lwpSeks0LQMy9xQlBf2rytyzg5yMPPPqt0B/Pwg3DzUsXWROdH/OyoUlQOb+2Jmtn6uF75PhyaPntgX3aa1rZ0PbV8Q9n16RcqL+YaVXMG41r/VYpeeZBOMW2qs/f8TtR1cequQ8nah9kaP30QruzdNzovyn50T39cnqrXTonipzb+N9hys0Dnh1iebbuGzaP3jzGcLJ3AeFHL0vQ8Cl+2SqMvfruQZ9J8Vdz8qgnKh/RH52WIrzMO+ndoju4bs9a4Wqv5eEx1V02ajq97fzOFyrk+GutFlFOVEex/fT74PncWPS16j6cwQynEjvh6c2qvQc0Ji/uH7Z/PEqVb+fWmQHOX8kklem4+cFepfmcu8d9FnvW6BX0H9luf4vVVV/jorH4R5IGXl6Ds+xyHCi+3dbdh+q6vejizj9uQQel12ujypTR+g5kb3363RVZe4v1ssTcTjHlMmTek6UrzAOCCeqx5Gjd9UIxkvk9Oc+RRyZV4g4ffzh+V8eN6FzR5oPRO0xbbUrfT/ReISczLiFeun5fY/9wvej51gEvy8G7aXnxgR1k54T+UXPiez4L3pFcYqczHiklyfL4TlIGb2i3w+BHIkXUf2CcSAzbunjRRTPenkiru/MhrS/ifo5cjJ69fJE99vqOVH+Kwon83sa9BzepyDDifobngkhnKjO0XOi/oEc/f0QBpxMf0OO+E/Uf/XvJ8rjenm871FFkYd+Jnbwvq8W5kR1rCyH95KRfC/q53oux3mpWcTR9UTBPdHIycSLnhPlAz0nqp/17SGShxyZp4jumdXLE/VLfD8Sz5llqwg5Mg8Q5Um7FyVUmd/ngByZv/HGwf8Bfi+v7ThyAAA="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0072_20261007_101115/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0072_20261007_101115/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..429621196dc4da8206320f8143e4c11291d37ba5 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0072_20261007_101115/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0072_20261007_101115", "meta": {"split": "train"}, "act": [12, 632], "T": 633, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0073_20261007_101152/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0073_20261007_101152/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..961a354edf1ad5fa3da2d7a04b3a09f37c78519d --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0073_20261007_101152/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0073_20261007_101152", "meta": {"split": "train"}, "act": [13, 668], "T": 669, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0074_20261007_101227/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0074_20261007_101227/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d3daf966333640160af0e17ce14c60a7517f4885 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0074_20261007_101227/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0074_20261007_101227", "meta": {"split": "train"}, "act": [19, 662], "T": 663, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0075_20261007_101305/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0075_20261007_101305/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e04920138f7a76e53955e1c505595b53621e38ef --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0075_20261007_101305/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0075_20261007_101305", "meta": {"split": "train"}, "act": [14, 560], "T": 561, "maps": "H4sIAN+51moC/+3d60LbSLaAUVVJBgJJ90zPzHn/Rz2EgFU2NviiS+3S+n5MyHS6MSJa7JJ16bogpdMf55RO/xndv50l8YpXEq/sa7ySeMUr2El3xyteSVEGLV6t7JVtLsl8JWl7K+Z8xpNs20iq0as8epW+8sqEJGm9+n4cstJvj3K62Ct6SVrPq8wraansQPcsCv8g1aeLN7DNLfFqPa/SZV4Vv9jkEq94JfFK33iVL/fq/YiXJF7V6lXmlcSrGF6lfHBFua0n8YpXknStV/n8etCPCMl8VfN81ZmvJEXx6uAXXkmKMF85/0qyHow0X9nkEq8q9Sq9/Zm9V86/klS1V7n0yo8ISVXPV93olSSZr6TGs/PM7VVnvpJ4FcarxCuJV2HmK+tBiVe8knilSb3qeCXxqlavMq8kXsX3yiaXeBXDK5tc4hWvJF6JVxKvmvbqaAMnx68kBfTK+4OSeCVJ93vl/Ctpkuw8c3uVjr3Kvj2SGvRKkh/gy3p11fGr5Nsj8aoWr6wHJV41Ml/59kiqxCsGSarWq+4er9gm2SGW9cr9ZCReRVkPduYriVfmK4lXmtarjleSwni1/x2tJNXsleelSgrklesHpS2UTiyr6vKqv+B59LySptKAVwt6ZUUoma94JZmvqvOqhtd8uVeJV9JWLE0pFR9X6dWM92eQFGu+assr54tKLYvKK0m8WsqrzvU40pZWrKVXNeR6Z0lNekUoqWWeypXgJ6pirQe78vhVLS9f0jyApVMf1u1VPueVaUviVcVeOf9K4lU9XvW8khTWq+54PZhGr3r3Z5Ba9iodHm4PeLy98CrzSmrXq64Br9z/SppGgwjzVRfaq8757VJgry66PUzae5USryRV69X7fe7e7ieT3sFK9ZzlzivJevDLVxj2+kFeSZvzqqLuuD+D4+1S417VfP/2q7zy/qDUnlep0ePtvJLMV3HWg45fSQ1Cun87MB0MWMn9RSXz1UqfNkX3qvf+oGS+6gqvuqhepVjLb8l8dbtXp84XreBFXztf8Urailf7c92rGbCuO3518/1F+SaF2iE+37595dfc96NXqfQqn335vJI25NVlF0hXux7klcSrCF7dtR6UVLNXxwu/APdn6J3fLm3bq9SoV653lni1oleXn9+erAelFgFt1SvrQal5wFJlw6DrByXrwa+9inh/UV5JG/Iq4PPoz25gT3eW2ha1Aa+cfyVtbgIM41U+51WMkVbS1Uy18zwv85XEqwjzletxpBlWW/W9tmbOv8rl9Ti8khr0qtjhg5/PYL6StupVxOd5hdncEq/ufoVxvTq6f3tq7Nsj6bRXFe3EN85X7f04kcxXvOKVFNGr40udI55/lcbj7Tk39e2RdOhViuzVp+ud3U9Garacimd7BVwP8krasldVvKrrzr+6dT0oKdp6sOOVpGBejUQE8qrjlbQdr46Nivf+YPL+oMSrMF6Nr/+6503wSormVTp43kTyPHpJ1XtV6Xx1lVfuJyNtZL6KeD9k95ORJtQgxCuMez7DXetBSbG8Cn7+VVceb7celHhVsVfp2CvnX0ntihr8epyOVxKvAs1XPa+k9qVq4/k4jl9JG6mJ+zOYr6RNzFe5hecPjtcPJtc7S42rZb6SFGM9eOhV7Psh80q6fXoJOF9FfD4Or6SNeJUiz1ddeb229weltkUN/ryJT/MVr6S4Y1368g99nH8Veb6yHpS241W32ed5mcWkSKil3JZXyf1FpXa9am6+4pXUolep8CpF9sr1g1K7Q1VjXqXiq6OV1PqUFfz+V7ySeGW+ksSrSeerr34viVe8krS8V10LXnl/UNrAZNXC/ZB5JW1lJfgJKdc7S+IVr6SqTYiiVjUrwVu8yuN25pXUtlfpgC5eSbzi1TJeuf+VFJfJdO5PRHl/sHe/PolXTZzPcDBfOd4utU1bA88f3J/1yiup0Qmsifnq7fHUvJKmXphVtx7sPryKfn/RzCupZa+6Fu+HzCvJfFWzV59+4ZXUtFd1P3+wv+b9wc75DFLroqbPs0oQr+56/qCk73YuXk08X/FK2sx8leo+/4pX0gYWed+MS+1cj+P4lbSN6n8evflK0p+ZKlf/vNT+uusHeSW1uB7sCq8qnq9c7yzxajwnPKXjx9TzSlJ981X954vySloejkrPv4p///bEK2kjogb0Kp/zyv3bpcnmq3q9qm38i/28icRM8Wqh+Srw8atKvMppqb9U6bK/einRVC1Y2tbx9jq4TcPs37h0vA7uvjxWSiiFx2rPUzo4oSG6V+ufL5rn9yq/e1UeyTt8Rncqt4NzaBV7Pdjq83E25VVffrG5eJLksVe9PVFBmNzW+aI1eNUP83+KE17lgyff8kph5qvL788Qfj2Yipsgp4WvHywYyMXH/UI85ALn1+9g/7EZciofGZQOX6hDWQq4Hjyesip7VbG9yot9+jyuBsdX0OfCqzzOnJlXCuZVGn9t4/hV6dXS92cYTs9Uw3Je9eMGO+PVG555v2LklXi1rlepNq8W2oLnvBoKr3LplflKLXhV9/2vLn9e6hJepdPrwTW8Gso1YMr7ZeirV/tj7/n3lky8UkyvTr/C9/mqCzhfdRv2Kp/xqs+HXmVeqSWvGnke/SXnM0z51Z3xaqmzB468Gnil9r1q4Hn0a90PeXWv+uIY+3iW6q5/WwT+2ZDHx6+c6q5G5quKXvNVXqVNeTVujaEfz0ztc/+tVz2v1J5XNWS+Or9putGlS73qeaWGvIp9vfOK89V+Y/X9uA1z7mfchld6NRRe/d6SvFJAr766P0MNd5C6wqv1zr8651W/kFfDV169H7MaevOVmvGqhec7H3q14MvP57waFvdq+ORVxys1uh6saCV4w3qwW9+rNHqVMq/EK15VPF/xSrxa5BXGf57X7fNVupeOM17tFvJq2O29Kux6+ORVN3olVefV5fe/Km/dXun5ov01Xn378tMGvBp4pc3MV/GuH7zm/cH5vMr7O071uxlXXeNN2y/yqueVrAermq/WOv8q7+86/MpCXtyrh13p1cAr8SqYV9cdUL57vsqrejUcetUXXmVeqVGv0gFdAbw6+zz6xeerE179XhzySrxaaL4Kfj3Oou8PHniVzVfi1cJeRX+e19pevYnBK/Fqvle40ecP8kqK5lXiVQNe9byS+YpXvBKvFvu0W3gefRq96td7f3Bpr3Z9zyttxqv363HSeD+Z9P44v8q9yp+86m7zarL5queVNC9qYZ+Xmqubr3glLTBfdcV89aFXaK+y+YpXanW+asGr8XzRo39ovuKVWvPq0/3bq/eqO+fVte8P8kpfjd0HZ/qUJyN/9fFEw7u24FVazas0o1fFf64fnzv/wKsavOp4xas87qlXPh9nletx1vFqOPCq/84rt0K+xauTv0lH+0lq7Suv2Kuqj1/l75+Xep1XU96vL63g1VB6dXA9Tm++WtSrzCte/dk7x33/Fasrn++86P2QK/LKfDXL7ps681V935Kqzm+/1qvyr1Ra8tuz+nrwAq8yrybafS85fiXz1dfnM0w5L0VYD/JKTf9c+PZ80eqOt/8eW/L4ar716vb79d3PyN6rrniIVvfIK2kjXnXXnC967fGrqWE9OV89rnE+A6/mWYWMtwsvP7YGXMWr8NcPpuPzRdOSm3nvVe5Tv7xXjrfPuwsdnC2zBa+qvT9DiuzVpyFxpfPbV/fK+VcLzFfd6fnqaD9Jxq2556uu8KoL7FVay6vxs/02pK/y+BWvlvHKfLXYK0xd7Pkq3Xx/hohe9bzSxr3qxofkhLx+cPQqte7V0Ht/ULyqq9vvh7zk/a+OvOrX9Grnehy1wGSD5zN88mql9wdr8sp6UJugLR3dMiPifHVwPdE2vdpZD6p1qVqbrzbs1bgcfPOq45XuW5hV+wpTfdduXnf/9jVf6NJeOb9dvIp1vJ1XH14NvJL5as3edrv3/S2VXuWzXm3peHtx1sQlXvXeH1SjXqXEq9bWg7xSLCbTF0zVeD1OE171s3yWb+arrvCq45XaEq2h5ztvyquzz8fhlVqeryr2Kt3u1XrX46zgVTlfDY63a0I46vGqvFDwM1JVePXlDeXPerX8y1/Bq71FD/nQq1PzFa9Uu1eHZ3+mTw4UN2sfP67Lq4t33w161Z/zqpyvEq8UZr76em7hVez5Kl3jleNXut+rNO9nTJ8+Ti17tf5LWWs9+O3xdl6p6vnqkkNXB8evAnqVzFenvDJfKeZ68Cxan42q83i79eB5r/qT81XilVpgMuT9r3h17XqQV+IVr+r26vvzGXilNkSr+XzR67xacSl78CSI2bw6/Iy8Eq94FcWrvUuvTO292vFK7TH14RKveCVF8KorvIr+5Mf06Zd0/b8d1avy+sF84NX4p4tXwiuF3L+L80VDns8wmVfB5yteiVe8iu9Vsh5US16lU2vD8F4t//LNV5L5ilfmK+nk8fb4Xq320nklLbKfxz2//Vio7PiV9aBa5qo4/yqN51+lcGc3HAplvuKVmh+09kYF8Sofwmu+sh7UFqQaB6ywXlkP8kqbWhkW81X36Y7vEdDN3h+0HlTswenrvfer4+0p6Ne68qhXpVc9r9TYfHX8RJ2oNvOKV2pzwmrz/Cte8Uq84hWvpJW8cn57aK8Sr8SruF7deD7DYL6SQuzkvOKVFHQUu+iBOq15FXU9mK/xauCV2pu1iv+DV+YrqWKvzj2anle8kuqcr8I8H+fs4vW6lz5s4HkTvFKz8xWveCVVvcP/ud55vConbej8q5heXXW8nVdq0Ks/H6a0La+mnHaq8sr9ZNSwV93eq8Sr6r1yfrt4Zb6K4lXHK/Gq+7iNe7CXzyteaYNeFY+h4JX1oBTAq3AvfwPni5ZfU86FV7szXqXxRTnerqbXg7FefscrXmm7XkV+3sQ2rse5wKvxT1sPqhGpPoz6mKvCns/AK14psEInPj45Wb3+T47/fOfDXzbllfNFtS3Z3p/ndcRYpOXskVe5ea8+2nl/UNaD8by6eT0Y+3pnXsl8taXjV7ySguznvAq6Hkzfe+V8BjXFVeFV8Th67w+2Nl95f1CteJVGrmK/P8gr7w9qM3KV979yvD2OV85n0ObKB/cXje1V2oZX11zvbD2oViarrmvsfIZkvuKVtjBfhfWq4xWvxKtAXt3sD6+kuvbi9PV6MDdwvTOveKVNoPbJq7Sl652d3y6Zr3jFK2mB+cr9ZKr36oLrcXilNmvp/UFe8UrmK15VuR50vF28cvyKV9IKe/HX+ySvJvZqWNgr60GZr3gVZb5yPxnNOOhMu6Pw6oRX125aXklVePX9c9odb9/AepBXaqqj8xli3w/5OoMGXklR1oNnvUoNbGDzFa+0wg5hvuIVr2S+cvyKV1IFLl3+UPo/z/PqYj/Pq5LrByvyqrh/e+aVGhI1XQkcr8xX0rYmwPm8yrzilVTxeMgrXin0rJQ28iXziley2oq0gfM2j1+5P4N4tSWvYp/PwCvxynxVu1d5XA/ueCVerV++8IrAd6HSWpt8+fszjF/fq1f9Ga/G869yPvVaJV7N40A69X/mArQ+5bV3xcd84NXDIq9nKEamPo+fnlfSatCnU0NXX4A1pLz2UufNq7ymV8U24JXMVxV4VYgw5GL/TONxnJV6yl0qvVpoPZh5JV5V69W45tnlfn9U+XE3PFYwXw3zHb8qpRkHy6P3B0uvxj/teLt4tU6lV68z1cdvHna7HxXMDrvi/cFhNq/6fj8xPaR8xqu++OO8itFTeci23991oD9466khGbdFY9/1v1F4+6Jff33pb9r5J+1hGa+K89uLNfFQ3IhhVyCVB17FmGaey3eTxu/aMN8y/prLYSbXJY33BU0bwCt3+Wn3vhlf98OfFeyLD7vZvEon7RrK41fFgaqhtx6MV/kj98Cr2fblb7xKs95kKo1/YXOwexrfOHG8efV+/tWv4ap/dZ714OjVrp/vbxav2qz8kVvsvvPuyjnl4fRcd/Sm0dR/d9Lr+uhh/G8/tf53M/0ZYj6+mTXMV0P5Q3GJn4S5+Lt89HHxY2zGmV5zzFePecl3j4aX4s2hpx9P4z/5u/i4m/olva6Pfr6MP4H/eWrfq98/GNL79ThP6abxadI9eKEzB057lQ4/Pj3UQ6Hinj/YeOqX3Hl3fz2PP11fXl7Gf/Kf4uP8PNmyaN9fv8aP//uj/W9w7kavdrftlZPuwaUSi3hVXod07mDDx535x38zYatur370y81XQzfsvXrdn15+Fkb9t/i4f5neq3/9PX78f8+nVp4NLhLTn1te5XFfXm2+WmPG7HjVSj/G+ephSa9+vZ1IkR9+/8x/fnk+7dUwkVf9Oa9eeMWrM14Bq76Gfn/+1a64dH1+r3Y/d/uPux8/i3/2n8KufqL1YCnQ34VX/3veyPf53avV7iezplJH68H0nVcdr+o9uDE8ru3VqyU/Xni1oFdpY16dPd7Oq3gD1uM666CHnwWOB179UxwD7yc6Hl5+ZX/9Vaw9t+JVvsuriHvuOEhd7pUCzFd7ufIGvfqxle/zxrxK6ZxXp41yPkNAr4YFP/F5r/5dejXRKRb5jFf/2YpX6dxB52bnK5mvFvcq82pCrza1HhSvpvTqpfTqeWavel7d4w+vVJ1XfcNema+6Yr5ikHh1h1dPvKraK7aJV7zilXRNvfUgr3glXpmveCVN7NUDr3jFK4XzqufVRrxy/pXMVxN59S9ezeDVXfdn4JU271V/zqsnXpmvJF5t26ub/eGVqvMq17Ie5FVt85W0ea/MV7ySzFe8Oreic/xKvJpxvprokT28umu+4pV4Zb7ilWS++sqrZD24sed5iVfWg9G9Ml+JVzd69YNXvJLMV7x6Eyfd45VUnVcdr5r36sZ5iW3ilfVgFK+k+rzqzFfhyhcidN/9GaSvfwKar3jFK/GKV5v1at2/auIVr67frR2/unK2GsxiqqQKvfrbfDWDV531oHjFq0a98n6ieFWBV5lXzm8Xr3jFK4lXvOKVdOhV4hWveCXz1SmvnnnFK8l6kFe8Eq8Cz1eJV86/Eq94Zb6SeMUr85XEK17xSryq2KuOV4VX1njiFa/MVxKveHXBF5Z4JV7N5ZXzr8xXkvmKV7wSrxqcr/7Z2PNSJV5N65X7t5uvJF796ddm5yvnt4tXU3i14PMH0za98jwv8SqMV+PeueHn45ivxKtoXv36Zb66yiDP8xKv1vIq88p8JV6Zr3gl8WoCr7qte9V1jreLV7f2+DKs49WGzxfllYI37Nby6mcNXjlfVArl1ShCHtby6kfpVXk9TjeRVz2veCVe3eHVbkmvMq94JV7xKoZX6ZNeEq94Va9X5ivxile8knjFK+tBiVe8knjFK+tBiVe8ms6r5PpB8YpXQdaD6Vp/eCVe8WolrzpeiVe84pXEK17d7RV/xCteRfIql1AxS7ziFa8kXvFqOq+cfyVe3e7VE694JfGKVxN4xTbxynrQfCXxile8Eq94xSuJV7ya2Sv3kxGveLUJr/gmXvFqYa+8PyheRfAq88p8JV6Zr3gl8YpXM3jl/qLiFa/q9+rtS3f8Sry63yvX4/BKMl/xilfiVTCvvD/ofFHxyny1iflK4hWvFp6vrAfFK15Fma86XolXvGp9Pcgr8YpXvJJ4xSvrQfGKV2G9Svd4JfFqQa+cf3XX+4MSr3gVxSu2iVe84pXEK16dFIdX4tUEXj3xilcSr3h1wqtrDeKVeLWgV85nuGu+knjFK15JvOIVr8SruF51vHK8XbwyX/FK4hWveCXxile8Eq94xSuJV/fmfFFeiVfmK15JvDJf8UriVenVP+4nI8WpL73aVeHVX4+8uvwLu3AIcn67eHWPVy+lVz/OefUw0ZdpvrIeVGPrwQW9yue9+jWzV794xStF9ephNa9GQ54eC5d+lkZN9JIOvPo1/v//fuIVr8Sr7wB5eC68eiy9ei5fxkRvAfDqLn94pfq86hb2arxpwuNDeTP3Wbza73L556/xS/4Xr3glXn37eR+eH0avHkqvHkujel5V4JVUiVelFEt6tfvxMJz26qGf16uX4gDZ348b+T47n0HNefWw3OfdHXi12xUz1VAalef16i9eSXHqdyt5NTwWRhWcvL+VNfHK5dCr3Ua9unmrsk21DFjFZPPYL/dp81DMUbmfeY8oxrT+uTiZ/ufDRr7Nrh9UGwNWYdRDXuzT5pSLmSotuEP0T8Ux9ufdRr7Nzr9SExVX1i3sVVpnT8nlCnjBiZJX0kQrhfdFWlpw/0kreZXKiXLIW/ku80qteZWX3H/OepWOd7PJP3M55vGKV4rpVVpy/1nLq4P/5pf//dTQbsor8YpXobzij8SrAD9teCW15FU69werZuvi+4vySuJVrPWg63Gk27y6YIkzv1eX7OO8Yps2D1YVXm1qvnK9szT5rsWrmTYqr6TQXnUXehUdLdc7S1vyKrWwUXklbcyriHTxSmrJq9N/MBVepcO1YQq4UXklxfLq2iuQ/hiV09s5Y6NXKZ5Xjl9JE+o0/u+c781d61VXeNVF9cr1ztJ8XlU0X/15ISmNXr2tDjfklaRQXkWfr+5aD0q6RLBa9qo3r8pZK9rxdtcPSot6lab/BOmKHT6F9sp8JYWcr6bwqos4X/FKiuZVuuOOqnOMe7ySeDWHV3E3Kq+kiXX6jAqvJv2ieSUtxNkM/03zlSRe1bgdnd8u8SrcdkwVfBckXl26NDJfMUjiFa8kXvFqvfWgJF6tNV8xS+JV3dsx8UrilflKEq94JW3bq+1uR15JvIqwHV2PI/Eqkle2qsSrcNvRVpV4Vfl2zLaqxCvzlSTnM0w8XzneLpmvzFeS7Fm8kmLtZ3avSRfBNqjEq3a98g2QeLX4etDxdolXUbwyX0m84pUkXs3i1bL/trSl/SzZXXgl8WpbXnleqnTfj+vEq/q9kmS+4pXEK/FK4lXY7eh+yBKvzFeSeMUriVfb9erM7yTxilcSr8QriVfNe+X4lTT3vmbX4pUUZT+za02zHb0/KPGKV5J4NfV68OTvJPGKVxKvZD0o8WorXtmoEq9a9co3QLK7RFkP+gZIl+wo5qsavJJkPWi+knilU9sxma8kXvFKkuXIt+ULt5JNKPGKV5LsbLySQlJlZ5uYfBtU4hWvJF7ZDndvx8QriVe8kmT3urtsPSjxKqBXqXP9oMSrOPMVrySF8+roH0pSdV6lcb7KvJLEK0m626vOelCaPYeGJ/Yq8UriFa8kafL1oB8CknglSdOuB9MVa3C2SZdkT1nfq8F3QeJVzV51vJJ4xSuJV/rWq8wrSW3PV5LMV1HmK98FyZ7CK0n6Thz+SOKVJN3tlfPVJfFKknglaZteffqFWZJ4JW18P9Ok29FWlcR9SeKVJF5JEq+kS/t/FUzyYbBFCgA=", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0076_20261007_101332/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0076_20261007_101332/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..8ad014675a5cf7781e27e7f7b7d91587fb6f3782 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0076_20261007_101332/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0076_20261007_101332", "meta": {"split": "train"}, "act": [12, 593], "T": 594, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0077_20261007_101400/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0077_20261007_101400/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..fe3a01f0f55f3fe02c2daf7de00c9214ee297d53 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0077_20261007_101400/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0077_20261007_101400", "meta": {"split": "train"}, "act": [10, 602], "T": 603, "maps": "H4sIAOG51moC/+3dDXfUOJqAUUt2JZWEJvRMz/7/n7pAGiyHqsTyp2TfZ8/ZYQZIoBJfXrlku2l0+MJKv/bWbwlebkm8ksSruV7BSlJ9XkkSryTxagmvQvoj3klayKuQ84slqehRi1eSynDq8xVb+tMBXpLKKr7zKv5CKzghJalgrySpRq+sByUVvh7klaRavIp5OyAkaf/5Kg+rzispaT+vJKngeCWpRq/YJYlXksQrSYfO+XZJ9Xpl/5W04tLFUbXoizr+dg6SeLXbfCVJ/iWQpBXmK15JqsCr/PPtnpcqiVeS9IlXcapX3k6UVItX5itJW2Y9KKlKrwKvJBXtVebzvHglabNmPn+QV5L2Xw+GhleS6vBqHEC8krSjV/CRVCFazJJ0EK8sBiXVOl8hSxKvJOmD+zPYzyCpeK/gI+mQXpmvJJmvJIlXkg7iVcw73y5JvJIkXknilSTxSpKa2fvbJak2rzqvpKTN14Oh9ypGr4+kCrxqRnllxShpn97PV+7PIKl0tMYDxCtJO1LFK0mVrAc9b0ISryRp4fXgxN/BNkmbe+X+7ZIq8+ptccgrScV61V/vHLLnK0naxyvrQUm8kiReSToNV94flFRwM+8vyitJ+3s1DiBeSeKVJC3rlSTt75XnTUjilSTxShKvJKl0rzzPS5L5SpJ4Jek0Xt38sSTxSpJ4JenwXjl/Jak6r8xYknglSfObeT8ZSSp4vnL/K0nWg5LEK0lH82rC8yYkabPCO4Kcv5JU8HzVeH9Q0jHnK0mqzyu2SSp7PcgrSbt5FUMPT8y5HzKvJG3tVQoPryTV4JX7yUjilSQt7FXglaQ6vGp4JalgrxpeSTJfSdLcwt35qnG9s6RK5iteSSp72HK/PkkHna8kiVeSxCtJB/GqcT2OpBq8asxXkippcL++kLOfocObpK3Xg5nXO9t/JamA+arhlaQ6vJrwvFReSdpyPej9QUn1eRUCryQV69VgVRc8z0sSryRpUa8aXkkq16vp1w92XklJu3mV9/4gryTV4pUkle6V/aKSdvPK/nZJvJKOXnA07OZVuLXZgVcSr4rxKobeq5jjlcerStraq6nzFa8klT1fGX8lFeBVk+eVJG3sVZN3P2RJ4pUkfbYezDvfLkm8kqTR68HGelBSJfMVrySZryRp0fmq4ZUkXkkSryTxSpLK82pwvt3+K0m8kiReSTqNV5n3k3GGS1IBXgX3v5JUh1cNrySV7dWc5+NI0i5eeYCEpNLXg7ySVJ1Xjf0MksxXksQrSWf0atzzccLd/yJJK3vleV6S6puvDEySapmveCXJelCSeCWJV5JUsFf2X0mqZr76+X+SVMN8xStJRXsVeSXJfCVJ5itJZ/cq+3pnSeKVJC17PxlJqsUreEnazSv3b5dUg1d//KQkFeyVBZ6kcr0K0f0ZJPFKkhZdD+bc/yr5FZ3bz0jazasm06uGV5LK9aqZtf8q4E0SrySdy6st9re3t3/zVtu+wpw/u6TdvRo8L3VtOIrw6m3OC4FeUtVerX3YXm7/QdptX4Gff9Nkn+zbqxDurFxtp5XO6dXD7VlrH6/i4FW4u1jklcSrblevYoix/x9iOxytkpeEV1KBXm1xQD7eWRtuX3vXq+R9h/DHCyZpN69+Hqw9UnHtY/OhUK/CTa/eTsnzSirGq00XPCV51b8SMfbzVQzv93XwSipmPZjp1bz9V5dyvIpDr2LiVeDVnJIXrz9HGdr+JGWITgRqoleRV7xay6vLTa8aXqmK9WBXg1eRV2t4FXml+V5tup+BV+f6XuvSNWD/QrqSVLPXg1vcr2/g1a7ftG0c7GfohuvByKulv94h8cpGNk3xKmz9PK+2HK+Sk75vXoV+vvqXsretHq3vmSXmq2C+0rJehbW9CuV41SVetbzilSpcD6689gnphtQu/a7d/AxsF997Ff/06ud/8mr691rLK63o1crfR+kpjO9e7TpfJfs42tjPVz/Oa/Fqqa/3wKvAK832qtnUq4Lmq/6V4BWvVMt8FXqv4pnWg4lX7dCrX5fnvL0F3/me4ZWKm6/e/XD19eBl3luNi3vV9F4NZipeSWV41czzKvv9wXS+uhQzX8XIK6mS+WrD9UGZ8xWvpGrmK17xSqptvlqfjIK8apPNsen1g7ySePWvV8Wcv2qb29c780o6nlfdFN5K8irwSjJf1bEe7Hgl1edVf13K+vdn4JWkRbza+noc56+26e4/CzaZq8b14E7XO+/8/iCveCVe8YpXEq94xSvxKut8+ym8Su+DGp1vl07qVTnXO6detbySqvBq0/0MbTle3d/P0A68ankl1Tpfzdx/xStJC3g14Xle2evBtsz7X7W8ko7o1ax4Jcl8NdOrllcSr957lUBwibt61fabrHglWQ8W7VUceNXd9+rie0Y6wnyVv5+BV5LMV7ySeHUCr1peSbwq2avWfCXxynpQ0upeNbzilVRuvOKVVKNXTWM/g/2iUiVe2X/FK8l8xStJy3iVhQ+vJPFqc68ir6TKvIp57w8e06vYtalXnu8sFcgVr3gl8ao2r6wHpd1Xe7zilXQc0eLp9re3oXG+XeJVVfOV/QxStV6NW+AdYD14a75y/3apBq8y97cfdD3IK6kqr3LPt4dD7Re1n0GqaT14bq/MV1IB09OHv2DO9TjhUNcPmq+kwr2acL7qqF6Zr1b97kv/ZQzBiyNezfAqur8orz76CyCWV3u/P8grXvGqNq/Ouv8qJF5F1+Pwile8KtirxvuDEq+K9+pnH3gVeSUV6tWsKZ9XkvaYr052P5mQeDU8f8UriVflXj/ofLtUy3rwl1fhJNfjfOaV+UrasDhalMF8dV6v3F9UqmW+ylrcHXF/e+v6QemQXs37AxXkVcsr6TxeTRCmSq9cjyMV49Xk+7fzSqc5fFyPU71Xda8HL12/0OOVeGW+4pUO7hXTtvbqpPdv55V4dfz5ilfiVb5Xbqpa+3z1wCvxSuO9CpnzVeCVNG09SKzNvTrGfPXzepwPvXI/ZC1/NPKKV+Yr7T04aSuv5p5vz/4D8UoSr3ilHUasYMFXtlezpmReiVfawauOV+KVqpmvpvyBivLK9c6aixXTeGW+kni1//KbV7IeFK/yvXL/K/GKV+YrSbxa2ivzlZY8xsxgW3iV+TwvXkm82s+r0L8/GCOvfngVXe8sFT5fNZs+jz4UfP6KV46dGd/m4pX5SrziVe9VEzb1ynpQUq5XzVSvwnG9cr5dKt2rhle8krYujhaFVze9cj9k/9Zn/buv8r1qeCWJV7yS+ep0r/VIrxb4ivJKyNJyL3jhXm3eh/dnsJ9BvCrTq7jhJVC1eGW+Eq9K9yrwilfiVR1eTbg/w3Ln23klXnnBx/yKfbwKvBKvtJVXwXwlaS+vmk292nm+6ngl81V9Xs15Hn3F8xWvxKszzVdT/kC8Eq+0nVczr8dpUq/2fG06z/MSr2r2Kux4f9GCvep8Izl8eMWrSrwyX0knXQ/Wcf6KVxKvCro/A68kS/QKvGpD4/1BrXwAeZDqsbwq+Xoc85V4xauSnj/IK63qVeDVjBdz6q845XxlPaiFvQrQ4hWvVOzBxyteLeiV5w+KVxV7FTI/Zt33Z/C8CW118PGKV+Yr8cp68DReeX9QS/9jziteredVxyutd/A5fzW2zOc7Tzdwzv1kwr73k+GVduJL716a1KtQllfmK51jPcgrXi3rlfcHxatCX/DPX8SQ/zF5pXN7NfoA4lUBXmX/gbw/KF5pqi3mK16JV7yyHhTTeMUr85V4dUqvnG83X2mbY5FXvDJfyXzFq/W8alKv9nxteCVeVebV4Pk4Gb+DVxKveLVFno+jpSziVTVeharnK88fFK9q9ypmfExeiVe82surwCvrQfHqUPNVzkc3X4lpvFrUq5jOV+PPX3W8kni163ow83z7Ub2yHhSvNmva/ZAnPB+HV+LV7QPC8yZW9irvC9Q53y55Hn0lXlX//mDklcxXpb6YvJrqVecbSflevf+FvFpBI17xSrwyX/FK0gJenfL+DLySju/VrPGMV5LMV7ySzuBVDP3+9hiX/gR1ejW4HodXUjleTb0ep7H/StJiTq2xX3Sx+aq57OpVm/zpY4xd/PfpF9+9itaDUlFeJTNV1k1k5s1Xg0/V7bojJZUzht6r+N6ryCttefCG3/8ZQnpRT/j4YAyTDuGidoWFT39qjldhxhfjTYn0ixFumrbVejCm68Ho/FVt7TurrzNshPdHyE27Qr5DB/Fqwv0Zsv+2qVch9N9kMXkWYbvJK3Lp+oXeu/NX372KvKqql+SL1MaN/+lbz6t416uQrKFiOL5XqVHttI85yauYetUmXLW3vt8Wr5fo4fHy+0R69yNeVdzXy02v2gr/KolXIfUqxvSf95h41YYbw8AHFwCZr6YtnROj1vQq+XAPbffrG/vx+eHxt0iXy2+vuvbHfgZe1dXrcbxKj5D0ceht8l9i2yb3YPl1IjgObmx+83xXtRcx3pivNni+83C+itvMV/3Xtnnoeq9eBl61971qcVDrfFX5Wa3kCPnx4/4Nqu/fol1/CD7EwuemBearyTcon/dCpBtSY0x3DaynQn9u6p1XD71X3e8/S9e2bQQAryYsGD76RW+jTd6Ac9er79+lyV/4sT2+V5fEh+t2x2fqVbuRV13iVb8efHjunW4vl3TvKK+q66/u5vfYIkft8D2id+/ShTGrreR+fct4Fft/d7//b9ejePWBY4+Jz18yrJh5KN/1Kq5mROy9evi+7Pv1jf3w1J+YahPSeFW5V13q1RKHbQgfvEu37v2v3i1Hkvnq8tD/zPPRz7D+MPly+x+nlb1KKWhD3Mar9jOvIq8O5NWdbTKTR44EpRDu4vURT5Pvh3zXq0t/8vUcXr089vZ8u4z4YoY/vJqygmsHdg1Ovq910iHx6vuyr+XV0deDg/d01pyvmg+8GkC5slcvJ/Dqy7WH4e+HzbyKgy9GuPlNtvDSO7bpfNV+uh6MMfKqtr72h+xl1S/f8O3x9iE5CC7p+1aPqSGTPbnr1cPTU/KXfzg0VT//3/PU+WpBTUKYubwf91n6eenH24C/Z61rD26yneXn9YNutV2xV4/b/XMT2sfkPbuHFKyny9JehdSrx+fn/mf+fjy+Vz/OX8Xkiz3Bq9lH9TZepVN7289O3/9pTI0KY05KqNS+/L48oX3Z8NN2z4/9gHW9XpMFamrIZE+GXnX9HtHrly/9UPXfp1vrl2NtG/xxgeiv4/IpZ6f6gl5tSfQvbX9j9P2r3xuVelXjX/HkXeJL4tWG00b3cu1ZeErXaK/Jj8N1slepWDG5qPv569f+r/m/51uT3LFOarXh16EZU/5z7tcXKjyeQ3J5Vvq2TxNun6BgQRVedc/9dt/nDb1qX649C8+DNVriVXya7lV726uX19cewf/rP++laq8+X+TlbzE7plchIKre4uXae/W04dnnNp2vBl79J/lxu4ZX3xKvXs7oVdji+kFeaQU2Lv2loNNxGK9jsh5Mbur9nK4HV/cqma+S9WA8vle/L+4N4QzrwcZ6kFdL1b08pvNV8qn/3serust7esS4AeswXsUQav6b6KZX7cbz1eOd+So9f7WOV4+8OoNXgVfmq3nfQ5c78xWvtvBq8vO8eCXrwRFeRV4tKBqveGU9ONmr3pCnFbyK4c7+qzOvB0/klXi1yXz1LfVq+n7R+14l+0X/4RWvxKuCvUr3t//zdJCv4+rPo5d4xatdRCORZn8/3Xkg8brfW2V49bTpetB+hgkfE3G65VX6bIWwiVe/DuWuvV5W/jv+3n8Vm/b51/72+MOrxKWF9jO0d653fvn27eb1g3Xfn2H19SCv9B6sHcb2Pb0KBXj1cgupI16PM/Qq8Eo1Nth/dV35SOoGXl16KJ7SO28tc/3g4A5YD4lXDa9GvuHHK5Xn1cPeXsX3Xi0yX83x6vjrQV6pfq9Wv/1Vf1vaD7xa+3qcO16d6vwVr8Sr6V49r+tV4JX1oHhViVeZ59sr8iqOBohX4tVkr7q1vWrDGfYzjAdo4FXDK1Xv1XXtz5bew/M5fYTXGl7FMetBXi224JSOOl+N9mr6/vZzeDVxPWi+Eq8+P7ouqVf9IfB0vS7t1cj9DJV7FVKvwmh9zvC8CfGqovlq+NnuefW/08xXgVfiVebR1W02Xw2udx6uB1/P6JX5Srwqd77K9ipU6dVoToLrnXUor9pze1V3vBKvavZq1P52z8cZ9THdD1m84lXZXv3+UsFKvCrs/cETeJWzuEtGqs58JV7tdv7qlleDw/Go1zvzSpXXlefVt7Xnq9fTz1d5H5NX4tXKXsV473qc19fTn7+aIByvdGqvnvbzavrzcWKpR7D7X4lXW3l13dKrb55H/4bXwp9AOphXYdR8dV3Eq7v7GV5fG/MVgMSrZeYrXq0+XxmYxKvJ89UaXt3dz3Dz/NUp9jNEXql2ry5H9Sryilfi1YZexRXuL3pzP8O59otO+Jh40+m8Gpy/Sp6ifH1afr66+zz6V/tFcz+m0/MqxKuHS3+EH2q+iuGuV2fdf2W+Eq+W8OqpQq9iSV/Hxa8f5JV4tZNXi+1vP8h6kFfiFa8K98r9RcWrnKOqS73qD4HrNfnUr6lXk/9Ig/0M0X4GXukQXj2czatvt/a384pX4tWE9eBCXqXX4wy9+tZYD/JKlXvVbvv8wbXnq7v7r3jleRPiVdFetct4dZD7M/BKvOIVr6QDedWkXvX/ZY3zV8P9ogOvDng9zijRnL8Sr3i1Y3G0KEOvAq/Eq4K8anm1mFfWgzqlV1vuvxrsbx94daD97VleJT8eww+vZL7abL6KZ/BqfKGxvNOBvIrHfX9wMa8Ocj+ZwCuZr4pdDy62n6GS6YlX4tUCR1UZ569O6lVyPirwSrwq1avBfPXKq3FeOd+ugr2KJ5mv3K+PV+IVr8r36tf+q+B6HPGKV0V71e8XDfaLileVeNX86dXgcIxH/EoPvXL9oCr3quXVabxqeCXzVZ5XkVezi6NXbOYrHcGr/Z43wauN56v+xyH3d/BKZ/dq5etx7u6/uuVVrZmvxKsjeBXP4FVIvQq8Eq8q9Sp9Pk7glfPt4lUlXpmvfv4sr8QrXu3YxPNXjf3t4lWeV+u+P3j3eam84pV4NWe+elz7fPtRvRq/XLMeFK9KXQ8GXpmvxKtKvDrLenCaV6N/B6/Eq028Ml8t55X1oHhV0v6r2q/Hcf5KvNrMq0f7RcvzSuJV1V5V/TyvMHi+c/TNL16Zr6rwKns9KPFqI68ir3glXq3h1dfHRbw6xf0ZtvMKXuIVrzb0yvuD4tXyXi10v77Iq3u/IuR/dF7p5F7FVb0yX+X+inV+r8QrXq3vletxxKubXl3X9sr1OPnrQV6JV1t5lbef4RTX40z2ypJQvNryfn29V8/n9Mr9kMWrXEQSr543fP5g5nzVmq94pcK9ajbw6nLbq6fd56va45V4taJXl7W9une+/fm892/nlXhVgVfhnlf/2M+w2CeQeMWrJecr9xcVr2pbDz6ar4xU4lUl89WBvJr4/EEbQMWrgr1qeWVyEq+K9Wp4f1Hnr3glXlXp1Y35yvU48z66xKvt5iteSbyqxavIK4lXRXgV7Red8P6g/Vfi1e5efeUVr8Srsry6u/+KV+4nI14t5dXXtb36elavMvHhlXhVlldP1oO8Eq94ZT0oncCrZZ6Xenc/A68mrCB5JV6V49XI/aLFbWKaeL0zr8SrotaD47w6zfl2z5sQr6qZr5L7M8zZzxBLPYJ5JV5t5dW1Fq9OOl+5XZZ4ta5XefuvXO8876NL1oO8WsmrkPcxzVfiVYVe1Xv+atZ+UV6JV1t61d7xKnM/Q93zFa/EqwW82vZ8O694JV6VNV+dY3/7ms/H4ZV4tdV8dY7rB3klXpmvRiy7Dn++vdC/qnjFq7xxpk6vmt6rwCDxqqD1oPmKV+JVnV55f5BX4lW5Xtl/9cevcP8r8SrnqMr1KvBqDa/cn0G8WmG+CgWfby9KNV6JVzXPV7zilY7sVTRfVetVnmie5yXz1cm8quwI5pV4VYtXn51vP368Eq/W9uph8qc61/MHeSVeVTxfDRdvyedqeMUrHcGr63HXg3fmq/+e7PpBXukoXsV29WHjrlePiUt/pUZNXw+u7lV9+xkaz8cRr2rwKjnf/tdpvfI8Lx3Hq7Zd/WF8seu9eur6A/7xMXHpy8PiXsU1vKr5/gy8UpW123qVQHntCWnS8Wro1WXyp0rfHxx69dfDBK/aUPd8FXil+ouXrvdq7fVg+ti77jE53h9Sop5To7qpnyr1KoRklnv6koD49+i3GN6mNfOVtOuCMBlzrhseVW26xaBLibp2S3gVUh27NvnMz0/9Z3sdvd780yvn26U9v9G7Db8pQ0pITI/9bgkU4sCrNjlxFtLF55fR683a14POt+toXrVbehXvedXOvunwd6ni4AaaMfRTZHhbAb/97NtJtDGf5PsHjP2vDKHu503wSvV7teX3ZEhPZqU/buLyXqWfLLzhGBIbM70KIfBK4tVkr0L4V5G3j/B9erv78dN3ymL/488e3xf2fLV4Je16gBXi1e9JKTQ1H5RrnG9veCV95lVYwqt2nFfhVF55XqpUpFdxaa9C2ccsr6QyvBpDxSde/XnwhWShczqvLPAkXlXj1buXQdJSXsVpXsVFvYoH8EpSSV69/d41vAon9wp3OrdXo35mxny17How8koyX304X4XEq083dDp/xSuphvXg6V9O4Ei8KrzYv06fzZ833x+UxKuyvZpwPY50bq9G/QyvFl0P8kqacnCFcO9QmHi+3Xw14t+HifdnkDTmMHnzypHzmVfNmPUgiaSJIoV3+wo+Wg8GYvFK2hOtkDFfBRfoLuqV11JazytDQc4imlfSjl69PZ/La8YriVcn8SrOeH/Qqy9e8apgr2Al8YpXEq94xStpP69C6L1yjni2V/yRzFdn8MqrL17xqmCvvD8o8YpX0mG8ani1mldZz0vllcSr3byKvJJ4xSuJV7zilcSrU3qViQ+vJF4tWdbzJiZ75e5j4hWveCUd0yvX4yznVe4ak1filflqwVdzzK/IfF4qryRe7eNVwytpA68Crxaer3LvL+qlF694VbZXkni1m1fmJWl1r5y/4pXEq7N6tconkHjFK15JvOKVpMW9cr6dVxKvTuuV11LiVSVehcU/usQr1zsv7NX0/e1effGKV1t5FdyvT+IVryRe8Wrx9WC0HpS28coRwyuJV7ySxKv1i6PJcn9RaW2vfv1KZ68+9mrk/dsnrgeNY+IVr2qZr3gljffKu4NZtnzqlflKWtUrL8N8rzLx4ZWUvx7U5+vBdZ+XyivxymGyuVeTzl913h+U/LNejVe+EBKv9vIq73w7ryRerfaijfIqWg9K4pUkLeqV/VeSeCVJvJJ0Qq+ckJJUkVcx72PiTZp9mDqMNvJK0hShfhvlOuit1oNeZWmGVyHxysG0klf2X0m82t+rJtgvKvGqEq8a92+XZh5oY46JtwdP8GqKV5n3Q572ayVe8ep2a96vT5J4JYlX1nSSzumV8+2SeCXpzK27n8FyUtJeXrmfjCReSRKvJJ3XK/e/kmS+kqQFvQqud5ZUuFdN71XOx+SVpA29CrySVOV8lf8xeSWJV5J49eeviFPfH7QelDS/rOud44zn4/BK0j7zVebzB3klaWOvMp+Xar6SVMV85WS7pFrnK0nilaSTlHk/5OnvD0pSBV51vNLK/T8Xb9WGkAoLAA==", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0078_20261007_101432/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0078_20261007_101432/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..7c64b6989a16054ad6561d259626d34e929a4154 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0078_20261007_101432/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0078_20261007_101432", "meta": {"split": "train"}, "act": [13, 564], "T": 565, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0079_20261007_101502/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0079_20261007_101502/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..0da48126179d01057e693b28958a4cc120c1bbb4 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0079_20261007_101502/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0079_20261007_101502", "meta": {"split": "train"}, "act": [14, 611], "T": 612, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0080_20261007_101533/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0080_20261007_101533/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..afb70437423e836e4491847f40e6de672ebf817b --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0080_20261007_101533/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0080_20261007_101533", "meta": {"split": "train"}, "act": [12, 575], "T": 576, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0081_20261007_101604/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0081_20261007_101604/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..c2132cd917d43dde2f1ed1193d15311a39d4b21a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0081_20261007_101604/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0081_20261007_101604", "meta": {"split": "train"}, "act": [11, 575], "T": 576, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0082_20261007_101633/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0082_20261007_101633/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..5cff130bd71c1a7eebaff36dedeed9669e9b5aa0 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0082_20261007_101633/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0082_20261007_101633", "meta": {"split": "held-out"}, "act": [14, 554], "T": 555, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0083_20261007_101703/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0083_20261007_101703/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..9cd529adeb80c8f70cf3905ef507756bb8a1dade --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0083_20261007_101703/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0083_20261007_101703", "meta": {"split": "train"}, "act": [14, 616], "T": 617, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0084_20261007_101737/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0084_20261007_101737/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b7dc57c048e9406641e88b542e9a104a0108b541 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0084_20261007_101737/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0084_20261007_101737", "meta": {"split": "train"}, "act": [13, 585], "T": 586, "maps": "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", "force": "H4sIAOW51moC/82ce3xNx/bAT1OJEJGHeFSUeIdw4xHE4+bsvRJpEEmIkIZGvFWpPCUiJZEHGiXSEqrIj1ZRrbYupQ1ylV4f8rlBleilUo/WrYbcpChC7ln7dCb7JHvPTH6/8/vd3/x58s2aWWvWWrNm9uxtMBgMd7fVygaBFv+kRog77/7Uqtyx0TYgwoXZ/ywkb3jEm5RLD/7CT+R/ymGd0fAfaMXru0jWHJ8oJ9rvp3McrMINm16q/D3m19eZ3Jy85ua5i/uIqceGf7VROGnzUKa8aw+fKH/fGJzP5Ly8uiry2q3JZXI3QxwVLixeFuJ4dlnm0FMWmTcyPp4/lxzuJ8R13jFCaHyeE+RGcYakl4XszJtfYhdD1jw/kfk1XEgW8gPDUVchrtx7jhCXHvebUYST9jQRkicZNwvJ4zUSb4YPnhOTZyWOxCWPo37AadbmBni4C8Uv0aPQrUxIX/vxK4Xm19ocL87pfEwt8hOxX94uT8kaHO03c6VRZD5irgULyfM48KGY/x13YHJHY9zMeffZ34TkOY9oJ5bXOH5P8xqniXJkfDw/aHScc/JzY+UVZmQyOZtagyzC0X4587vxqq3ZfsN7MOdt4gkXofXN2us+8Rcp8QWxOOo9UWx+OfFGxme42tqqnMe1L4zWyKe0juD4H6lfiidPEuJ4jdRNvPERfa3FkXqX51dkfkU5nv/9b8U5jxOt7xurr3B9JciJ5lNevUbWGZ6fNrZfnl1E7Uw5Tp4kHG+9FM33ovKo/bp8K6ZHxPj/l3UnXacvbhSq12JaVlhlH0C4oKp0Ib+PGTpNaD3inR+I6mFtjsbRYUnIr0Q5UTuL+j2PE90nE443H43d11orn44u8RA63yAcL87/a/BQoThv4tJXKL/QOodTX4lyouuqaF1H/IXn942tT3njI1zlp16SNTnR+OXVL8QuQV+tEMpDonV25chQobqdd45J8xWHI3Unz350fIKcqP3QD464O0m8ecP4HRm+V+LJw/Ut7LveMk8eckFX8mXe/he53q1K5dpDn8gsfXF88qr78tNKZ2D6vak+vf9yUwj5NRT4/twCct6YDCw/RW54RHPIvDUHWOdweL4x/RVHaB87g82Z1t+MOCcoWhSjya0q6wgkzl1Ot4VUmMXkcF244t0KnNvN0+SGLO0MZJ+C3JmlrzPlYX16KbEFrOy5iKkH7kNv77CD04OS2PLiflO48ilxoFXbEA7XrS0DHCw46sMqDs/NFs5vAsuq5oNWjUb0xfPi+tys4Paylp0L3nrOwn5aHMYbcpb2c27AYf5DfdXcovddG3Imf6nPadmP9CtnL2DOB67Tvx+rlfXkEQ73Kci9tnKhJrf7wW1a52C86XHkPAfP7w82fSTfnpTA9QOUx+PQr9wjnuhyG+QO9Hc4ZaPrp6tvdKb2qzlqCyv6p3DjDfPGz9OTNbkBtXXxhhxPHs4HPvcsDVjCjl/TfLD6VXOYh3gcNoxfHufcsan0fMtmunqQOEJu+AstdeOccFiHoTy9+VBzqAePw/Uc7VLhqa2Hl9cOuj/H+X31SSrT73H9QLvs+u4Nrp2jsptxOewXx3d5RjqXwzx01z+dGx8Y53ryiL6Fq/pJQ449k+U7aZrcZ2ndqN/X534M7NEgD+Hz1eLi32TJuY5L2zSwQT7F56sY52p/cQzxb8ChvHed7+lyNF+Z5hf7VXPVjuGyVl6rL++Y80S5wb7H5FfIqf1UzVE/EOSw9cz8pxA3+5cqWe3PWuPDc9axXW/LSePjmfLwnLU+p7afep9X8fi6Rb5Xc3R+TXZmce1z69YZ7Fed7+3WBFIO4jrSOiKg4r4uF/XX2ZIeV5QaRLlxyQspl+HzRH7thwRNeYTDOtbuyr8sOLUeJkmUy419LFcMi9PkSBxhPkBOTtS2M60n8fdtz+Qxn2vbj66DpiYkzzS+6PgHsrrO0eMMht8tOLX9yPMPXI8eehvg49RFOna+Z46jN1KN9bnPHkym3ObdFym31t0GltxL1pRH/LSws6u0tVOtrOacv5/UgNu4M13C+X3RP5FyzwZFNZTn9L7CeXvW2e+qx5AG84b3D1APPY7oQbgBu+vsl1ndVZPDftXc1sODGnBoZ+QqqxIbzantRzj0+6IlD+XMvXXzkf/BmIbz5vhUiY+HHRKY83v2pI/0vfcDC079bKQ+5+wSyxxfmO0IRY+HeQs11wXC5f0wSqp1MoC6Xy27lLc6anSuec6C01rfkDNusoF5J9n7D9w3Yr/bPNOY8vD8BfUY+7H2ek7nw1TvTplRJRvs05nc3PHbJZw3PY70i9zpxTXyL80zmFxxmyMKx5UXUKBwE7encTkcX5d9SzW59mm5lGtacle+6suxn2kftflhtZwZuJRblyj+rMOReTvUaq2E+Y8nr3JvgvTLzir5oXc6Ux766bmDT4Q4zKd6HNVjfqnxwy9r5HmQwd7/mlpkqcnvOf1i/YfyeP1iHkJ5qeWcfr+9Y7zpYwsz7i1nyus3e5aEccTjsI5AzvftTC7nt8gOmqZkcf2gSacmcHUJWx7utwJW2MOMVmx5eF4Xb+cARXbZTA7rZxwfj8PxudTa63JqP0XOYMhhz9tRV+XchycPudwbTuAzStsutB4yjQ+5YyPZdsF9yqgCR0h7iz2/2NB+vPkl3K6tWVzucLQDdA/L4XL54AqOtWyu0JBhXNjBGaq+y2bmK0NaF+PRGDfoeD6baT88/1v3e3v4fArbzrhO9y1qB2lfpDM5PP/DfkW4ke3b6u4biR5YZ7M4dT4o83Xmyis8tUZ6rtQN9s1ncx7d/2J8M9VVlyNnv8gtzW/D5Yq3jZAuOrrB4wuL2XFuyrsoL8mYyuUW3XXR5YidCTd8dQqTw7zL4ogeuL+c0dUR1HWYXr/Iqes6zfg11XWVE5xgeUIKcz7Q/1j9kvEReXp2UZ/Zr9vjxJ0PbNivHkfj6M4WP+T8Nyzhco9uN4e+Vxfr3PGXzH6VUWBEDuz58xYw1A4OGZex+zU1+VV7XY70i3lSRB72y5Knzi9ol3dncuL3D07PfjSO/shDPA7P5X/t3w0e/017fP0Cr5v1uDxZan5tFJy+pT0+sm+U0k9I7otnQJvp2vXGJ99HK5zn8pny3sNZ8Jdc7fy34JD5eUxaxGH5Sf8CUJ8jqVtNpJfy+5XYn+SKku1Q/3yX2HffVwOV34P3t4cHubtgu8s0C67s5k/KuObsiTSfW2z1hSurPofhr0y04MKGuMHNrA2y7QJzvLreCYLrpXshfOEoC+6UnAwup9vSujrvnWgoufABuC3pYsH5VWbA+S3f0XOonIgsKPLNhvwwVwtuyLGlMDTaD3p+PQXIc7f8zYvh8UePLJ5vtbBbCqvPBkDOyhC4tP+q8sx0WfdJYJNYYcE5zM6A8OpR8NY8CZZUZiryEvzCIe/pOQsu4HgqOKbMgiXNIoH4QnpFH3ixcqUFd/RhsrKW5bsPMo27XOFwP0jmWzUf0OrtLGhzJg5297gj/VA8EbxD5zR4Rod9Gse8Dri/Wu8/QSqrOSsjb0mlG20HRcOj+5Gw9sRZGfWdu+mM1PC5ZLrRt2sUBEYFQ7vHx5W++jni/QOpwXPJRNdJMLMkCKZ5bzf780u2UtyJDppcnwHBkLzgQ7l+biANx/149USYfSoYvAccYHLb14eDf8KYepxBkyu4GFSPSzdqcQuiAqm+yjlokwMNuHfLQiE29iX4x7hTzH5nuYTA1E1BcMz5IrPfrJDRcOvCqAb+Vr9d+OtYyDn+EvSIq+ZyheNeAjwjtgY3cH4YxG4bDfgsiMXFbwlVuPe2GYDHuXuOgsrKpiAi79T255ncEZfRih5Ji5pxOfeM0YB7Nh6HfsrjDO+Ohfwe42BElL1VuOpzoyG/eiycetPh/5T75PAYuHkiVEge5kiyzvC4b062ZHKpbkHC3PTCYG6//mNHKlyvqlZMrjQ+QIk3HofyusWPhezmrkxu7n5Z4Xjydpw2wuVlYVzubac/K3bhcUnL/YU4UXlPrxkVPaIHtRXiNtS0Br28ps81bGu8Rih24fVr5kJhWiS7XyLPkhOVp20//50hsMfdjWuXqs3juPreGGaWx+MqUoYKcZnnBwv12+fIEEVfEQ7lzQ59gcl12T4MypqFcbmnnSRheaKczTfhfO6roULyaj4eISSvMZy63/TMLL//iTzUQ4Rz6DNEiOs8abDChezpwOQWxgwU4ki/PI7oweOIXUS42SvHcbmZ6/4sxKEeotzGgFAuF7XHR5h772kEl0s+PxAiIFKIC5gZ8R/hROxnba54cn9h7sO24UJc37IwIU7ELlsHD7Aq9/o5b4i8zNcjr/pPQnYR5fqv6CvkzyVTxMbXc2of2Fc0EUpaeDC57sF9YUmvyVzu8G1vyL0+jcsZDD4Q4zUf7C6zudrrPvCngbGwplt7JnepyBc6/j0F7sd3Mt9buv+j5r3r1vZ+EHliOZD7OfhcTYtbvXwEXPwoG9auNo/vRBdbzX2Xw9fDYMe9LMqlH7ws6dURL36TAz8Evqhwh94p1OT+4esDL7TKAZ/odvR+nRY3r01/OD8vBx41M9ulzJinyQVc8YKQydmN5s4+m6XJ3bjZC6LPpEPcxTZAzuW1uAHhPSw5vTpnfi/Iff4NLheV0d2C03ufxKZbT7j18mKuvE7VnlD1eRKsvdTWKtyZgz0VLmU/uz49WdILotolAz57YHEHintBact4LnfboQ98+nwClytt2wdm2ScKcV92SBLmru9l72fWX+6knK2/vJq9v8xbZQPd7VJh+tRfmecbHTqWyL/sjIfjNVuYnH/KSvmsxzzw2xjHPn/pOk323RwDngXDzFx1iaZf4V28C4PHQPdh/cxxPrebpt/jnRC3WwC2BWbu1O61utxyj6GUKwz31uTwLtSETb2h820z92liki6H99k3ze2ucLvstL+ngO93Jfk9k5PGmPNf3g6jJvcjnJTWL/xY7pn5KvO93oNfr5DiTrxHueIjW7Tt1+SAEe/zkO8fsDiPHc1keXYLLvdj4E6JcPg8RY+rbjJIImfAhux7QtzZqNckPe7S/lckcv9Fb/1ADs+nyfMKvPele4bw7ReS+jmn7l5g5D5JfR9Jj5v1Uwnl8H6EiDxW++ydHFmEW1a7VFbf59Lj9g/KFOLwvHldxSOuvnd/zpIJx/qOyM2Qt2QyH3jfR49rsWYtHR+La7d6HeVY7x/hObz6fp3umae7U52fcjiiL+s9SPRnEbugPxOOpS/6M+GcP5qgy91ou0GIQ3lEX9Z7Xuo4Yr0viRyxc7q8QYxjvJ+MehCO9b0Wtb6xZ1ozOaIv3usTsQurX1EOnyUSjuUHyNG4dHrfqpxevVt/fHp597/LsfxPzbHiV82x/BTvMojkAzXHinM1d+jv63U5XDtEuFUXz9F1xjPzPV0OfZnEG4vDb8nQexkMP8A1n3Kr+jE5kfVNzbHylZrDe77664yzrL6XK8Kx5Jn2KpRjfV8vIXA45Vh5A+//8+oNbCnx4ykn5XZiylPf72SNj9xDYNnF/O6qxK8PVBxr3cJ5o/dQHJ8aRfyPxWE9qb5fIsKx5OHzfHrPSKeeNLdyyrH9qryurmO+z17Hsb9bUdcv+/uO5TQfsL4XiXsBMh88jswv3rdl5SF6f4j5fcK68bE4tTy8p8qSR/3q4B5dbr3/hbp5Y3w/Aut2Wt+vymByIvleXY+z1ukj7mvr/I8hDxmR9ciz4Esqj7Wuqrnyqdm63KL38+m8sdZ9076Rcqy4RHnEr1h6mO8D/ZGHGN97wDqM+hWDQ3mEY/VrIY/xPSiLOpahr7pf1ndE0JdZ3yhQc/T9UMb3S9R+xeKQEfGXxnB0fIy8a7IajV9WnsQzAfW9SJF1Bt/b0ePcWveWReJNXeewOPX4WHUOcnSdZuyT8R1I9X1WvYbfjlC/J8zSV8Rf8P1ZXnz8G+jWUSOQWwAA"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0085_20261007_101807/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0085_20261007_101807/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..7f2934a20df74f339cc882d70f6fe3c044c14ce5 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0085_20261007_101807/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0085_20261007_101807", "meta": {"split": "train"}, "act": [14, 675], "T": 676, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0086_20261007_101838/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0086_20261007_101838/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..960dd90289ac8674ab18aae0caa5678683033e7b --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0086_20261007_101838/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0086_20261007_101838", "meta": {"split": "train"}, "act": [11, 629], "T": 630, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0087_20261007_101908/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0087_20261007_101908/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e6c43f4036a15f6d507303341b2f044d49153842 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0087_20261007_101908/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0087_20261007_101908", "meta": {"split": "train"}, "act": [12, 684], "T": 685, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0088_20261007_101941/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0088_20261007_101941/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..f47e063d29d22590f206460784b297efa2de9eea --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0088_20261007_101941/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0088_20261007_101941", "meta": {"split": "train"}, "act": [16, 615], "T": 616, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0089_20261007_102013/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0089_20261007_102013/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..57cd8383372b1bb4a6aaf2dc78ac4bdfeecd9fb3 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0089_20261007_102013/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0089_20261007_102013", "meta": {"split": "train"}, "act": [15, 629], "T": 630, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0090_20261007_102042/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0090_20261007_102042/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..92cd0bc571d8d179f500bf43024cd1cd2cd5734a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0090_20261007_102042/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0090_20261007_102042", "meta": {"split": "train"}, "act": [12, 600], "T": 601, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0091_20261007_102112/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0091_20261007_102112/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..cfa53d904be36167e80067fb8a54166525d245da --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0091_20261007_102112/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0091_20261007_102112", "meta": {"split": "train"}, "act": [14, 570], "T": 571, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0092_20261007_102142/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0092_20261007_102142/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..575d5c40a42bdd91e5d207b58e229853f76d8355 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0092_20261007_102142/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0092_20261007_102142", "meta": {"split": "train"}, "act": [8, 584], "T": 585, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0093_20261007_102210/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0093_20261007_102210/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..0584f7a2d1694fa207428da8ff5890a60d8fc73d --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0093_20261007_102210/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0093_20261007_102210", "meta": {"split": "train"}, "act": [14, 628], "T": 629, "maps": "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", "force": "H4sIAOm51moC/+2ceViV1dbAMQUJEXEAcQQcEEUGuSoOyHnfnVNmDjlzU5HIuUQc0AQnNLxhTIqQWuGQGmblUE6Z+JU3U69aETlLKmqKV8wBTazLes/dL/uc8+61N3W+5/vnO3/xPPyetdZee+211x7e7eDw/z97/nIbnDbJcKW9Bir/F1zuP0Lsyi2cft9kT7/IcoPiVOWvcj/tvGCS8R9wORccVfi77/5ku3BrPizU/h+bkSDFKWu6otwzfzioVeGwfmPbgcUBy8n6T5nZCOVCel9WZPQGBGyoEufwZT2U6xZ9Uvt/VMnrdvGzbBxQeSGbI1FucPwdqTiQjRedy6shxRX9/o3JHvZRTtRvlHPfs1jKf0XBE+wSf3rcI3noz8S9vcbR8K/rVqkd9uJk/Uy5PfXTpPTm9M+U4rxSU1Cu7tGGqsz4pZyoPyjn8MMtkwynpHhz5U3/uqnS77iPWa9vPWTeUhTqP4ybV5qk0DjAuF5DPlKuDqgtjCuWw8Yby018aT3K0Xb4T8uxqzysf1l5WN4FjvpPlsPyGstFXepvV3mlnwagcUDjSsTp9nUdh3K6nxHuSsNVOucwa5RUv4k4ap/zS8tQvZTzubTbJCPPXtyqu57mdiQtM2H2UQ7Lp6y8TyfUQrmAgJZScS87PvT6RWBfVcevQ3c/KS7qtasmGQ6rN1j/uYd7SflPZN+CWm2EdVhVOBpXspwoD+n+s3ccCPIQ9bOoHaE+TaTmD8qJ5qOqcrL5ND9jkpRf0rf4S/WH7DjC6mzWPiy/sO3N//sINA/pXFYLNJ/S+QP7sXkXq5tYDltvsRwWLyyHreNZ/2H2sf4T5Rc9rgT9K8vJjl/K2Tu/yI5fUf1C8wFWj7PtEHHUPtE8SMeRT48xcvWGYBzJjjdZedR/2HqmKnmS+k+2fsHWKWw+wDhZecDpeeOCB8r1GtJeuB4E7qPLoVLjl8oTzQt0npadj2Q50TxDudiMZ6XakX/1mpSfsf06Vq+Io/GMzQssJ+oPGqciTlYebUf6xefl5mkBR+NZlO9l1lsW40MQ9zLjjZ3f8lv8IFUXYxzI0+tnZJ+GXb/J7gtg8y/sEcrsd8Kenj7PIPMg/PQ6u8sHKEf9t8VpGcpR+4p6zkQ53X924mg7RPZRrsjjLSm92DxTFU6Pe8H5EZWHjTeWU0xrTHblEt8wybQD6nbYb5PhYM//1WvHlb/C0X4Drvuwt9ToZbEqJu9UXl/FLXSdWhqzFeXSXWcrgXH71WreV1RRv7Xad1dtVuBGRPKcfB+p8zM9DTm9vvrNV7la4kjS7wUJuYtTapMJ/hE4V1GvJbf2JN8W9RVyK0pbk7HRgwgWBw5LJkdMXBpGwl4dgrYX8v3Ugggy/q1IIdfnMxP57eLLqH0wLyR9SrgcmydjJ5uIY6cxqDzgiHM3EquOEnLrW3cnEzcPw/1XUScmfd+ZrHpg7L8PH94wn8+cLjSNm9+By9FzMKhfHA915HJ6XVwxD4K8W8MHEF7Op/XzmokhXE5fJ1eM86flbaW4d6+3IYlznye8OYnlNu3sJ5Q3t2FbMvlpL5SD/SuM0/dZK7ib5a3J/v09hfKAS22gVpnz2fCsnhvougzieXRJC/L0kknn3Mur2XBQJ8Zc8ybNQwmj1121md8q5MX3a24hj90zYOuId281JcP/UHRuQrqLDQfrZNAb2znc0D5WnjVHY5jloB2g9/6XeB5Kz09U7h30FnJQ3896wZfL6fu7wR6aPNPTrii3sH0NkwwH+5hpF5tyOXafFbjEwI6oPIgX4IJDO6FxCuuy5SZvrjw9nivqNb/oJqT4hc4Eq6+g37aVN+JytP6DdQDI4+ml6yOoNzLXNeRydP6F/SbgeO2lnHn90YC0j/gbysE5RNao+mT5uiC0HVBnN//Dnew/E4j228SydzVum38gGn/ALZpeh7z4czs8TmNWKRlj6gk597F7lejv6xKPqLZCvcB5rW4tbAdwWXlthH7R9D7vj8qD+PvOxY0rj8Yf1LHx3VzJ9oRWqDyoczBOX/8O2KbVLyJ5EPcyHKx7nGNchP6D/LKjyIk4Psbtg3XA1O+qkx6xYr3AuYX6ovEM+c/X05G4b/LBx9H0raYmzZ2F8mC/DuP0c5eCeKX8S0dueykHdQ7mF98N4Xp+AXk8vSwH7V16tiXKwb4P6D3u6oPaBxy0l8exea1HXg3yrwXeaL+BvOy3q3Hl6eO3grv50zNceSyX4fZQ5XG0DgO/nB/rQLK7NbHh4PyXctAfjoX3VCPup52jdQ7GuWfwb+riKQ1tOFiHsfM0j4N7D+z68vpAB2LEWe9vhB383dA+/+x9FhzI43GXyp5o7ZjYM1vpsqI6l2PPsU9ce6TWd/U25NhzCOB48tg8+bb6qyrSC/U4cL4XGxty+rzvdtu0psy438B/7HkyTx70r76P+c8dGnd/TWPD/tX3RfNq2JerqBOBO/RyI0OOPT8CP2fmeaAc9AfI88r1RONUxNH1EXDZcdeluCl5V9WFi4ztY+eth7eKDTn2XsGpcyO5HHs+k7NpoTIg9KY6fqCt/8AnNI/D/JvStZjL0XiBOD2z/Zb6XftmxCj3UQ7aC/aJOJg/Zh8v5XL6fFSx3gd5RnEAHM3j8AP7ji2x5aA21bkKeaDXSB7LwTr5nRUP1etRtvZl3H6sHN8bYub6OGrxcrprM4Kdu4A86I+SWr6G+Y/Wu7BfDJyRPFmOHUch419Vlrxzx7AdwL23t5POgV94nH5e8X64xoXN97ULB/un4D8RF3t4gxZ/xYU4N6fRKi2ev99hW4cNjp9WeU4yf62Jx7HzEfj547MXDTmL+5iJb2jybvr6GXLsPgPGsecLPi1K1aCDxnrZ80GPtFtqrTBjjq7LgFuw6rYhB+2lfoF9PZBnpJfNp1CPY3rZdTxww75pZcixdQlwQ/P8DTmaT6l9PA5yYFW53t/+wuXoPhLEAciDvR8jjt5bh/4d5Hyd6xfKQT0EccDjIMdQDuKex1H7gHM6f1ddsNbYPnoPHvI4xjVO+O95fcV6WoaLXRyo+TlmXqBhvMCYE3HW9/Qhr/H00n6j+Y83Pliu1+hiLqfvE6Z4K8AtHutn2A7K5U49acI4qhfOV5umXOFyNA5gngbuUMM2aDv8k9YqVyddUEO6thNyYJ8Rx9oH+fTQ8GtCDvIpj4O+pRycb+0OLzHktPmFjo+KefBs30tq39s4B/uOwO3+OgjlYD4HLqdBEOGdYdK4Sp36s2H8WXNQ10XsaI9zFesAaK9IL9QHMvYBB/bx2qv32xhfpd+nN4Ty8kPCtPUgr730N3LrUgXOhF45HIJy+QVlSoFzELk3qxPKrRztqo5aPZS8d7cDyk1dEKJu3pdAnnvcDuWWn+ioRvZPIcbzdGV7t1zprPZsuJoMT2iAcrluweqZtPfJse9r6lzWc0MVa67oTJ4aPP894p/yWK8vP/8q2YbzerpXvTJxLcmqc03npvX6xIZr6rFD/cIph8QcP6JW6i2w4XJyv1F//WCFBTfrh902XNQMR+IenUl6HF2HynvUuRlxbZdGpn2+GuV69mpJ/lk3nVR3ykL1+i1tTJwvLyerp6UacvTntbJYPbwgxYJj9dJf1JN8FeT12LVM5yJy7tpwpT/Gq6Mnp5GdnZKY/qhpczZ64/W31fkZ6eRS2TxDjraj1QvHUI7+chfcVrMWvV0RK5XnunTPgJW3Nq5M3b3vTVI4aSLKlVx7lhQmziNntk9BudQBjuTklDirWC6y4U4NdSBdv5piwRnJO+J5Tw3+KcaCO3/Z3cYvXziWq9kHRlhwHcuesfELcLkHFQN5Cy36bqjHadV3p7eNvMxsX5XVm37oE9VzUk0L7kO/W4p7+SKVPc8btGuxCnnSlqtG2P3Jvk17qHcbX7Xgfu69SSl8yXxO3mfleu1/u2bUVnsmOxPr/Y/A911IUlANMmTERvM+wxgnNeV169yiKI9vuJDwvZEktP8XZu6jwUrh9uYWXPKgA0qzT1xJau5y4jbsmG7TyXttbLhuW+qSV8PTye/9T+hccYfaNtz83g/U6Ks5ZEPvb3XuqOc51ZqLHluidl23hcS13qj/78GxSJXNUcDF3/xR/WrgPtKh7KF5n+F+mnaPgr33ANxX5xuQgo+/JSUdzPX86eQI9UCTOjb3IyLXeZG2McfIgKCXNK5g+mCQq8wrrW5hI+xHz916iDQzjdO48sJ+6r0an5n+fb2GBbeuZXMS23QX8Y18TeNGOnWEWLPhou40IuEZ68ja6ZM0bmNZF03vl1ENLLgWmY3Ikv3vVTBTNc49rbuhPKcG9UnJkizS8NArGtclZ4jhfQw4szlSmkrKt5nPK52XzDLkeq5yJrOfLCcX74dpXEye8f3rxBdrkHHNkgmd59MzqhlyD0bVJAXnFuvcnoNHDe+pbD3gSoKvLyRxHubzRTgX4nFHixOJw2cKyr0bWkvjVm4w3+vg3esDDvRG3DCfW/PuI9V73tWCg3MmIy7XzWzf/V3m83fevXoY0yCPcrx7kV3PulhwvPtNYB+rl3e/ffU0Nwt5sK/M4wLDE4V6ZbmBa+uQrSsq9cK6wojb/YWLJm/YnP5oO6g8FxczB/tDRtz54PoWennxArlz+PoEnePfB/YScrDegf1K4DYnme9/KJ/n2XCzN2Yq/z7soXEjzwzhcrCOgbPyTwbP1eXlv3HEkEtwbaVxby4z30/JzdukGK2f4DwTuKKWw1HO+cUAjZt7xpz/jL6TA877nr/G1QyI1rhBjuGG/tuc2l7j5rX9O1ce5S5PmEXU++b7R6cipxhyUZ8HahyVx+s3sA842l4jP1eFo/atHDNCyF31nC3k4I4S2w5YJ/PknZw/XchdOuRHNobECfVGDG5HqheK7StICCBPls7T/cIbRxXVE5kxLoH0HGaeV+F8BuN+7xQp1KveqpTHyxvQDpaLmtOPy7H28b5PXlwcqLVXH0cCjrYD41i9Pq13mXj9C+2genn3/oG7U2euLo/HQVyxHE8v2Acc1cub36zlwb4ZxlF5vHnBmluYlK/I2Me7dz35XBsLjnev+Vj3tlKctTxe/EF//DxkpjS3tcULhJ4v8OK+y+EZle3lzG/ZZztYcLz4O/GjpX28e/8gL7nDHCEH8liO196wmSEWHC9fycqztg/OGw3rsOeCpORZczw/g32QJ2X8LNMfEM+NPogno/r0QetEyFcy9llzPHmvfW0ZV6UfzeDKYzlev4G81e7TKtt7JFWKg31bGfswruDXOCHXbHCwFAdxAP0hygfW9vHGL3CsPNjP5nHsuJTlHB6e+0scjT9Re2l9IMOxfuZxoJe1j/f9IHCR8bGVHGfeAr0W7S2IV2Q43noQ6qbHi2aQ/APm+9i875T2hLW04Hj10C/h/tr+l2jeAs59feX4GLllLzeeQd7BEwPN86rrbC6XP36yzp32Ma4PWveviIMnk8n6rCHovH/4eFuNo3Xinl+2G3KXYwOIS+MJ5MfOUWa9K7YactuD2xLXmpFk8OaX0fxMOQeHGHS9WrK0DRm0eowuj/dOxyOvVmROQrTOxX44giuP5Xjv51jr5a17wH9sO3jrntAhfqR2t1cq28t9d8Zf46J/nWxeD3oeMK5zLrWw5AZsM+T+J8JXs4/2m9H6En5v9miufQND44Dn55nHmpAF7/TR44/n53dON9Husj863Q2djx7EeWt3mLvdrIdyu1Ibkx5BfuSge6GKcaFpHuTj5BbktQGr0O9/TzX6RbuDRu+h8OT1PZ+pPjPBiejvpXHm6W69+6uO2SHkgk+YitXPsO8M90gpx9MLoQl7pfr9Xe47TwtNsN9A96mx72tZjjfe6H4DvfeAvZ/Dcrw6wprj+Y9y+rtvyHekcF9Ut2/pHe73eRb34Xzxd/joPQDMPpBHOezdLVYeLz9bc7z8Ys2J3jej9yNEnB5knPW5NYfJY+9biPxH7cPaC+cb9C/sHQKWw+KFjSvsfRDaBm1cIt+l/ikO+b4bfKLfP5BsB/Y9bFU4qleW463LaH/ItAM4dbyr8LtjiBd6biP7zqIoTql9/H0uM0fvQWHjg+V4+d5aL8ZZvEM69aRJRp7oPUFqn4ij8nh1IuVov4ne79Tv3SDfWbOcKP/R/hVx9Awfi2dWr2x/iNqhnxML7KuqXtH7sbKcTP5jOSxe4JxU14vUByyHxQtw+niTfDcUmxdYTvSOZlU50buwVeWw9wWqwunv4Ao42r+8ffQ/y/kn7bErJ9sOUV6TqYurwtF6Uvb9YlmOt0625kTzoK5XUNdRDpvPLfttrZz/3sfzH41TESf77q/MOoW1D3vnk50vRRy1T8RRvVi98b/JYe+/sBzcR5fhRPOqrF4aB6J1ip7XOPH8H+eP+XBIYgAA"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0094_20261007_102240/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0094_20261007_102240/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..185b441bfe9649d5431db8b66362a4977f8deb02 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0094_20261007_102240/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0094_20261007_102240", "meta": {"split": "train"}, "act": [8, 516], "T": 517, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0096_20261007_102327/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0096_20261007_102327/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..9189887024f9cbd807c1405832bad830748ceefe --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0096_20261007_102327/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0096_20261007_102327", "meta": {"split": "train"}, "act": [15, 611], "T": 612, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0097_20261007_102357/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0097_20261007_102357/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..d4b44cdba742b53d03f4e57ed6b39990e23836bd --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0097_20261007_102357/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0097_20261007_102357", "meta": {"split": "train"}, "act": [10, 577], "T": 578, "maps": "H4sIAOu51moC/+3dbVujSrqAUaogL7537z0z5///06O2SsWOGggUVbDuD3M5M+lEY1g+RYA0jVZfmOm2klStV7CTxCtJvOKVJF5J0nWMEEUSryRpmmLvFbIkrcQrnEkqY0l41qv46Zbx/TbBOCaJV5J0zWLxb6+sDyVV4lXDK0m8kiReSeKVJJXsVfj0f0oSryRpjFdNevwVryTV4pX9V5J4JUnWg9LKC+F1mAjJ5nrFZBG++LrQn/0rr+xvl8r36mpo6vIqfueV9aDEK15J4hWvJK027w9KBY1OnoOBXnnmJJmvJIlXkvXg5r1y/JWkGrx6AYtXkqwHJYlXknglSbySpMFeBV5JqnK+anglyXpQkngliVeSVJpXIYb++lfR8e1Scf19/fbktMPBl99b0fX6eCWpFq+sByWVNFvySpL5SpJm9SrySlINXjWpVw2vJPFKkngl1Zzrt/NK4tUavQqfkHK8qMSrWrzy5Em84pUk8UoSryRZD5bkVcMrSeYrSeKVZD3IK15JKtsrx7dLqme+YpUkXkkSryTxSpJK9ir0XzNLEq8k6SqvQno8A68kFe7VyXzlen2SrAcliVeStumV9aCkGrxqeCWJV5JkPSiJV5JUuFfeH5RUsFeu1yeJV5J+yPXbeSXxaqVe+bwJSTV4Fexvl8QrSZpvPcgrKXf2Xw3xquGVxKsKvWp4Jcl6UJJ4JWmTXlkPSircK8e3S6rCq4ZX0oJ5f9B6UBKvJIlXkjTEq6HHM7BNUi1eSRKvJOmsV45nkFSFVw2vJNXjlfcHpcVyvCivJK3Qq5CuB+1vl1SyV94flMQrSeKVtJ7sbx/gleNFJfFKkub0KvBKEq8kiVeSNulVwytJ5itJutIrx19Jsh6UJF5J2q5Xrs8gyXwl6YecPzjEq6ZpXK9PKturEF7fvE9um37ZrHr7NV9JtaHGK15JK5ivhj9kVcB9Pv7K/nZJtcxXvJJU5Xowen4kVTBfNbySSuzv/VfNJvdfncxXdXz7kqwH7b+SxCtJmswra0FJK5+v+CZpKa8YJGmtXrFNUk6vGvOVpBq98nkTkmpZDwbrQUlFz1fRfCWJV5J+WKXY5IatB11PRhKvJIlXknjFK0kle+X6DJJq8Oqvz5sYcn1RtknK7JXrM0iyHpQkXknauFfWeJJ4JUm8ksQrSeKVJE3glevJSCrYq+aTV9HzI6mS9SCvpOIK4eVMlJBc2y/98szowStJKsgr60FJvJKkRdeD3kuUtJRXrn8laa1eSZL5SpLMV5Lq9epkUPL+oCReSdK068GfvUp2WXX2X0lazKtPX/JKUrnz1SCvvD8oqWivGl5J4pUk/ehVDP3+9uj9QUklezX2eAbzlSTzlSSd9erk+CszkyReSdJVXgVeSarSq4ZXknglSRN4FV3/SlIFXjW8klSLV80nr6wHJdUyX2X0Kiw7yz0vhb/5IO+Vf8a36srn0Z/xKvPPG9oFqXrlMvQ/dfj0S+eVtPn5Kp2pYjvyc1qvfwZeHrht+weNMbannPFKKs6r3MczpF613dJetalXMZm1TpeK3oOQipmvNupVF0+86mK/qyDySireq/nPx0n3WXW7FK/sT8OzlrySzFd1eNWc9yrySuLV62MnLu2W9arllVSxV00Gr7rEq308mXbMV5K+9apZzqv9sl61zcnxDLySqvNq5m3zxKsdryTxaqhXgVdSFV59fN5EM//nTdThleNFpSK9OjkfJ2Ser8LCXgVeSbyq0atk3HT+oFSiV7mvL1qlV+YrqcD5qsl6PMOhlvWg+Uoqxquc5w/yShqe6/U1539IXvFKvKpivsrgVZN6tejTcOpV4JXEK/OVpGm8uur6ouG6x15+vmpSr05Hb8l6kFe1eGW+kgr3KmzJq/iXV4FXEq+q8yoEXkm8Knk9yCupeK/iZo9nsB6UeMUrSbyyHpR4xSteSaV71fCKVyo+x4uarz6dPxiSl4bzccSr4r0KG56v2oZXWoQiXg1aDy51fYZ9aceL8krz0zPJP9pSvPrCK/uvVAlyvNq6Vy2vZL7iVcFe2d8uXlXrVYZts+j5qvfKi0TWg7zilcSrKteDB15JXm0DvYqh9yrGfI/NK4lX5qsRXoVPXtnfLl7xqiivAq/Eq4q8+uvzJjKuB/flz1d/loZeUOIVr3gl8aqS9eC+qOsz2N8uXvGq5PnK/ivV4ZXrMzR//5C8Ml9psDc5sOAVrz6dP8grLbFk82obYMZVnz/IK/GKV7xaYj3o+gxaHYa8Wu18xSvxqiqvGvvbvT8oFetVY756n69OvZLyjEpebQO9Wur6DLzSGpBCj/Ugr7SR+Uqj14PNhs/H4ZWsB81XVXuFLku9M19ryfkqbnN/O6+k2rxK/3bwileqYhG5Za8+5quGV7wSrypZD2ZYplf2/uBf1+vz2hKveFX0fMUr8YpXvJJ4NdArxzPYfyVe8aporyKvxCte8Uri1RxmOH+QV+IVr3gl8YpXE2V/u6rxyufj9D9f5BWvtCGv6gLO+c5nvIrnryeDK/GKV+YrrcWbgfSMRG2768GGV7ySavfK9Rl4pbmXdhPNVxvdVWF/O6/081KvuWwvk/Ugr3glXvGKV7zSlF6JV5m8So9niGH+P5iSRsyZef+EmK8k8YpXsh7k1Va8anklXlXh1Vb3X8Xeq8/n44TEKy9ViVcle2W+kor0avT7gyO2ZV5Jum6Jbr7ilVShV3GzXsXEq2D/lcSrCryKvJIK5YpXn9eD0XpQWqFX69zfHh3PIC232vvZq5H+rNkr85VU6HzFK15JK/dqRI5nkMSrib2y/0oqEKxkq+UVr6TC56vo+PZnr9r+a15JBc9XvPrLqybxSpp9atAorzZ7vOhZryRe8cp6UPrAile9RtaD1oO6fvOZ84XgVcYrXsl6cI1euf6V49vFK17xStaDvBrCQrjgmXI9ZF7JfMUrXkmacj1ofzuvZL6qzqu5l+m8krSUV+G6b4hXkvnKfMUr8Wr1XmXYf1XNfOV8HPGqOK9yf75zZetBKcdmGj0Hl89XOT8fx/4riVfmK16JV1uYr4Z4Fa70qinXK5/vrKU2U6+wQuercr1qeSVerWy+WvF6kFdayBNeZZmvrvyG9l1R81WTeNU/PV5J4tVmvXL9dvGKV5N5FfN9Q64vKilRJ1z2hl84PwBdreH3+bwJSbwyX6mmdaPr9Y3yatvHM/BKlTm3vifislvkuz4DryRe8WoWryRelerVhq8nE3ml5TZT5w8O8iof846/Up2bj8/zKs6rhle8Eq9Wuh689vO8yj8fRzYfXhW7Hsz7zXl/ULx6wcr+q3HrwcnvnVfS9xvPy+VAeLXIxMQrabBXs5+1az04xTfEK9k0mxCcoTrSq5D1G/L+oKQhXp0Mo7zilVTyfNW4HjKvpAq9GmZQxytJub1qePXV5+OMniMlLT9fBV5JqmS+utarpi6vXA9ZKtmrma+HXPD5zrySeFWFV/E7rxx9LPHKfCVpoCgx4/WQK5uvsjwnknjFK6mKBn2eF6/OeCUp11gV+zNszmx7KU/XeFX3/qvulSzzlbS4V833XoXFvGpTr06O5sq1Qn5/0C79TBzzlcSrIrxKHqZ9lunty13Xfjxo2yZePccrKZ9X4Vuv2v76q39Wjiv3Kp73apd61UZerXtbUBW/ozO/ry7GZAN+36BD8/k/Z/Zq150MPvO9rpL7ThaBqVeRV2t6+X/pld9r8b+xM7+iXdt71bXvXsUcXqX/ZJdpn1XsvepO5qvk/cG2PTeCqX6vTn6Z1Xr1Z5/q+47VcOpwCLX/hPHbH2DfxQSN54058Spm9epkpZbZq65LvYq8Wk0nv77kL1HNZ1b9edP6TabXt4TeX9O7UL9X33dIvWrbN6/at4Oxvv29Tvlx0Lt0XdqdvK4m9ir5Y9v/ctvkfYaQrABfbtP/77b+2r1KfrWr+XXukv0X97Ht1iDyd16FBI3345AW8KpJjOrm8yoOusmJV3Z4DJ8DPl4ayeol69/9L71azda87/q/709tu1+7V8lc0+3OeBUyzVdt8n0s6lU48arhVc1eJa+kl/PY4/rmq0NyMM6vtjusexnwuv8qvi/vn73a9QBl/MmzeXXBp5qlXgVeXe3V318vNl+FFXp10+1270PVv8f9Xb8g7Fb4mtq9eNW+7bp7GSYPZ7w6OdZg7V59vcURqJnk2VvKq9DFdXq1P/Re3a/bq+4FoBezXt4ijc9e3cTL56vpfuVdJq8Cr7blVfpYsVvj8v7Zq8O7V/8cDw/9mbi7Fb6mXpf0++7PLzY+/4R37RJetW1M/i503Xyb0KCbhFUd2bJFr9JXa/q2cyj0GRv+b47t/njsvXrar9qr8OLVcf923NmzE4/d5RxN6FVMvGrbJSfZ8KVdvKraq/TQ6Bm+hza0Hw8QYtvlWm8em/3d7dvXv25ufx9W7dXrOv7m3avnP0BP/U/546psul9JjOmRUfMdf/XFtWEuMopXV3i11FOWPOw+PRB4+u9s3x4O/bazP7SZfsJnr+7v3r5+urn9Z+VevaBze3j7rT1//Xv38Rv88dCzOL8kM75+T9+9CsktcFS3kyf7GXpC0oNZptfksLu77a04HnOtEY7N4fHh7euH491/btbv1ft89fIfv3qvdmv3qr92Ba/WU/JuTTi9DuSsS7Sb3ePD7cd/uz2mjzznrHVojk+P74/aNIlXq/1rdHwzKjw/sU/d+/ba5vVqiZ+cV+trF3f9dPWQb+s97n//uuu9isnWk8xd0089++bw6/HDrua/t+v36u14htc/BDcfG2u61ztccC9SAe17KbruMZ9XhxOvHu4Tl07UPEzu1fMDf3zd/O9u/b/iNrwPG236dLZe/KquQ/x4R3+/y+hV1/76/YVXv9KZZ+rv6MSr3X3iVd3bb/jp//pzPeSR53ebr1RINx9eHXJ61TSPv+/7/3LiVTrzTL1e2zXdP7/fv949/N/9bCvPYrxqEq9aXqnqjh9LhLv94zHjAz985dXvGbxKh6cPr55LvDpU+esb+PmDI4+p45WK8+ph/5Bzm70/8Wo/r1fdV17dJSvFGpeGeT4vlVcq0at9xge+y7keTL36zSteqdIO+96r+5z7cO5Sl+be35569etXv5n/7/Zjv9WOV7xSPV7d7/J69ZR4dZfZq6b36sxNeMUrFerVrjfjLueZ87dfefU0s1dPT/3XyfGis55+NHvhkluceOX9QVXt1e3uvgivTo6qmOgdy/T6D4+P/dsKqzkfZ7hX5itV2L44r+Y4vp1XvNK6vLpZ0Kvbhbz6l1e8UqVe3fFqI14FXolXvOKVNKdX3UJe3eX0Kn3H7/GxsR7klWr3qitkvjryildSUV7d8YpXkvnqjFdxpV5dfHx74JVW5dWRV1vyyvHt4tUEXt3P7pX3B70/qBV4lfPEudvHxbxa0Xw10qvmktOj3+t4JV7xilfSsHaJV7d5vbrN55XjRXklXvGKVxKveDWzV3HAHfJKvEq96r7waqIryvPK8aLiFa94JS3h1WEzXjW84pV4Zb7ilcQr89WUXgVeqX6vbsxX5iteiVe84pU0oVfZ3x/84nycu7m9evL5OLxSnRVx/uBNzOdVWJVXPt9Z2/Jql3iV9XoyC3nVPKVe3ZqveCVe/ezVUwFe/ZdXvBKveFWgVzPeu5THq+T9wU3sv7IedPyVeDWdVwdezTxf8Ue8GtZNVq/alXo16P1B60HV3mLn49xkXQ/yauB60PWvVPp8tT2vvD9ovhKvrAeLnq94JV5V41X/tePbeSVe8YpX0vSVt7/9llfDm/PzcXglXvHKfCXxile8Eq9m9KrN6pXrX/FKvOJVJfPV0PMHJV4t5pX1IK/EK16V7dWMq02JV9aDE89XvBKveMUriVe8mkyUMPoOO16JV8sdf7V5r8Lk9y6Zr3g1qVeOZxCveLVgjr8Sr8rxaserGeYrazzxqpr5qtmmVyGOxYpt4tVC89Xjhr1K1oNx0nuXMnu138x6cKNeNbyS+YpXVXrl+gzi1dReTb//KvKKV+LVNV5F81VOr4YaxCvxyvVFl5yvote+eMWrMr0K6fnOzscRr3hVulehn6+i+Uq84lWZXoXUq8ArrcCrm7xe3fIq33zVXLMelMxXC81Xj/a3O99ZvOKV+Upaq1cLrQcfXa9v+nuXeMWrxbwKvBKvlvIqWg9+utmQj//ilXjFq8xeDdnfziuV7dW+RK+mOn+QV+l8xSvxaqhXy5zvHM1XvJL1IK+q8sr1+mS+4lXJXo09f5BX4pXrt1/fsM/zCv1tfvLKelC84tWiXqW3MV+JV7zilcSrWbz6l1e8Eq94xSuJV7yaxiufNyFe8WqZfJ6XeLVOrx6369VIf3glXi3jVdyyV87HEa8q8Cryatjx7bwSr8xXvJJ4dbFXDa94JV5N5dWN+Wo2r/r/yivxqgKvGl69emV/u3hlvirYqxnvXeIVr6adr3glXvGKVxKveDWxV84fFK94Zb6SeMWrSb1Kr4dsvhKvRnrVze3VWs/HGXk9mWA9KF6V69Va56vxXjleVHV71VoPrnw9aL4Sr3jFK4lXzseZ0KtkZ3v6tfWgqvRq1xay/2rPq3nmq2bAfMUrma8u8sp8NZtXs927xCv7r+aZr3zehHjFq8K9cj6OeDW5V/Puv3L8Fa/EK17V5ZXPdxaveFWDV/ZfqXqvdrzaklfmK/GKV8vk8+jFK16t06uhBvFKvHJ8u/lK4hWvzFdauVd7XvGKVzJf8ao8r7w/KF6V75X97cO9kgr06oZXvJLMVwO8OvLq8vKcPygV59V+G141D+aroddDlsrzqtueV/9sab4KiVfB+4Oq3au7rF499F4dv/Sqm9mr40bnK16JV0M68iq3V43Px1Hl7T9EOHQPRXh12M3r1f3Ddr0a6Q+vxKvb/utdYtQ+9Wqi72i1Xrn+lbbUIbb9kPPU5Xzk+9tkpNqdW6FO59Wu7Q83unvoF5y/N+UVf8SrKbxKZ6quO7M9TunVfe/Vr8NWvArXzFdSKWAlXv2a26uY8HO8648lOKRG7Wb26naTXjW80hrmq48NeZ/Bq8NCXnXJ/dz0c13ztD+38qzwVJV5r38lldGu3zbb9nHu/e0h3a2evg+YfBvPs9D0XsWQPnD/9X137pEq2pR9Po42PGxlfLAufc+uTbaINl64pYTLN6P0prvk+K7kDO86z//llZRlS0tdSj25mKFw+SaX3mfbnZ3r6t4mh68HeSUN2AZiHDUq9RtPaC/ektL7j8lc14XNeuX4K2mIV2NmqhOvugFehbMPHGv3yufRS/m9GrPx8GrsfMUrqUKvwlq2xMm9CrySJvWq5RWvpJV7dfZxrQd5JdXi1Rbmq+RZiI5nkAZsA+FqryKvxs9XvJLGejVgi4jzeGU9yCtpeq9CD88l/+7lNidehRV55fO8pFV59dd8xSvzlVS3V3U20KvZVpvSlrwasomar8aIYj0oLepV4BWvJF7xShKvFvVKEq94Ja0dLF5V4pW1o3iVx6uw6uNFB3oVxuHFK/GKV4vNV2Hqe5fWzhWvrAelGr0adXw7rwZ51Z/oHQAkma8qma/C5Pcuma+m8KrhlfWgxKvavJrx3qXVg8Ur85XEq++GOV5JusqrAf9uzOfYr/b6V7yS8nCV8Xyc9T+bvJKyeRWG/ENe8UqqY77i1dVeedYk81X+xn3eRGCWxKtKvEr/5TTTm8QrXk28HvR5XhKv6vCq4ZU0GqyRG89I53hlvpJ4xSuJV7z6sUH725ux+9slXvEqu1fmJYlXvJJ49e2/8/4gr6QlvHI8w5VP5gS3kMSrgrzy/qA0kqtrvWrsvxrmleMZJF6ZryRe/bzx8GqYV7Pdu7R6sK7fIHjFK4lXvJL0hVfh+vvgFa8kXvFK4hWv8nrlFGeJVwt2+fk4TeoV5qUrvJrgPnhlPSjxai1eWQ9KvOKVtCmv7L+68sm85Bajzx+UbGK8yu1V8nRFw5aU2SvxSuLVqr0KvJJ4xStpA15dewe8uugWPs9L4hWvJF7xahavAq+kK7wKV94BrwZ5FVwPWeJVsV6FgV4FXknZ14Ph9TrxJxeLX+NKaPL5ilcSryZu4PUZkv1X1oMSr6qYr3gl8Wql85XEK15l9sohDNIkXs32/qD1oDWdZL6q0SvXv5J4xSvJepBXPzR+f/uQZ4Ft4hWvlpqvnD8o8YpXEq94NYlX0fFXEq+q8yrwSuJVHV41vJJ4tdL5im3iFa94JfGKVz975fgraaxX194Bry66xchngVfilfmKV5L5ilfn14MDPo/e9ZClhb0KvLrUK/OVZL7ilcQrXvFK4tUavQq8knhVtFejr9fHK/FqIa9CPPuwdW+TPs9LyueV4694JW19vgrmq/4W0fXbpZLnq+D67Wfmq6HPAtvEK+vBJecrXkmlrQd5lXrl+CuJV7V5FXglFehV4NWZW4TJ713ilflq2vnK56WqzP4fbomtXWCVCgA=", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0098_20261007_102431/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0098_20261007_102431/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..baae42a2a9948686773f9ed3ff176ce4778cbaac --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0098_20261007_102431/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0098_20261007_102431", "meta": {"split": "train"}, "act": [13, 507], "T": 508, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0099_20261007_102500/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0099_20261007_102500/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..f5d53c909d2c53466e5aa2496368a19ae091f0a6 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0099_20261007_102500/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0099_20261007_102500", "meta": {"split": "train"}, "act": [15, 560], "T": 561, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0100_20261007_102528/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0100_20261007_102528/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..9483b9111985f60f8fb2fad54c2e1b5163075ccd --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0100_20261007_102528/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0100_20261007_102528", "meta": {"split": "train"}, "act": [14, 548], "T": 549, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0101_20261007_102557/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0101_20261007_102557/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..32fa2d6c7a3e4c403abcc9343fe3d335fc08098e --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0101_20261007_102557/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0101_20261007_102557", "meta": {"split": "train"}, "act": [15, 483], "T": 484, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0102_20261007_102622/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0102_20261007_102622/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..4b8f5c4703c8141436d560cd0c966d9b4ec30b1f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0102_20261007_102622/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0102_20261007_102622", "meta": {"split": "train"}, "act": [9, 529], "T": 530, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0103_20261007_102658/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0103_20261007_102658/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b0f83f4b20577387e3b6d428ce0a7a39bde57ccb --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0103_20261007_102658/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0103_20261007_102658", "meta": {"split": "train"}, "act": [12, 492], "T": 493, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0104_20261007_102725/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0104_20261007_102725/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..444526b4eebf2726c107aa5f57a9811458a239cc --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0104_20261007_102725/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0104_20261007_102725", "meta": {"split": "train"}, "act": [10, 474], "T": 475, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0105_20261007_102807/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0105_20261007_102807/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..afc262f2b5e5d37a1f80dd2ea3297c4074757e44 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0105_20261007_102807/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0105_20261007_102807", "meta": {"split": "train"}, "act": [18, 525], "T": 526, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0106_20261007_102836/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0106_20261007_102836/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..cffcec0f49a873b47a7a9f8174f4f2d3d3cfd1fa --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0106_20261007_102836/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0106_20261007_102836", "meta": {"split": "train"}, "act": [16, 510], "T": 511, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0107_20261007_102905/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0107_20261007_102905/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b7ddcbc8595055ac11b2eea5141a238cc996b274 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0107_20261007_102905/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0107_20261007_102905", "meta": {"split": "train"}, "act": [13, 594], "T": 595, "maps": "H4sIAO+51moC/+3di1rbOpuAUUu2A7SdvffM/V/s0AKxQgnxUZbstZ555oe2HHaIXz45itM05xJD/P1/sQuv77Qh9iF2bcNV2O2Dgb971Sa9avVKr6CS+Sro1cq9kiwwX1XRKyMWoFcAegWctlcAcgScRBx6FUb3TNyAinolWcBe68BpvQp6BZTdtfieqT+9Uiyg7DnMfAVU0quZ85W6AdaDAN/PV435CtArgL17JVlA3l7Nz49eAXoFoFeAXgFk61XQK6D0Xs18fBBgr155Pg6gVwAr9aoZ1oMTP1qyAL0CWLFXloSAXgHoFVB5r+Zfr0+vgFp6BSw7Bh1yegUcte16BZivADbplVcghJ3WOIzvVTP0asknAMg2X9nPAOYrvQL0Sq9Ar87Uq+j6oqBXegXoVTHrQUCv9Ar0ihXXg25o0KuCe5X8dadXoFfmK9Ar7hXH/ivg+POVXgF6BWA9CFQ8X4Xhen1xfIA6Nx+w23zl+qKQ/Rh0yOkV6NVB14Pzzl91SfAAvSq5V4Be6RXoFXd6lbzjpgPqmK+cbweKn6+sBwG9AtArQK/cKIBeAegVcJZe2S8KVDdf2X8FHHg9KG9A3l41Q6+WfAIAvQIIS/OjV4D5CkCvgAP0KugVUHqv5u+/0isgV6+CXgHWgwB6BeiVXgF6BbB7rwBy9uqL1/PSLaDQ+cp+BqCy9aD8AAfulcABegWgV4BeAdOPQceQXoFe6ZVegV7pFegVY3Iz8/UH3dCgV5l7FfUK9EqvAL0qo1eAXukVgF4BegVYD+oV6BXXXiXvuOlAr/QKQK+AM/bK2SvgmL3q3HxA/l7Nen2czjQGVNIrZ7uArL0KS3olWUAt8xXAXr2yvx3QKwC9AvQKoMBeLbk+g7wBu/TKfgaglvWgXgF6Bdw5hBxDegV6pVeAXlXZKxf4A/QKYHGvXEAZ2LdX8/OjV8AuvQp6BegVwN69AsjZK69HD1TTq+Eq7iHqFVD0etD+dqCWXjXDenD2RwMUO195vVRgv/kqTJ+vJAvQK4DmU6Jmrwf1ClY7BtEr0Cu90ivQq/J75XoyoFd73VbNkvmq0SvQq0p6Zb4CvTJfgV6hV8AxetXoFVB2r6LrMwC19cr1+gC9ArAeBPQK4GC9kjcgZ6+G/Vdh2vMHAXbrlfUgcNxeyRugVwDf9MrzcQC9ArAeBPRKrwC9AsjSq+SvOzcfkLtXUa+AA/aq0Sugvl4BZO+V5w8CegWwdq+aoVcAR5yv1A2oqFeSBegVnOMYdAzpFejVwXvVOH8FelV6r2aNS51egV7pFYBeAafsFYBeATzqlefjAMX3yvWQAb0C2GQ9OCM/egXsMl95vVTAfAWwuFfh9h29AvTq0RfPP1OGOHz3f4+WQZSh6PVg5l7FfXsV2yYOfxbTen66JfQKDjFfLdHGXWo19CoOvYppr4JegV7d6vbtVdu9TlhDr67xCn/1KrqjwBF6tSRw+/cq6hVU16t99rdn61XyTcYQHvYqhKhXoFcFz1ch6ZX5ajUxua+FL9+Gibk5Z6/au/OVXq3fK7li5V4151oP3nl8UK/W1CaNCl+9DXo14lu+2X8V9GqbW1qvsB5cpVfxZrHyTa8cW+usB/Xqy/ujmyJfr5bo9p1bbucrvdIrvaqvV3HWJ9ArHveq+eptx6DbQq/0qpB7l/NXlLIeDHqFXrFTr87x+KBegV5V2KugV6BXegUU16sl9AqYOx7lfj36anplfzvU36uFdTNfATnnq5rPX0W9ggp7dc7z7XoFFc9Xua+H3OoVMHO+yn19Br0CCp6vQkG9cr4d6utV3Ot6Mm3O/8r3SA1divevL+r1JqD8+Spkna/6bL0a/rvuvz6OXoH14H079Spee9XcvN5Eo1dQUa9m7BetsFdBr6DeXsW5+68Wnuu6dAX3CnLdO90GejWiV61eQYXrwVP2Kt72KujVtmcbgpGCle9RWc+3792r7rte2X+lVxxrvtIrxt45vDwOeqVX5iv0arNehXJ61eqV+YqKfwM2eZ8/mK9Xw5s389XY/e2Ornlietvdvp6XG4e5R3Kc26tQ9XxlPZi1V1GvMF8tma/sZ4AKe3Xs81fJSiQ6fwV6NeffP5V8vh3Qq3J65fwV6NX4XvV5/ys/98rzcaDGXjVDrzJ+wd17lTTKfgY4dK+6OcdxHb3yeDv5jkF3tu16FRb2KhbUq06v0Kt6exWmz1dTb+uSenUzX3l9HPSqkl7le/3BonoVvJ4XenWmXi1ZDz7v0qvhTb1CryrvVZydgip6FfQKvaq6Vx+nmn8fpVGv9Aq9Kvq22uf8lfUg6JVe6RV6pVd6BXq132311hC90iv0Sq9K65Xz7eiVXh2uV6BXh+vVVLvsb7/zrcRGr9ArvdIr0CvrwRXXhtaD6FWFvfL8Qb1Cr8rp0bi/D2H71/Pa//oMSa+iXqFXtfUqZrx+u15R5+Gz2V3CnU2vxnzDd6+H3OgVelVBr8Lxe5V8K63HB7Ee1KvKe2W+Qq/yHotDToJezZmvPB8HvSpyvmqSwWLtL6BXoFdr9uxU59vH9gqcby96/prz+s7mK/QK89Xmd0HXkwG90itg8Xpvi15NXoRVsB60n4Gcx6g7W6b5asYNXcP1ZPSKv+/gzreXMl81eqVX6JVeWQ+iV3qlV+v3qkl65Z6DXhXfq+B8u/mKvL1icq/yDRZ6hV5xJwujnu/cjPzHB18P3umV+6leuR+UOH+FDD8U8xV6xf0sTJivMhynoYJeeb4z1NCr7deDesXpprSw5K/16tS9mvp6Xu5N7Joz7vZqxvn2sOALljVfRefb0aua5qtpB2enV6BX+eer+fvbj9Mr+xnQK+tBvQK9mt4jvZrQq7DgPxL0aoteLcvPKeYr0KtD9KqpsFfJfoYuXh+WCDF52z0IvTrgenDhN7RTrz6+hbbXK/RKr0rrVfJF47DW0ytKCJJ7m16N61V7bVTUK/Tq4L2q5fk4eoX14AF7FY75+KBeoVfmq6a+XrV6hV4do1dh+17tcr0+vUKvDtir468H015dbnv19evRB/vb0atDzFfhUPOV/e3olV4V26vLqF65N7FpkPRKr+b0qkl65Z6DXumVXqFXerWwV2Ho1cbX6/P4IOjVkl7FjNcX1SvQqx161Xl8EPRKr8xXnKlXIboZxvdq5vJOr0CvapmvZm1N0ivQq73mK72CxXdQvdq+V+YrWGe+cofTq4e9sl+UYpLF9F7Z365X6NWB56sjXk/G/iv0Sq9K7lWrV+iVXukVPIiVxwdn9SpYD+oVemW+uoZh917FpFet+Qq9qrZX4WT7GW56NftmgIW9cofLNF/V1yvzFXqlV+Yr0KuCe9UcZ7+o+Qp2E0cmaPHrpdY9X+kV6NW4L3jxfBxgcq8+Xm9ierGW7L/qu/y9iun1GZJGvY5X5is4eK+W6PbtVWyHLIXkz8Nft4N6QeYj9e4/CLHZ5+WMY7tv09P/6mSk0ivIdhTGZGBKD7Z4U4rhvdjHthsOzC7fEbpzr26y/aldt69Ir1ewZa+uiRpOHrftEKm2e10Mfbz9HNvLsHbsjzdRTO1NSFeHegX58jW8mfSquQz7I7sfsXv+ODK75vLFR977jEs7EvMsucLE8+d6Bbv3qkv2cD+11yVh/yv2P+L7fNU3zx/5GNGrheu5jL2K6TuPv68YPm+k5SzTNXtKkpI+HPcU+6699ury6/14jn3z8ihGy3oV7i9dN1wiX79SCMm+0HD/A9zHK/7FfPuT1as6e5VMV03XX6756n82zf+015nqR75edW22XrVJuj5uh9A2dx4Y1av6dHcG95vfilTVq+EEVn+5XMet10L9k/TqafjIuG2v+nZ4t93wfnXTqzDMWu3tw4Lp/V2vapM+V+LmgedWr2rqVfzqsG3aru8/fsJPT8//dh8/6Pjz49x7236932BZr2Lye/DSJ78Iu3az3Q3J+at0p0Jov/mArwtLsZ7u3Mlicr+K9a0Nw+N7ZbInPN0eHqq866Y/rZteXa696p9e/ht69etl+EF3W/cq5upV+KpX8V6Wgs2ix+lVV3Ov0kaFT+8kd9CPzUnh9lfzNpPlzbNFvv6mD9Sr5GP6LulVG/WK9XvVdOkKo7peJUfIzf3194mN4YB8+jgl+/pv2uv9dptepQdHt/rtmU4tcfg5xu7p6en9WOwuP/6vf3v7d69+DN/NBr0KNxssws1qNddvqeGG16LDeA5f3+nTMyKh8l4l78Th9/vrHz63+XqVHqf96l8hpoGJXX/94+fn5/dexf7lo1ehjT9ekp6s36tPpTbIsI6fH/fLl9j0/df31y0O3q5N1wjxzlrpu/t2aEfWIVngxdA/Pw2f+59Lvhs6rcIlbnD0Jp8zeaLgn/PtfxoVuj/7r8LbL6DL5cvbKtPPH2a5/Ho/jOI/bbxcct1f29gn6802HexuHpec3av7x97L8/D2vxl71ebr1fC1wtvb4b1iL81Hu0L/aLWvVxTcq/Z/u5jv6O2658tw1uSSDnZ9O/Kgif240ycF9qrfuFfd5z9/WwRen+R806vGLiSqkfSqyXf09v2P5+GIeXpKvvLN1b6/XfJd5vXqJV+v7j0rRa9glp/XXrVNn+2rXvqfL8NXe87aq2S++mevXoX1e5XuE+g+r6nfe9UNver0Cr0a3at0vprZq+Xrwb161e3Rq0av0KsV1oMb9CrU0qv1loPxTq/iV71qOnd99Eqv9AoO3avb8+3dOr1K98Gm//SovUqXwO3jXrXu+ujVCr1aY776/E9PNl/pFXpV13x1tl5Nma8avUKvVunVRa826lVMe2X7OnqlV8X1KiS96uf1yn4H9Eqv9Aoq75XzV/PE8b0K1oNU6kcsrVcr7b86236Gx726Pn8weHwQvdIrvYI8vfqvzbjn2fmrmnrl/BUlzldF9Crn83H+J+P1r0rqVaNX6FV189VJe2V/O3plvjpqr8xX6FUB+xmO1KtgvkKvqp2vkkvipY8Phpy9KmG/aNQr9KqiXt3fz3DS+cr5dvRqpV5dMl7/Sq/0ijp79d/Bzl9ZD+oVelXLfNWkvWr26dWI+Sps8fkfnb/SK/RKr0b3ZINeTXl80PX60Cu9Ouh8Bd8dYTdv69W8XpVw/qqEXtl/hV7VNF+Vt1+03blX5iv0ynqwlvlKr1ixVzkfkNGr881XQa+o1Dl6FfbpVdAr0KsRvQoF9KqE+SrqFXq1tFcvy3vV6VXzaY66O18FvUKvdp2vJvSqsP0MOXtlvkKv1p2vLuft1Wr0Cr2qt1dN2qvmTq/yvSJQSb2ynwG9qnG+0qtRn9phgl5l7FXQK71CrypZD+rV515NeT16vUKvSujVGufbRz4xYf9exbm9Ar3Sq4p6ZcRCrw6xHiykV3Farzw+SI1e7GfQK6hBrLpXK1yfIWevQlG9cv4K85X5atS3ZL6Cxb3Kdx/Wq9V5fBC90qvjrAdbvUKv9Mp8BXqlV3oFRfTqKe1Vv36vSnv+4F69Cst7ZbMoenWUXtU3XwW9Qq8K7lUB15MpaL76feNM+Q70imJ61ejVoecrvUKvVuhVp1eV9Ar0ynylV2C+0qvHjw/qFVX26qjn25u0V82GvRrbk3r3M4Be5ezVifcz6BXWg+X3yv4rvcJ8pVd19crzcdCrpb3q9Mp8BZPEA/eq0Su9Qq/0ynoQTtWrMvYz/NIr0Cu9KrhXjV5Rq1PvvzpNr5rbXgWvl4pema+m92q1EOgVerVVr7rj9GrsEb9xr6a8npdeoVcnXQ8W3iv7GThSr5q95quNr8+gV3d7FfQKvdKrStaDeoVenbVXNZxv93oT6NWyXnl8cN35Ta84U6/isa4verZeRb3CfFVtr8ZcD/lI60HnrzhDr+JB5yu90iv0ap1edafuVczeq6BX6FXBvWrSXjXH71UwX3GmXtnPsEevVvO4V+3Qq6hX1Nmr5gy9Kmy+avftVTOxVx4fRK+sBzP3Knm+s16hV0X1aszrTez1fJyd14N6hV5N6lXv/NWO85X9ouhVwb2yHtQrDtWrfz0fR6/0ilrmq2g9uCW9AuvBe3HIdr69vv2iri+K+WqLXkW90is4/nqwkl7tsR6ccT0ZS0LOvB7s9KqqXsGZ56ub64te0l71K60HG71acT0oWZy4V939Xp3s/FUlvQLz1cP1oPlKr0Cv9Ar0Sq926pXrIaNXG/ZqyI3z7eYr0CvzlfkKvVq3V0+b96rRK72idq932ufSenXRK72Cr+3Uq/7uetD5dr2C4nplf/uDj5j4bUzoVdAr9EqvKnn+YPD8QfRKrwrsVfB8Z/Sq+F55/uD9+Uqv0Kspvdp+/9VNr3q90iv0Sq/sb4cTrAdz9sr5K71Cr/SqyF5FvUKv1u1VzvPtW64HCzGmV91w4+gVejW6V91x5iu9Ar3Sq9m9Wnn/FeiVXukV6JVefb8edL4dvZrUq16vNuxV1Cv0Sq+O0yt71qlOPEuv7GcwX3GIXr3fcdt/9UqvoJJeHW2+aqwH7/XK44NUqa15vgrtyF41euX8FXq1oFfd8169etYrvaL69WDWXnVdxvkq6NWyXmkbxfSq2adX5qv9ehUe9yroFXq1S6/MV3qFXi2wyvmreY8Pnq5XoVn6+KBeoVfWgwX3ynyFXo3qVatX5ivQqyP3Ko7vVTO9V5LF/tpCetXrVeG9Ar3K0yvn2+2/Qq/0qtJeBb2iyjXEjr263OlVZz2oV3DeXjVt8u6z6yF7fBC9mqL71KtOrxb/LPWKE/Xqv/161ae96vVqs14lr+885WetV5Ti4+iN/7VZe/WUdGmTXoX08E17lZzoP2yvwqc///N+q1dUrkt61XaH7VWf9uqg81Vyc3Th05+/9+qiV1Stvx69/7Z9vqO37S5dTOqVHmtx5JES+5G9CjF5anR4Snr1szvOT1KvOFevLhl71fbJmqVr75x7+XbZEy/trF5djt+rPr09u6FXzxN65QLvFNir6xH7q33O2KvYJeeUYnp+KXz32zyEoVft0+gZIfmHMTnP3zy3R/k5hrRX7SUOk1H3usAO77fCj/j71NbvXrXtozLpFeUZVl8vMWOvXrsTZn7c0KtL+3Xbfg9X4dMfpMNWckweaKWTvlzQba8+2vPaq4+lYkjPVT7sVes4oTyXnPfLMK9XbZjRq7tf+FC9+mY9+PYQ4Z/1YPveqz6dzcxXVLg2rKBXcehV7JNeheTzje/VpwulVF2vm159Ot/+MV89NW/rwdfb4OEsrVeUrc15Fw1z45D06k5hHvTqzv8/0HwV0t88v3+qb88h/LP/Kr71KvSPpky9ovB7fM4Dd+1ehZtZq9heheQLjv/qY4bAtFfdFzd2CH82BMfwdvt0x1wVQ1HS9WAM1c1X2/UqfHOC/G2+iuFtK9Zbr6JeQQG9Kvrbz9Kr+OUw+/YE0ff1oMf8IGuvOr2a16swsVfmLzhlr/4+/nP2Kr6/F1xPBvRqxwJ+2qehV6BXK4xUNw/f3V5pPsz/9HoFBfcq6FVz52PDnb+b2StAryrqlREL9EqvQK/06pteNdaDkL1XrV6N71W47ZWBCbYX6+xVhiLqFehVLY0Kj/7dxwu26RXo1Y69CtN61egV6NVu4Zry96YryL4ECp6P0yTz1bcN+nt/u2JB1l61ejVpvorOt4NeFTFfhWm9sv8Ktq9V2qtgvpo9X+kVZByvfl/fV66m9SoMw1hY9wsA3x9Crjs+rVeNXoFe1TJfhXBdD0667dzOsO7qUK+2m6/cyqBXuecrvQK9qma+aoZerfnZAb3SK9Crs/dqxu52NzRSo1c5e3U9fzXj+YNuaPRKr6roVdAr0Cu9AvTq678f9otOCFCnV8BuvRoxXwW9AnbtVTP0ajy9Aoqerz5/tF4BuXr19mpeegXU1qtGr4Cie9XM7RVAxl41egXU2qs47bPLG7BLrxq9AvQKYPVeeXwQqKJX9l8BpfdsZq8avQL0CuDbXgWvlwroFcC660G9AvbukfkK0Cu9AnbuVVjrCwCs26tm6NXKXwBg416Zr4Bj9crzcYDsvXr7nzj1/JVeAbvNV8F8BegVgF4BZ+xVo1dALb3y+CBQeq9cDxnQK4CN14N6BRTdM70CzFcAa89XwfUZgErmK70C9ApgjV7F6/VFvb4zUEuv7L8CKlkPhuDxQaCSXtnPAOgVgF4BZ+qV8+2AXgE8FEcWZXGvAGqZrwD0CtCr+3/v8UGg/F4l81Vc9wsA6BVwxl7NPn+lV4BeAXqlV8BpewWwS68a8xWQ3aTn41zfsf8K0CuARx3SK6C2bo3sldfzAsqer2a/nhfAcjOvfxU8PggUvR7UK6CWXl2f79zoFVB6r8YtHj/p9ArYrVczzrbrFaBXgF7pFaBXAHoF4Pk4QK29Ml8BZffK9ZCBeuYrvQIq65XrIQN6BbB7rwD0CkCvgAP06net4qxPAJClV/ZfAVX2ynwFFNqr8Hk9aL4CrAcB9ArQK70C9ApAr4Bz9MrznYED9wqglvUgwD69CnoF6BWAXgFn7NWMV/SSNyBnr67vzHi9VNjQ/wOv7GMNEOUKAA==", "force": "H4sIAO+51moC/82ceVTV1RbHL8akgoCiOCBctJRUFAWHFOV3DjhhKaaZ01P0WZlPA332cEwc0nzmWBpaPqmQ0AZxiFAhcUiwUEGfiisJEBUUrZs4oJI+9rney73wO+fs8PeW3X/KxWd999n77LPPPr9JpzP+2gfEER3i18drK4rD6mG5p/77OrOPlnLK4JeDUdzNTQqGKzi/QFPOsP0tFNejbCmKS58QrimH9SPqZ3cUl9QuDMXpArppymHtJu3uoSmHnd81bms0nY81z4zDzZvD85rOL3Z8ovw7tzvPvGZL5q/TNJ9j3AJQdvUD26G4mHkRKLtYPSwnyj+r8SH9jfKcjbMrWEeWnCgPasNFtN+vqR62HkRcKgl+0vmtDef7e+oT1xdLf7GcaB+sDYetB01jF+Pq7pgp2tZxjfMg7qMMFKcLLNeUw+4zWA6bp9j4xTXqrem+heViTscHY/IU20ek2H/0l+5zsJzW/VqMf66m/cHTiouIs8yX9IN+uLy6eEBbDjm+qKvNnkq9F9VxbF9itc7jnkXpZbtRFOfv1hLFaV2vsPkXN32kptyAHTtx+9utg5qeQ7Ecdp/B7lvY+oI977te+FTTOGuxz1jmqSjOWnO1mV/sfondj7Actl/Dnt/KF6/QtD/Vet+KuKetXS3ywKqeCuqVJSdab7XhsOtIC+7/WSex5yNsnmKvqzRtnawph/UXW3exetjzNFYPu89g9bTe97F2sftgzIoLmsYZm8+iPrY2/mLrH/o6q+C6bW2uS2HrkGjfqs31uszEOG3Pg4Jzt9VvpYOm+74+8+NgLfMAe13+z+y/rhU2BMOlp88khf0SFNl8PHLRUfK6E5HPmzvV6d6TctemtaTTHv0i5aK3NKMXuvxGZPP2nZMHXRj4gMj6+639XGj+3SrO6txrcU7+Ms3JiqusYopanWy9thFNfOa2mes58aSits6BKwu6auYqtVU5r0eu1CHrVzP3xpp6pPo4IV94elbn1fIZTM+S23anRFHrcx7MbyAdH9S/oFdcqeeKIiEHemD3+KBcIqt/YHfXH3nSPJgwsrGUA70RJxpKOaj3wMnGB3kF/so4OAfAfEjtVuphxgfnVbCL1ZOOr7JugN7a5AIi66/abXKjE6ZcFHKZZ7Yzzv7mJSKraxNPudGoLr9L7Z51dqd2fy8nsj5C39ed7tovrgewb43r6kEvvnGbiPYj6GNn7ZbbBT+As/TjtStZquvXLcOFHp1/y8z9WmxL1PJ5HW1I68y8Yf7b+qWXVNfbon94SDnww+akOw1LKqla521KFbW6Nj2jmZWeKle5LwAX7nKthh/Vr9cVTfaUzi/kX3SYlzTO4G+oR3OpHsTv8lkfq/GJOMu48NYRcLPPFwrzRf/qUmX3lTb0TullKReR7EcHp+USWT8E3LHSbGmenlrgrxkH57L7Pp1pjxU/CrkI3y2Mq9zTUHoB446juLEN5FxcSEcp55jztfLFqg703pIMKTcr2ZeOeJQutTvgRjvqO/yglKvzbFv6pT4VpZcxN03IQV+y/7wfigN/Dww9JM6/yv2jzcxOUi62LIHFRWYXuJQ1bVCc3b1npVx6r1hWD0ruieMH+0K9f+pV9aYf8VQs69D1OS2lHIzvXIWP1K5+3jDU+EAPuPADPwg5eO4hs38LWv/wPnG+XFzLuDOJ4vxLebicy1n6C3E+FN5U1a4lF7FglpKX2YQOyUiuwZXZfmvlx7pPPVQ5q77T6102vrdydtTg1ocMN9tNCpyqXP3InXqXJUr1Ij1dVfWs+uJOjkr5DWcpB/e775XUU+Ws4jemPtObduRLoR7cZ4J+SMbB/SPgUv0Tpesc4iLTg3UOfZMaZzW/lf0p6BXvixfqxZ1d9ae47M/EcY4Z1Dr4pk8jOmtTsnB8Md6jgyEuGC76bCO6+B1x3sN8hHd3p6lzMoX5p/9jWzCcG9XWr6Ue9BEerzem4zdkSfMF9FYfyRaPr7I/hXWZ0O6MkIN+Ejg1PUs/gIP1JrMLfkBfp+aHpR70YVAPZHbheTjQa7HqnNBu+P01SkyOp9Rf6F+gnkbm/Cytu1DvR39yQqjnn/ED2z9kXG7Sci5nee6H+tflkRddOua0UM94n91TlbPUM94/8pSOD+aN52/1+/vAqcW5+v19DAd9MY+zHB9wkKfNBhZI1wcv763iXKkH600Wl5i7d/vsWdVctU+sPr5GOzykHMwHT6963eVxlvsl+Eune8n73YGLlVMpLnR2wVHxvpDeRNnk+hsJ/e6oVO96iwtEZhd+8Xu2kXVtDwn9WJNmR+6MTCHujbcL9XzrTmM2l59IFZ632kzeS4Kbe1Ib76JqXIzV2PTB7we3n/wSvRRoR7lrDfbV1+crhxyW1OC+j3C31q//r8r98kPa9oh1/n0XNNXq3/o3BylRo+NrcKY4ma6hwc/7wyT62pUXrTiYI/jv4SwbaqqXJd8nU8vaCT+T/zM31qcjjrix/2+X/zFNa+Fixa2aajyXgn9lzsOI8fpDJC2z7WrF+ZQksb9NmeBL1wauM2r3WagyFynkkPdKWupAaWTfHcovtJj0HfZVjesAsC9GV+yhfUoIHdNiLalHi0iRx4Ya3Lb4y2T+/Wy6vmMInWG/gvRY2F2V8xlnQ+3OltGGvfvTFVFzyK0GTcnQ6Mga3FfjHpK1GaX0QXQIbbv4TZJSaEfmGmrey9n4wR0SklvMuIddR5OIgJmVTEwNzvmRPc09eZUeTexFl00bT4z3o2pyB8Lq0AVLi+kHLr3p4M3hBPpstfwF7sg7V2nPS0F0iu0gLmffy6gHdvP03UlS6WhVLrrJM1YcTy/0BXsrLts5gTs+4Hxe7cbGN/n5iifiwK7TC1XchTYOhKc3Z9RVxsH4En+xfyKuxSsPSLBymXEtbvfijg+40OaXaerV7kxv8rw6qnpNOt1neiYu4poTl1vbv8g8Ph5X2MCGjt1QhNIbu+oy7evVk8VP38WZq+dxsIj+a10X6jw4hOxxtFPlJm22oR3nFNIttwJYXOJPuHE58GOR/gU2vnRnPXd8dkMKGXd2Rmehv20W5TM/WJw58/HJFh0FPeDsV/UjUYVlitq+Cxz9dx6deD6IztsYQJIyilS5rE22jAM94KCvU+NgfB+PyDfbVfarj2/+S7ZmP0BPSb3J9QPibBqfYyP1uMC9saBd+fSP/GAhdzO+nIAecDC+7FbXVe0ago127XcZ8yrzjbqExy3sls/qVbeTbbl5ZdPHlhp659HhjRXGRdnfVrW72MWWLnkrj9ldXNaaxEQ9VHh6D5aeo60+68n0ZnWyVbXbwMWRcX4zejEu2yGbc5/RlUatPsPsAuc/9ByHc2J64C/kPV/PyMH6HfsHUc0/y/EBd73lQOLbNl/KgR7PblyDKru2bn4kZVk9wvN35dEzVVxRAccPRxYXqPfgr++FG1zOpAccb94qT1jEL+UMLc99PG/x5zlcCZlU5yz1vNWNjv60KckdcpfLdTh4mtUh8KNDvI6IuG1TujK9gqS9XD2nDSfNejx/X2pdQjrnHWecbHxfn67S43MGcvnKTzQhtDPj4nKKhXrpTQLpK5O8SHiDVC4H4zNxsX87KuQyB3RlHH8+DIyD9SHmSswc+MFfH0YuxNVot+TmKW6+AGeKC9+ujkJcTJx//W1CzjQ+wfM+VnoiztLfzC45Uj3Ie+gnef5acvx8MXKmeoXl+OtXR/2dTjFOnM/lBLjP6/uzeeNzOjrixxzWR4jzRUd3Ljlt5vbE1eHGBThYR2LOQMCuaV0uCfUSchBn4AzdnLhc2Moclqf6z+sK9MoZ93NeAON4+yVwnd4/afaDt28BZ7f+OKtXoCfjQK+yppOIimvcODv8x1hfMBzogV1+3ajsrx7bBU7ZeVzh9SXAmeLM4yDOswefNMcvN/a2UO+5nv7Mj6nj6/PO+VQ//icWP+CCIl04nC3jTPHj2YU4W+qJuJQRx83zm77PwPUX9GC/FI/PQGA+5H4YSEFhlR8yDtabmDPmlSlfeJy9Tzkb3w8/dWHzxuP0rYx+mPLFUNqQy1nmVaZfocKza/IXOP6+X2UX/ODVXdCD/dfE8fSA6/bFMRYXsKt2XjDtb8DJ9KCPsByfyA/w1zGlM4ozrg+djsdBnIEz5pVOJ8pTSy62Yi+K4+/nBpJSeuyxH3Ju0lw/xhm88/jrrZKD/GPXT4O/5darQ7czpHpw/gDOmPc6HW9+4fzh2fiQmeP1G6HLHOmoi2m0u1t7xpXb7FDlRq2wteZiD3C5dzamPV6/fL1OQUa7pjxIsT/H9aPi3AEzF5t6X6iXuaiDUY+zjkzcfx07Mo7Xx1bnRu59yPV3Sf99Zru85/7BD+BMfoi4ycNS6PcnfBk34O0t3H1mUlQyjczuKJwPyJcLy3eZ9Xgc6A0cnkT7eD0nzL8Kbx0dMnc7fXuQj3A+4Hki/81bzRwvT+E6fe+3t9HuY412eefusKw4sjkqkabU9WLcJz06qs5vQuhOcuPlrXTSFW/Ghd4ZosrBdVu70JUU5g/+3bD4A/XrAu3sqU3YDHpvfTH7+75bX6nb3f0jWbCxPw3NMF4nP1HvG1Uuf/YiMiorkJqegd0QuZxTJ8PJht+bEBMXezBalUs+7EAuDXYmy8LTGBd/M1L9ulS/BKXOIx3j4Bp7zNFBqhxcIwKbQ6N/U5IPL1Ni7R6ozgfc0zCNDfSiLh1AcSOnG6Qcu7+6epmUC8vSEwwH+xHvuX/gTPcqoB/yfzlQqsfOjeUzeHXc/Lwv6Ik4kx5whhatUFx6SN9gGcf2z9U6BcMV1NuE4nRfJKC49G+/4XLt23+uWN6f5nHN51XVxvfG7OFylvdFDCMmojiRXvX77Fpyovd7aqMHz/ugOOT72Fr4a3nvDp4nxHCi99Ws/HjxmKJD/ETfp7G6i4p8jyrp3SwUp3a9Xe3nG1uG4srfi9WUE61fq6ukgnmzujqLfC8Q+14b9n3Jp8Vp/Z4m9r02bJyxXPngI5rqjdycjct75PuI8Dy1lvOB5bD+av3dQSwn+l5GbfS0fs8f+z4idnyi/sCqjiO/d4OtQ1pzWn8HA7uORN+Rs+KQ7/Vi46z1++LYuGC/S4Ld354Wp/V3MLDfdcHaxeYV7/mc2nKiPrY2HHZdYuOsdd3AzofW383D1nvsOsfuv9j1i43LX53Tuj/Vep/Bclr3YdjvUaC/W4GsV+jvXubv07R/wXLY+eBdb/ofEjgzmvhcAAA="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0108_20261007_102934/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0108_20261007_102934/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..478e41b8cceafefae861a89d62f203bf2a607060 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0108_20261007_102934/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0108_20261007_102934", "meta": {"split": "train"}, "act": [14, 489], "T": 490, "maps": "H4sIAPC51moC/+3d63qbSLqAUapA8iF2unumZ9//pe7YlkVhCwskDlWw3md+qMdJuo3FylclhKpqX8UQ3/4Xm/DnH+oYjuHjYW+h5/GVf03P43wPy/jvURKveCXp06s68aoe5dVweXglaWKvwiCvQiLPkNM5JkaFMry6ZYbsMT16kkkFeVXgfMUrKU+vvu5fHcIVeXglSRkxzitJpXl170xWWw9KWmo5GDoOhWG/KZGPV5Km8mhmr8xXklaZrwKvJPFKkm7rvN9+k1eStN58FU5eEUtSKV79eWB9J4lXkjStV2HIb2qz3y6JV5LEK0kb8KrilbT0OegleV5JvOIVryReZeNVHPKb2tyfQeLVkl7F0HoVeSXxKqu67x/klcSrrXoliVerexV4JfEqc69Gvj4oiVe8knil/mPl/qISr8ryavj7BwOvJF6ttB4M6XowjL9elFcSrxY7Vjfcr49XEq94JfFqj8fgsi4D1oPe7yzxqpT5ilcSr7Kq5/orXkm8ytyr5MCNvv7K+3ckXq3iVcUriVe8ksSrNbySxKu1vIru3y7xqoBj1X3/YOCVxKsy5iteSbxac37ilcSrTa4H7V9JvMptvuKVxCvzlSRe8UriFa8Sr1x/JfEqu6a7XtT7cSRe5TxfSeLV6l4F85XEq4yPlf12iVdZ5/7tEq94JYlXGa4HG15JvFryWFW8kni1/fnqY2XJK4lXi3pVtV4NGsrazFcSr9bxKvBK4lW2x6q60yvrQYlXy3o17v6ikni13npw3H67JF6tP1/xSuIVryTxahqvqtYrSbza0HzlegaJV8vV9/mDvJJ4xStJvJrCq8Qh17dLvNqsV65vl3jFK4lX+kLPnfdn4JXEq5y9ksSr9bxyvz6JVzvwysGWeGW+knilqvd60cp8JfGqkPmqcn8GiVfbna9c3y7xalmvqtarQUMZryRerbUePB84Xkm84pUkXk2wHuSVxKtSvHL/K4lXvJLEqyy8angl8YpXEq90gR73k5F4tWGv7F9JvOKVxCtd8yrZv4pDfhOvJF7N4NFYr1zPIPGqlPmKVxKvSvDK/WQkXmXXdO8fdD9kiVdLeuX9OBKveCWJV7ySeLW/Y+X1QWnIM302VXg1/CDzSuIVryRe8YpX0qbOUV7xSuIVr3gl8YpXEq905VjxSuJVKfMXryRe8UoSr3gl8YpX7n8l8cp8JYlX089XPm9C4lVW9dxPZsB6MFQ+31niVSZeDZ+veCXxaj2vfP6gxKsVj8Vor+y3S7zilSReTbse5JXEq4y9Shwa/fmDvJJ4tcZ8Vbm+XeJVKetBXkm8ytmrfop4JfEqq2Pl/c4Sr7LxiFcSr8xXH1452BKveCXxSv1euV5U4tWG5iv3k5F4Vcp8FcxXEq8Kma+sByVe8UrilXgl8YpXvJJ4tZpXrmeQeJVdu75fX2ifIuHL/93zTXpCiVcZzVdV69WwE771KhT3swmt16Fzt8IQev5DPaHEqz3OVzFkocC7sqGu6vQ/jVfi1ca9inE8FOef02o/qPf/jFi3H17255/rfq/s0YlX+Xk1++uDoe6c+6s5cPKqxTaEP3q1h6Hq3b/y3BKv9rIeDLGzBKtX/f5jbL2KITYXFY3pNxx4JV6VPF+NWw/++dE0CVfNWnPL+/dfh8Sr2HoVIq/EK/PVV68Oq66z6mSU+iMXr8Sr3L0KS19/lXhV5e9VSLx6P1aeW+LVisdq6evbe7xaA21eiVe82pBX3c13T6ybjnL3ufbtsXPQMRh8rCpeXffK/tU0Xm2e/psufOHVaK/GnY6TeXU0X2382dVzWjpFebW4V7fdvz1br+qLXnku3elVSJaAwSnKK/MVr3jFqz15teDnD9a82qVXIfHKbjuveMUrXvHKenDa9WDHq1V/Ul2vYutViJ5Lk3lVXTCKV7xaa77ailfmK17xilfmK+tB60FeZeJVLNqrcNWr+OUb9ty6dcYKndcKHRhe8YpXvOLVbrwa+H6cu8rVq579K7dCFq94xSuJV0V59bC2VxeXtfbbxatcj9XS92fI1qsmmK/Eq+3NV1v16vTE+X6/vo7tEq94laVX7icjXuXsVRzym+7wqsnTq7o6RPOVeJVjq30efbZe/ZmvovlKvNq2V3Ez81VjvhKvNujVXXXu12c9KF55Wo04Vmt+3gSvJE+r0V5VrVdzV5ZX9q/Eq8K92tF8xSvxKlOv4i1ejT3YvJJ4VaRXq37v372qeCVe5Xys7r6+fUPzVXXBq5pX4tV2vNrQ/lVlvhKveJWnV4FXCx7hLx8+cf037OZgB/cAudGrile84hWvzFe84hWveFW2V6v+pOxf8YpXvOKVeMWrDXq16vfe+byJHq9Ccj1D9EKOeJXD34DDvbr7lM3Tq9p8pUkHyuHPFV6Nnq+q1qsxI/7dXq29HrzuletFZb5aZwdhCq+mXQ/m75X5SrziFa+kz9NOvBriVfttNNaD4lUhXu3zeoaYehUSr1qkOq8PRk8s8WoFrzL5fJz8vTJfiVcb8irySuLVUsfKepBX4lUp89eevWpvNsgr8YpXvJJ4tdp6MPBK4tVc89PlU9V+O6/Eq0K8mm49uHWvXN8uXpXt1V3l71Vd80q8WvRcbM+wMNyrildfvbIeFK8y9mpuQlKvDmt7FVOvwqX1IK/Eq9zWg8Nw45UnlHhV0nyVXM/wLs9d1zMU4JX9K/GKVx9erXuMeCVelbgevP3zUuP2vKp5JV5lOl9VvOKVeFXoenBRr7Lcv+KVeLUhr+6qAK9cLype5eTVl/cPhvb69rjo9Vd5rgd5JV7l6VXoeBXm9+qQj1c1r8SrrI5Fz6m63v0ZMvIq0ejt83HOXqW/yPWi4lU+Xr3PV+2Bm//zvDperf15XrwSr/Y0X/FK4tW8XiVfH/l5qWHS9eDaXtWpVxeJsn8lXuXmVbvfPvs5ySuJV8V4FXgl8YpX46p5JV6V6FXVejV3vJJ4NcqrSV4ffL9Sffx+O68kXk2xHuQVr8SrTXpVFe6V/Xbxamdejb9eNPJK4tUIr/r2r8IC8xWvJF7xilfi1R7Wg1Xr1cg/1HpQ4tX95+LnGXb9fn03enXb5+Pk71WIvBKv8veqMl/xSrza5HxlPSjxagGv0pGqc+B4xSvxarVjcfVYuf6KV+IVr3q8qngl8YpX4/L+QfGqCK+mu170Lq8aXsk56mnFK16pHK9CdBhu8Kra23qw4ZVyOE15NewMW+F6UV5J5qt7vDJf8Uq84tXPXtUre2X/Srwq1qvAK16JV7zK36uGV1rrNOXVj2fYJJ+P8/En8Uri1RpeVeYrXolXvOKVdPl0sn81wqvqPq+sByVezevV16/v1auGV1r71Hx7GxyveGW+UhlefXmaOSi84pV4VbxX347dvvbbrQc10+l29blyfq/u2/7V3u8pM/T+7bz67pXPxxGvCvAq8Mp8Ndt0H3ZwIEd5lf79uHOvQupVGPGMWvh+MrzaJ15bPT15NfN8xStema9UqFeBV7zilTLzqnt9O694tbBXjqTumK9C65X99trTR8rUq8ArXkn5TeR5zFeRV5Ju82rx+4vySlJHlNgOTpf2Nrv3vzr/rrDAjjKvJPFqFq+8PiitvR7s8yrwildSZl4lX7/BK/tXklb3quLV27dXm6+knLz6vC4rpCNFqOxfWQ9Ki3ftetF4Oi1PXzws6FW1rlchGQ1jnYyJ58ed+18hSlrsrPzBq8PpNhZvX38Mw72689atnfmqWX7KbPfoUq9iaG/snO7ieaOblMX89dC0J/Kvejmv6tSrZe5bffk1hTr5WJKYIMUoKTuvHg+tPa91dz9rKa8Oi3uVPI7de/7zSsp0qfjFq9/NAK/C5F41x3q5b5lXUpGj1YdXTftS2O+6dax3mjqdx/d6lexZ1cdlNrB6vOquAS97xS4pB68eEq9eU68OyTkaZ/WqWsiry3aFPq88R6TcvYpnr+K+vZK0jkvf1anrZD14SNaDZ6/STaWfvbqFrs5rgsfDCgfm0rUe5itpzc5vlImfRr09Plbxc6R5eagOj6fH/z0kl3U/Jog0yQAUJ/HquLpXFa+k3Lz69Kd+f7fJ6fFDVX8S8fpYHZ9Oj/89nN6b8mbJ06HlqONV/GmGK9wrTEk5e/X7qTo+nx7/75h6dZzXq6rHq7j8QfJK4PSHNPQ8dmj0Q03dLgyb1qvD2au/Eq/+79Or+gev6mm8Si+5OqReNX5ovNJOp6vWqz+PPylojofm7NVz4tVDwsVzz/5VPckA1PXqmP6DH9vGvJIGe9UkXn1S0Dwcmk8i/v7V49XTcK9uma8ir8xXS5W+ME7SjL1qUq/Oy65D6tXLAK/Sm0HV1eReNalXRz82XvGKV7zSTteDMbndWYh+OrnWtF41f5aDrVfH89rwzaunxKszR0/NZa/iJF4deMUrXqkQrzqvAtadu436sWnG9WDkVcbrwfaaq8Sr5ng4b3H/fm69erv+qr62HpzGq+7d0Zs+yVTsfHX57qzrjF3pdkY9x39C/Zj8Kx4ekq+8JOuF4Ll97TjWdYJXkw5ep0fPx3YJ9ndzekp9ff/ghXff3Pl+nO4HONT34SdenZ7u7bZCcmvrOnlNu57lXmuHX8mG7PPTY/uVv5PH8cET49oPMF626/z4/f4Mp2fRS5141ftMnIEVF5jzapL9j/YZfojtLSHTZ36cxauHv5/bYeD15aX9yr/J4/p5+NF7733fLYZQhxDDDk6eGNOdxuRxPP/l8/5TPX3hcYhXxiAV4dVDfWllMZNX//xqt09+vyZG/e81+c/7NdqrsDev4vnbiMlOY/vdvw9gp9sSHONwr6QMvTo/c5v4eEYqpl7N8tx++M9ru9b76/frZa/ql1u8Cu8XjfV/6Pp+vTqM8uquz3fWLrwKiVdh/hMq8apO5qvO+zNm6eGfWb2KIXRvK5n+DRCT3xbLXv/UHa8ufwRhbA9A8/n0mv/z6KXFvIp1c+n0nsur331eNfd5dXl27fGqyEkrDvXq41d83JOKV9qWV7nMV4O9+hSLV0O8qnil0r2K681X968Hb/YqFO9V+sFVV70a8Xn0vFIx81Vd2P7VKK8Cr3ilDc1XM10kyiteSbziFa/EK15t0yvXX6nE6r799tarre23xx6viuzTq6GvD5qvxKv1vRpx/dWevHr7f3ilTXoVeLUTr1wvKl7xilfSNr2a6f3Op/OMV1+88v5B8Sq/+YpX5ivtw6uj+WpjXn38ksp+u3hV6nwVecUrbcmr4q4XNV/N7JUPSRKvrnk1/n4yiVfVjryq5vBK4tUsXl26nqG6+n6c0HcT6oK8Ct4/KF6VOl/t7v2DvBKvlvbq93LrwZ16VXW88n5n8aoIr5Idn82vB5Prrz5+rPbbtRGvwl7227d1PUMY+PpgZT0o89W8XtW84pXEq715Zb9d1oO84pXEK16N8arilXjFq83MVxWvxKubvZr//gy84pV4xaudeCXx6qJXv1fyKtpvl3hVhldx4159u79otB4Ur3jFK2lJryKveMUrma94xSvJfPVVLF7xSjuZrw75Xc/wi1f3ePXxS755Fbw+qELBSv7ePNbL/XsHzle8GuZVNYdX5ivl7NVhPa/MV8t65f6iMl/xilfSjFz1vD4YM9lv59VEXgXrQW1rvkr32yv77Zucr7zfWeYrXq3t1fXrGapkvuKVeMUrXklLrQdL3m+/dn173KdX39aDvBKv8p+veNX1yuuDKt+rBZ+TvOKVdIdX4ZA8J+1f8YpX4hWveCXd79XR9VcFepX8Q9XvVXq9aHR9u8xXvFpvvqquzVcdr7w+qEK56rmfzJrXX73wavL56sRa87lu5JV4le98tfXXB0fNV66/Eq+ynq827FVLy0/7Vz7PS1vz6rgtr9rJI0TXX3m/s8xXGXsVe7yq9+lVxSttbL6K21oPVrv36tv7na0HxatMvTJf3bcelLL2arP77Zv3qro6X6VeDbieIVgPKkew0s+byOP9zgt6Ve/z/qIDvZJy9uq4Wa/Chr1K/qG66tXg+7fzSrzi1VpeBZ+XKutBXuXh1dXrRXkl81UpXkVe8Upb8yqP+yGbr3glXeTq8vVXIbmeIcwydS17vWjY/euDt7zfWcraq5pXW/XK+we1Na8Om30/jtcHXd+uzXm1i/vJ7HS+qsxX4lUmXo34vNR97rePvb6dV8oSrPT6q328Pljv0quKV+JVzl5V5qvEq5CuB30+jnjFq015JfHq5NU/iVe/F71f36bWg+FHr3rv12e+UvFeLXp9+z8vC3q13fkqDL3/1cffSLxS0Vy1z8M6vV9fPfvrg/1eTfZ5XvuYr0Z55fPoVXJN+zxsYsFeXbyeYQdehZ+9ejsUdXI9w9HnD2qLXjW8KsqrcNWrilfawIIweR4+5LF/NbdXcateBV5pR/PVw8bmqx3c/4pXMl9tZL7awftxeCVe8ao0r96zfyVeFeHVXu/PwCvxaiNetV/bx/4Vr7Txkv32Ot1v39j1V4FXN3gl8cp8tZxX0XwlXs3r1dzvd+YVr8SrjL0KvHr/2oFX4hWveCXxilf3e1VX588fHHd/BolXvFpjvgo3zFcSr3hVynpQ4tWsXoU9exV+ul/fh1c+L1VFl+H17a5nGF4c7FU13iv77TJfWQ+u4tUN81X6iyKvxCvz1TReVXN7Zb4Sr9aYr+I+Px8n9crnO4tXhcxXcUf3k2m9Cl2vRn8+jvWgcmgf++2bn68Cr8SrLXpVWw/ySryayKt/b/Fqr5+PM3i++rLfTh/xaj2vRlwvar99iFcuKBWvlvQq7sGrwCvt1avHRb36dWy9en3p8ep5kvmqs/uyNa9+Mub26694JV7xilfS7TXJ46dmQa/+7vUqWQPGe7wKl0/rbXnV0eniL0nfP9i4vl28uterV15N4VX4wauQehWMTyqyQ3P+e7OJz03y/8/u1XPi1Uti1H9Tr55GeVWFZD857tmr9GHdrgd5pa14deh4dZjfq8P6XsU9eBV5pS169WsXXoXUq7ARr8Iwr+ofvIo9DKJN2XTseHVY1Kue9eB/0j2rR15d+abPqHQuMuu8AembVxWvVKhX7cNlvfor8eol9eqfW7w6bbb3e5Wcl8mJWW9qvopfhqrT91uHQV7VvFL2XrXP0of65bDcv/jw18vjZa9+p0YdR3mVvj7Y61X6uC79ZEzmq9So9JuPb5cinL2Kg7wyaylTr2L7sPWqTuared6ZX7++tntTz0/JyfL8kPyyZvB5+9P7cbrnXLh4WvOqx6vIK+XSodXomO63z9+vZL56jMm/+eHQcxKN9CpcWUJt4vwb7NXH8fhY/17ebzdfqaSa+Lzg+3Gqh6djMuUl/+abbm06wqst1bffHjtvQGpHqviJnP0rFfqUT4atJZ+add+aY/CeUvAWka5X3z95In7Zj4/nXzyEIe/HUc5eLbkc5NWUy8EPr75/vcerwCtp1JkWey4xGL4HzqtbvbplvrIsFK94xSupLK+696ri1fjV/LVbxIR03c0raWSx7zUoXt3h1pWvB15Jk3p17UKEkIxoyOKVxKvNeRV4Ja3gVWjXg7y6yauB1zNIuuTVqFOTV9N5FYb+oa6/Eq/u+o28utmr63cYDbyShi/7BnhVV/t4l+DKXjW8kjOMV/N7lVwv+n4Tw+FeWQ9KE3jVLgFDHXl123w1/vXByCvxile8knjFq0m9sh4Ur3jFK6kErsLws41X1oPSBrxyJOfzSpL5ajmvutczJEvxMPYPNV9p52Dd+PvMV3fPV5X1oLS0V5FXi3nl/gzi1Z1eWQ4O96rilcSrkryqWq9G/qGuZxCveJWzV5J4tYpXgVfS7Vzdfz0DrxbxqnF4xSte5ezVWh/7LW3Kq/TzJqxrrhzB+64X5ZXEq7W8iiOuZzBfSR2w7j01g31jXkm82oFXcdwf6vor8YpX83qVfP3e9+PwSrzi1RpeuV+fNJKr+/fbecUryXxlPcgryXy1rFd9nz/IK8l8VYhX5iuJV9v0yufRS7xa3KvkF4+5/xWvJF4VNV+5f7t4xat5vUq+fud8xSvxilc5eyXpE5u7r2cY/Tt5dYbe5w9KvNriepBXcoLxagWvXH8l8YpXEq94NZlX43699aDUBSuXP2R3Xtm/kniV/3qQVxKvCvMqWEdLvNqWV+YriVfreeX+VxKvcvWq7359gVcSr8rwyvVX0lhqwhR/iAPJK6kMr/SzV8nX7V9JvOKVxCvxSuIVr3gl8aoYr1zPIPGqEK8qXkm84pW0Ua8cA+tBiVe84pWUl1fEu9krrw9KvCrIqzjuD+WVeMWrVbyqeCXxilcSr3g1hVcxtF5FXkm8KsGrcN2rwCuJVzmsBwfst3/1yv3bxStezetV3/2Qh+1fSeLVGl4FXkm8KmQ9GG64/koSr7LwauT17favxCteLeXVt/cPjryeITrY4hWvVvHK9aISrwryyvudJV6V4FVl/0riFa8kXvFqjfWgJF6t4VWwfyXxilcSr3jFK4lXvOKVxKuMvXK9qMQrXkl39/8Nw3GE4PgIAA==", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0109_20261007_103001/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0109_20261007_103001/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..abfc60af5920842b48e54b1128aa216fa107dfc3 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0109_20261007_103001/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0109_20261007_103001", "meta": {"split": "train"}, "act": [11, 472], "T": 473, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0110_20261007_103027/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0110_20261007_103027/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e880f1e84a1f9884d4f1f213bd3559426face7a6 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0110_20261007_103027/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0110_20261007_103027", "meta": {"split": "train"}, "act": [13, 515], "T": 516, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0111_20261007_103054/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0111_20261007_103054/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..973bbd9b5a4b48d32b448c0c16a606700387abab --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0111_20261007_103054/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0111_20261007_103054", "meta": {"split": "train"}, "act": [17, 478], "T": 479, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0112_20261007_103118/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0112_20261007_103118/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..3bdbccac24a13c8c0e80e9480c7e2edebc3a9994 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0112_20261007_103118/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0112_20261007_103118", "meta": {"split": "train"}, "act": [11, 503], "T": 504, "maps": "H4sIAPG51moC/+3da2ObxrqAUWZAF1/SpPty9v//p8dJbTNKJAsQl5lhrQ+t28pORMWTdwChpqF6YaHHAugVoFd6BegVgF4BeqVXQDXpAiioV/IGFNKroFdAXuKtrP3slWQBJYxhfa9UC8i8V0oF6BWAXgEA5CemQ5X5CiihV65nAPQKQK+AndErwHwFsMx81egVUFS2APQKQK+AnYXK8StArwD0Cthpr1x/BZivAGbtVRN82CqgVwA5ZA5g9f74OGhArwA2Xd/pFbBVqFzPAOgVcGPns8vpFehV7b3yfhzQK/MVoFfmK9Ar8xWgV3oF6JVeAXoFoFcAegXU3au3v0dbA9ArAL0Cdtkrx69g1Z3PLqdXgF4B5iu9Ar1iRK8avYIcexXCr2EieWz65ZVdeVfdAsxXeYppp2w80Cu9AvRKr0Cv9Aogv8TrFVBstgCyDpX7XwHF9Mr1ooD5CsB8BTvYQ70fR68ghxTplV4BSg9gvgKsB/UKqLBXjV4BRWVLtYCCegWgV8DFXme3s+XAXmfLAfY6Ww7sddhyYK+rapP1W87GA7IulV4BegVgJQ0YtgBTgl4BeqVXoFd74fPoQa+KG6ycHwSKWQjqFaBXAHOI1oOAXgHoFdTB+UG9gsqi5vMH+15NLL22AdtV3CYArKQB5pyrHL8CU0Lmos+jh6x75Xi7XoH5quT1oF6BXpmvAL2at1eOt4Ne6RXAnL0KegXmq/w3mfkK9Mp6ENArvQK9suUAzFcAjw9W/d/C+G8D0CuAmXoFoFcAV3m/M2C+AliqV65ngLV3PvcXtR6EmnqFXoFe6RWgVwtusl2tfwHzFYBeAfy2CPT+QSDzUpmvAOtBAL0C9rwqbLwfByinV41eAeYrgHnnq+bnwSwAvQL+2PksacbxeV6gV3oF6NVC60HXX4FeFTNf6RXolV4BeqVXoFc722T9lrPxQK/0CtCr7bacDQ16Zb4C0CvAZAqw6nzVmK8AvQKYtVfB/WSAzEuV0fH2EKIRDxg0X20Ti7b/qu2MeEDGvTr2X51Pnf8hwP212Koj1WeX2mM8fHx9fnk6LJTjf57i55MMsf8Pyb/+9bUlKejVgF69Ltqr2LcotkmvYn/ULMboEBpYD+bVq3jRq8+jZq1egV5t1qvr9XqLkvkK9OqBXp026lXQKyirV802vYrb9Kq52SvH26GI+Wq1i5/a/qKry16dD6s+6fdnnvbK+UHI1vafP3hKrr960StgQK+aDHq1+HwV+r8HvYJSe9Wse7z96nrwuPjxq6ubwPEr0KtBvWpOSa++6RWQ83rw9PnlQa+ArHvVH786ZLAedPwKCuhVyKFXZ71iR7zYpvaq2er+oqv2Klz5Uq9Ar76QHm9Pzg92L+Yr4PbO2t8Sqon198p8BTX0KuiVXkEh68FV3z8Yk14dkl49b98r1zOQmX/egHHxB+luX6PbnB+87FWXU6/MV1BAr5pVexX1CpjaqxymzPZJr4CRu/COe+X4FbntoX8cv0q/bPLZf/fXq5NeAXd34bHHr5bYq/UK2HGvfquPXkEtvYob/1aWn6+uZSz9RBy9ghJ6tdn7nS96dV5wvrq43u6iV1GvoATb3F9Ur4BH5qtmT72KegUF9yqYr/QKrAf1CjBfzZPs2OjVIi+r9CMem/tf22gU1qulr7/Sq2x7BXqlV3pFVaXKZxxf73pRvSKLXdCLbWyvtv78QfMVMHY9uFWvDm1WvbJEwXylV4N6dd5iPfjbewm9hNArvdIr0KuZevVrb9UrvYJ8pYeX49a9inoFDJyvtl4P6hWQNb0CiulV1CtAr/QK0CtgC5sfb9croJRerXm8/dqTDcHn0bPRzufFVtx6UK/QKwaJ278fZ831oF6RVa8GXKId/nmB/rqy+/3VmXxbaDZeH+2t9OYr9GpAr5LPdNpvr2JO73ferFdN2it7EZn3avNPNt5xr1J6hV7p1e2d96NXwXylV+iVXukV6NWsvdrJ+cFbr4e0V4LFeq89vRplZ8ev9Aq9ynd8GtGrYL7SK/Lr1fsL9OKFWeNrdFyvNtsKeoVe6dXYXoUdX9/epL2yF5FVr/ayLabvwqvqzw82WV3f7rWEXtXRq66uXpmv0KuierXd+cE1e3XvNeP4FXqlV1+Jm/fK8Xa22TU3Oytffq/CjntlPYhe6ZX5ih3ubmHwY/VKr/QK81W1vUq23DbSXh0db0ev9Op2LMxXegXmqwznK/dDhrzEfoccsevplV6BXhXQq4v1oFcQ6JVeAQP2zdkfXWqvrpdar6DuXs23W5uvAL3SK9htr4JeAfvuVbNxr6JewWZGnR+c/Pk4hfQqyc/POSq5d6j5Ckqbr+KVHbuuXiX3N75+r+P0g5L0CopYD+64V1GvQK/MV8ASRam1V+kxq0avwHylV8DivdrB+UG9gup6lcOdDvUK9GqB+UqvYJ0d+dd5oosbs9X4InX8Sq+gzl5lkWy9Ar0a8rA8jl916/fK+3FAr0rplfc7Q2m9Gnt+cKHjV1v0ynoQqu5VV1OvzFdQX69Cnb1y/Ar0aoKTXoFeDehVk8F6UK9Ar27L6/p2vQK9GrZjL/jT9QqYcb7K4XqGpXsVrrdLr2DrSBV4/dXpoFegV/e/Y9T5wZJ61VxdA1oPUso+XfH9GR7qVdxkvkoSddQr2FGvxhdl8141egW77dW4z0vtD+k0cZ+98n4c9KqQXlkP6hV6Vc568MoevLNeuT8DZfUq/XLpNU+5vVro/GCSqINewZ+5+nUtjvkqt/lKr+BqsPRq172KeoVeFdirza9vTxLVLdmreDFT6RV6tZ9ezWetXpmv0Ku9rgfn0ya96sxX8GevXM8wvlcL3V806VW7Ta+utwvQK70CPve5Pi+LHUBfrFetXoFe6dXl+5qvN8rxKyhhPdjkcLx9o16Zr6CQXm3+efRJouLsvbo1g+oVFDxfZXE9QzRfgV7lvB5Mc9G2M/82Lnv1+U8x9s/X/fqg9l4tslvP36vm8kain70KN3uVwcQJelXAT196vkrXgHoFevVgr+Jyv8/La7H0CvRqfKT6RIWFe5V+/qBeUciO7P2Dj/enlF5d/13rFSXlyv0ZVn64XsH0XpmvsulVekFDTr0C68Fs14Prvn8wzVLSiLV6lX556/O8zFdk2iufj1Nrr357WnpFvb2KZT9NvWomnB/UK/Sqsl41Bc5XQa/Qq/30as7PSzVfgV6Zrx7rVdAr9EqvMuhV8r88vdmXXlFHr/Z0frCpdD14/SnoFearPEy7f/u+5qtGrzBfldirbT6P/lavYtzi5aBXUFSvVr5/u14B93t1MUqGmPZqTCse3JnblXo15DKN9HMJg/c7Q0696geIt1y1IW7Sq0ParvT6p5k/cEKvIGt33sYdYj9RhbdctdeHnoV7dbzRq7Btr4JeQU69usjDIbRd/1+6ZXuVfsvpVq+6mTfG2KvQ9Apy0oVfFw383BvjMbTHEb2Ks/XqHNJeJT/3MG8lwvSrZuUKVpyzbuxwb1Xq4vvu+Barp/5hh2161aWdzKhX2gU59OrQ9r16jqv1Kjk2dUpv5JleJHoMuWwzvZpx69XzthGWEtNbo3fxo0Zv1Xg+v7fn7S9/Hz5fQe3VMs3Wq9QxPROXT6+Yr1fphWxBrx7ZrLu4H/Jlrw6fB6reqvTt+T1Ob8/7P6fPh909VRjvPQC9+rNXUa/m7VWV92f4+QT6qpy6p/fIvP3l39+bw8e49b/n86H9WA8+xbZfGn799B971V387Iv5yuuz5B3raq9ajdKrIdqkV4e0Vz/SXj0d+0jFLbKRXtV+NLjp1Uhd3SulXfbqfHjue/Xfv5vjR5H+9/J8fj9Bd26e20Pfq9XKoVd192rp/eicHqcINW7WnfQqvaLq+PpeoLd/999/NadT36unP3t1WrNXXXri0G5fSa/W24/0Sq/0ilJ69dwmK4pY42Z925rJR/mG9MqgmvqcrAeb43Ofr//83ZzOH716fu9V+Nmrbov1YHq9qOPtJe9YN75e+Nd96V+phy5WuWF/71W8uqHLD1YSgpc+DP/+kcxXT0/n916dxvQqLvK7bDq7fXXz1dJ71Gt6GnzFZ9slcWzTRcKhnX9HSXrVhOQXjpX26vP84Jsfr/3x9v87n07v12Idm6ePtz93l98cFu2Vi3P0Sq/0Sq/YQ6++fUSq61Y8nNC1Ly/90dan9KD/t/P8RzjSXsU0lAtv3dDcXOTPvt+md+v8WaL4/pNPh49DW23zLTb/PPuLW8vEq0cmXN/O2F6lH/W4UDde+14d1gvWofvr21M/4yUH0Zrvz8njzsvOV21Y9s+FNW8Pd9GrePnvw3t2jv1I2Q4vrTmITOartzmn71W3Zq++f3/pR6rX5E/7v1+Sxz3PtFRas1chg17dWDC2/e9wxfuLgl7plV6xM233/Nmrtj2s9ut+0asfi/QqbtSrsPl6sP/96BV6NblXfYtek5OUzY+0UU+r9Wq+U2BDenXxUYDz9CB82augV+jVTL162V+vZm+BXrGHXrWfveq26tW3DOarddeDC2zSqFfo1UK9Onz/K6/5age9inqFXulVib1a8fOdYYletav26nutvdrI/fODeoX5apKu4vkq/141eoX5Sq/0CtbpVatXdfYqPtwr0KsMetXtrFed+Qq9mtarJ73SKxjTje5pF71K74Gc3BwwSVe1vQp6RTW9OiS9inqlV3pFIfPVhr1K9p6/F+5VPFTYq6BXmK/0ynylV+hVHr066JVeoVe59SrqVdKrse/Hgb336i/zlV7B9F6teD1De7j8fBzzlV5BrvPV2r0yX+kVeqVXegV6pVd6BVn26turXmXcK9czkF2vol7pFeiVXukV6JVe6RW77NWal1/plV5RtMuPfmpW2o/0Sq9Ar+736tz36lWv1uxVo1foVXm96mrZa93/Cr3SK72CfGTeq+h+fXoFfa/O/fUMu56vHL+yHkSv9Cq/+cr5QfRKr2qdr0Cv9EqvYGSv4mevYqdX1oNQQq9Wv5+MXukVTO6V66/q7VVo3P8K85VelderoFfolV7V2ivIrldrH29/dfxqVo5foVe7ma/0Corq1brH281Xm/Wq0SvMV3qlV6BXK/SqbO5/xU56lTRkz+vBPfXK9QxU0Kt21V65nmGd9WCjV1TZqxV3XvNVSb2C3ffKfLVOr1zPQJ29cvxKryDvXjk/WAH3b0ev9tQrnzcBBfWq1Su9Ar3SqxUMOT/Y9s9Vr9Arvcq0V+5/hV4t0asferXYfOX+V5Teq+svcr2qdz2oV+iV9WApvXL8ihp6te77B11/pVcwtVfRfFV9r1zfTj29ymO+cvzKfAX3etXuez2oV6BX5iu9gtl61V9/1bpeVK+giPnK8Su9AutBvSq8V96PQ3brwZjJelCv9ApKWQ+6XnTxXjV6RQXz1Xo3RHa8fV7B8SvMVztaD5qvoKBeOd6+m14FazzK75X7yejVrdUmmK/0Sq9Ar/TqTq+cH0Sv9Cq/XgW9or5eeT+OXukVpfSq2Xevij+/bz2I+cp8pVd6hflKrzbr1dh5Uq8wX+mVXsHEXjk/qFc3EqVX6JVezSeYr7Ae1KsyexXdnwHzlV4Vsh7UK/RKr7IesYb3aux6EPRKr/QKJvbK8auSY/VFr4JeUV+vXC+qV6BXerXpelCvsB7Uq/30yvVX6FXaqxe9Wmw96Hg7FfbK+cECxf53H2MYOl+5/gq90iu9gj30ynpQr0Cv9Mr9+tCrKnp1qPV+MuHav3d+EL2a2Ktvr6cBvTrP9DT1qnF+EL2a3quXvlcvL2mvnvRqtKBX7K1X3Xa9Smaq73o1a69C0qtOryhZ231WI7aHNXv1+nz8/Kfn56RRf6WNOunVpF5FvaLKYPXViMf1FoTt4eXc5/HpnHTp5Zg8bqaEtum5syRSegVFSYpwWq9X3eH51O8851PSq+e0VzMtUS96lVzEsIf7t6fHr45NJc+Xner6IoQVe/W2Dk2WZcdD0qWLKe/KFNDvfsfBU0J6bVJIdubkOE+192//+XTDY8trbSOXXsVNehVjl7QivTzq8rNdmq96dRjQq/Dm11+T9VHyda29Sv5P/vxz4ePJncPUnw45SOoQVjx+Fd5WZUmvLva1sHSvru6IZR/P+WK+6ib2KuoVWb7SP1/ZK74u0yknHXN++4ev9qPQTepVlUeaw+3t1ib/k4/Wg1TTq1VflnHirxaSXsUwZb66vgYssmKfz+WLXsXkiXYPv0Zgl5mME0N52atwf4+uuVfjtmCcusbTK/beqzBxPdiHbve9Wm001iv06uFeteYr8xXolV7pFdzo1ZhvvOxVuP/r1Nyr9Hj7gF6NerhewcAxSq9GbcQwbIOG/tv0ClbYRS+Pt4f7uRrWqyL3yQd65f6isPx8FfVqYq9C0qvg/gyw/npwwHLQevDR+Qr0Sq/0CgrZ06aeH+z30XbY4fa3Bzk/qFewQa/6B4d4/zvTXr3/kq5n8PmDoFd6BXr11U+Z2quQLo/K7dXILTj8rjrpbYb0Cr3SK72C6nsVRvTK+3Fubjq9gszmK+93vvaw0Hg/DuiVXoFeWQ/OUJSoV6BXpc1Xjfc7w/q9avZ+ffvE9aD7yYBeVboeBL1a489vvbq5lDZfQZ69ipXe/2qGCVWvYEhJVsqi+Srznw4192rM+cHaexX7ZzLm8yY0CHKdrxq90it4LCRr7DvWg1cf5noGyLBXrhe9Ol8FDYLVe7X769tHrQcbvYK1e+V4+8Prwcb9ZECv9ArqDNYM+6ZeTerVuPu36xW4XnTbXjk/CLlFb/j7cfZ0vF2vIMf1oOsZ9ApK6VXQK70CvSqxV8Mf7fwgWA/m0asw5gfqFcyyb4Z4f9d0frB/zOf9kMPd66+CXsHM89WgXpmvfnvYgPu36xWs3ivHr6b2ynoQ5u6V41dTejXg87zChJ8OevXoL6NX11OvV7Bur8xXS/Vqyk8HvXp8Pej84G8PC+5/BRvsm+arEb268nmpYb6fDtzZe1zPMKlX7t8O6++brr+aVJSxa3G9Ar16nPvJQE1x06srvbIeBL3SK0Cv5imKXoFe6RWgV4sUJSz60wG9eqgoD70fUK9grVwNez9O2fvk1Ovb9QpKnK8q79XF229Co0GgV3oFLN8rn+cF6JVeAXqlV6BXtfdqxHcAejVzpNQH9Kqy+Wpi3dQQ9EqvAL26t1H0CnLvVfiiVzt4P45egV7pFbBqr6wH9QrMVxn1yv0ZoJheNXufr1zfDlX0ynwFZNerKvdU60EwX1XVK/cXhUp65fiVXoH5Sq+Aib1y/GrkVuj0CvQq514FvQK92rRXk36gXoFeZd2rFX86oFcPPdp8BXo1s9g/pyFPa9L5Qb1iaf8P2Wfk7IA6CQA=", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0114_20261007_103214/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0114_20261007_103214/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e2c03c2d06ebb9d37c8d365a1920364762439b29 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0114_20261007_103214/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0114_20261007_103214", "meta": {"split": "train"}, "act": [14, 554], "T": 555, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0115_20261007_103245/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0115_20261007_103245/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..3968cb9789c2661ddf5dc43cc31b9efa3447a54e --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0115_20261007_103245/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0115_20261007_103245", "meta": {"split": "train"}, "act": [13, 485], "T": 486, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0116_20261007_103311/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0116_20261007_103311/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..ac12b195787987cb4ecba6f4b71595b0a0032237 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0116_20261007_103311/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0116_20261007_103311", "meta": {"split": "train"}, "act": [12, 530], "T": 531, "maps": "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", "force": "H4sIAPO51moC/82ce3xNRx7ArzayKVWRqFeiCUuxWk2pt+SemUh0iVfRLqoi9arHoo1KUJIgKYIgNFWteDREFaEN0bIX8ShZUoImEuKtKsSr+mm19v7uNZNzOL+ZSffutufP5Pv5/eb3nDlnZq7FYrHMqORFLQpP+9pPuZSbcq+aSzlbxlZi+V89g0cFqmA9agzWVLiAdR2UOK3TKqsK5984XomzvBgRZPkzP6t7W/+Mw+o5YYwjXtryesLxhfRap8RNKp3m4Gydvraq6HUVx/SWXn5KmH/nai52/D+85xklvakn97iUU9Vre2K4EqcdX/R/5Zo2Xen0r9vqIFfkiyqnmqeMsyzy11zJaV9UVtIb816G2vgU61L2MP/J+kvjD7aVL26pjTQlbvRCpfiGDz7mUs7i1UwpbrJ5i/tZUV7j1nOUuNJWk13KWfZWUuMk82B58y/8ei3NlZxyPv9RnKSOmB25YRVc0l9YP5X1ezZvxfwcpVZHB0qVuJij7n8IF75xndUV8fgo/biSvaz/qXKyeHB7Jf1e1Y5ycy6a3+wd3NnH2wVYVThZ/qmu11g+y+qovOsI1Tp39bpJuZ9K5jfGeR56Va3v7juhJs+maSrrZ+V8dnHfla3bGVfsW8elnHL+SeqD+Vmml78vVGijVr+SfjAu27dc85Gr5q3yrptk7/HlXWervh/ZKt136XuZrH7rTJ6t5GfGpdbtppR/Mr1sHlR9f5ONj7/nSfKP2SGbzxknWx8wvarrF+V1jqSvqepldii/J0sexsnklbvvXrsf6FJ5kn7AONl3ENX8Y/JU12Gyfqq6vipv3Cxbpge5Ik95/knqiNW57OGc4vrZVXnK5Sm+97iKU13XlVeeal6x7yXYepDnwYN55pbbl1ZZvQGT/uxIIovvsKRKZHS3xUSWz51zUklI3FEi6msgr+Dl0+THOweJbL37z7WlpNXh1UIO1k3ARX+4jMi+W9R98hYZcm+OVN61jJ+VuHPev5BpzaYL7WUcJk/PPV/7NsoZ868U5Vg8YL370d1bxHfoMiKbj9asukqmVl4t5TZ9dR3l+Dxt53pUvULSOmYI5dnaLFDi4EkOvUyOR2QR2Twzd9QZ8lvYIeH4YJ7uPvEiyvF12IDKWsaPJ6UcfJ8EvYNH7SGydd2M/gUox/1s51q6HSPRxXuFHNgRm1aAcvrvmF8cK7QzuUTWh4BL+vVbYT9IHRaiWVofJ8kr93PuTGiaZsaBvL/3zhdy8FTwO0dqDD9RxtE9KKePh57Tf+d6Zf8FgzzMfwmjTxvswLiC+JNSDvIe5GHxaBdxmNfvhxsL0TxgHKxfQN6QizmmXPqPl7k84LA8PX33F94PfGefQ+PL9AIH8tI2HzDlUooqEpZXwC0KzjPlXs2uRth3huRm+ahexsF8BBwWt8fuW/jfl44uRvNZz1XdcQXlmP8Yp89TM//FZJ4JAg5ywYw7360KYRyMD+OYPKhz99gC1H+MgzwADosH4yAPgBuVt0M4PvBznQ3npJzze8llNO97Trju1JuebR15sgT1M897Rc528Hlr+0PnSaPsY0IO/AxckxF5wjyA9/PzbxWhdnDO3jeA6zXmG6FfgIM6qnt0pzQPoD8vb5Ul1QvyMI7Jg++TML5X79vE8XVb7dCLcUwvvEeBPItlv5SLzj9DbjYWc7B/BJxUnn1egBp3n7tXLM+ep2AHlqeMgzoCOzCO9SuoD6jLqCWZQg7qDca3eHSmNG5D3/9WysF6vN/SQyjH42tf34u4xTdqcD+LuKmVG/G+C+PD7GXyYHxg74ReO8T9ys6Bn4c1FnMQNxV5EI+BjXJJ1mXzeRDeOdi6GDhMHq/L9WmOeKSs/Zd4nrE/KhzUb2ZSHvklNUM8D9of4HJXbJDmC8Sta3qauJ8+sDfomU+F/c9S0t+6vdoBKQd6veP/LeWg34MdUr32eR+4I5s+FdrL5GEckwf75zDvy/T6T6WOfiCTB/YCd/LSevF8ZK83GB/GsXhAXkE+S+XZ8xnWEDIO+ims/1Q4kIfVL1t3Aieqc76OteeziOPvR3YO1vdF/ltMuYDQs7wfgP8slq0u4eCBbwJYXer3TaGOZByrX4zj323tHMjD5o+HOWze0nPgP4xj9qpykH/wHnqx/z6pPOAwe/XxAA7TG5Ry40G9TdTOv1SR1vYSc8k+kSRqc3X6XMZhod5rL2aQHZcCKba+YlzfI22pT/SbtP5XWULu8NmRNLNkIA08sFz6XSCsuCc92jnhd3EnNhdZ9Zznlxbt7afb0vWFwwyc571Youde+fgAyXihCWX72vDAXpoq517dmwLDuM3vVKAdJ7SA87y673+aNvTSFHr0eARhfddnrxct9AoycCAvd+pnFL4vVd69jcA3vztba9OKbwYbzgcD1+nXr+ikm/609dnbBPZseyV60bGtOhi47T5Vtah52XR6YksKZ5avJtclRSUV6RmfFo/I+yZ5Nx0ZT2mj+zVoSMNuZEHNUpL1RuVHuHnLsqh39TC6u7A6jU4PIu4D15G68089ErO3Li2mRz7uQ5eM86T+K58gMd9M1vxurXmEC94xiHrWGkG7rfW16yrWHvdvTvXvfOz5/HAvenPTeBp50Idm7k7QAutUt/Mxj3w7dd/UmnqNn0frbfZzjH34KQ9ixqXsaE/3Bi7k3JoOM02/1/p83s7AJcXsMOWK/FvThPCF9O2Puzs4z/5XTbnhM5yc7zNhDs4/8aYp5zG9Fc3fN59zHi8fNuVKotrSDn2SOGer9pspl/NBS5qTNV+ZW3+6q1AvcFOCkziH2TunMMAh78imhlLuUuOFNOdJfwcX9c5PplzHmkYOG9+SF56j32cmc67Huu0oB/G1dxUpB35mnGd4mJBjebB/9CLUDogvk4ftLwAH48vb2VUoz61PC0deMW5s4BBUHnDML1trr0DlXT83X4mbNiKJXjhez5nPk+eZcsNoawfH5GkvDTLlFga0d/jvbhBxcOGrJqEcyEvM7SjUW39FOzrg9lzu57HbmppyFza2cnAsbgGR61BuUewcmh/Z06n3rQDUXuDiXlPjBjTydfba9GBUb9+Jc+i8xAf57LkKtXdsYhlnaXfIinFzGySWyRvTA5X3slfig/5st2O4nym3sU57A4fJAw7sYFzU/jlK9hbvM8+XFXWDaFLaLNp6ijP//pGebsqFNmlHP1tYphc71wzj03OWJddN/feen9XAFf9mHt/tA630p6FJ9Mryus46R855HMoMpjfuQp42oKJ94l0rOzniy7gY76maCrex+VJTLnJ+F0ceMA6rI+Dcs2ZxDqsP0AvymrZwxq1WzVmmXP7U7gbOsygZ1Qt2MC4qIQHVC/Fg48Piy8Yn4yAeev8l+RSacgt2hhrk7R9knn9jx3Zy1AezI/XmHlPO806owS+lbc3P/42PCzZwIr+AHbvuOfPPc9QIdHwgj3EbAyJRvRf91Tk2PqwPgZ+H2WZxDvYbRePj/lvuLcznuQ3qOPNvQCKaf+AXxqX6fSCUx/TmbuuEcvr4qnKwLhbVGxsfNi8ANztoJpeH3acD7tbnOj97v2bK+Q/sQt/tWiYv/GwW2odqZZfJi+nyM8r9rYUuD5D7CxBffR5g9zUero/ikYdN9Ta42NkgD6tz8EtpaYLUf2CH3i+qXO6T4ZpKPLTrp6z/Tf493E9Feg3+Q+bVvBNdDP7DuOk/GOOG5Sn4xSAPmS9Zfx63r7ZznkmugXL6OsL62sNc+CdJKAfrCKZXxrG69LDlmXJpmzsb6hLrQ5CnKhzIA72yOoL61dtb7LFAyS+iutRz2LoO5OnzGYsbyAOO+S9mfRxqh94vGAfy9ByW98BBXTK9Mo7JW3NkjdAOmd6qudTgP9vyZNM6T3ELVspn6H8q9fYIJ4hvn4IE6XwEXPbj70vtTVsRSuuEz5T2NVg36eVZZswNwji9PIwDvWAvny+RdTvEQ6VPgr0GvYifH563MA7yWc9999gyUy66W3eD3oADS1DuxZ5yDvqGYZ5B7s9AH9LLE8VNXx/YPA2cYV6t5I5y0z6L53HD5lWoD8gXziHz+YqG7WmVifH02rWnKdvHNuOu93vJwGH9KrpmE7pnzzQaN7Km8z2gU0Pz+IZUpyVr4ujeQG/hbzn0TXyK1oiIpbHjqgo5Nz83eiV+CvUYXEnIvb/BQutvmEJPuD8u5Tp2fI9+Pemu8DckPvG7T2y2SLrp1yIhV9TmHvFuM5L63s4kMr2B3SMo23fF8mqez2O0Yl4IPT99Md9PNuPu9P0LDbzdhqacmunghpd8b16XzW+QXUH16L2IGc7zDP2amY4TzsKSPfmE7WOvqh9iynlHTiP3X99I2L741ZWvm3LjJ/Qh1fbNI+wbdv1+8abcmJAXyPqCNwjbRzzS3fx8Knw7v7StAWH7gyl3zefBMSEbNGD5PuKZUab+WxTcW4O9GX4O7+Dzphx864dzeyoc7OXJONifgT0SFc4gD3kfBA7OKbL9UOx+z8Mc1g+YXnYO9LuTWzQsp/WcFjYa5eBuoyrH7kGK7oHDPo/KvXw9J7o3ATpVuBObB3D/iR6wVYW75dZSM+xjI0+/nUOV5AGnco/l93BYnjJ7+b6u4F4R+I/td4vvr2qa2j1XTUmvnhP9vg/sp/F9bEEegF/YOV8RB37h55WRemPymF9EeQ8cG59I3rurFnCux4U3UG5c9lLOiepo/NEtnPOPry3Im2Juh+ieYeAXHoT5rwdphdsb25A8fE/EdG/yTnsuT2SH+9xQYjgXjtVllV7l5wT3Yqp0C+acKL6TP2xR5hdB3PScyC+qnN4O0b1j2LdX4fL+OqjMDsHvjRSe9eTzvsgvuyI6a/ycr4BLf/YHJa7r+LWEnScU9Y2n+24izf19pBzc9eDnriUcWw+J7vsBpz9Xj3Eh1m38HEVp3Gwhx+TBPREVvaJ7co177+T+k3EqftHLk3HsvKgo//ScqH5dzZ3OsymNr8aprYTlqUgecPr7EBg3cK1NiYPfzdOfj8W4nKyy9b3o/v5Hu9ZwTtR3S60xPO9F8sAnKv0g3yNf4+ezBX0XONaHRL8fMeRijqbSn2H9zO+rSTh2HlMUX3gH0Z8rFa1LmF4RB/JU9MI7kkof19shyhc9J1oPwXuUynwE8hgn6gfgF5YHGPcfQ2cL3vhSAAA="} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0117_20261007_103338/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0117_20261007_103338/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..23bda49a5be8fe15877487896b3987e5248e5775 --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0117_20261007_103338/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0117_20261007_103338", "meta": {"split": "train"}, "act": [13, 515], "T": 516, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/episode_0119_20261007_103433/viewer.json b/applications/steering/xhand_data/handover_random_1007/episode_0119_20261007_103433/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..9a5dca0e16bd908a83a2358da173c00d9ccf4c0f --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/episode_0119_20261007_103433/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0119_20261007_103433", "meta": {"split": "held-out"}, "act": [14, 549], "T": 550, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/handover_random_1007/splits.json b/applications/steering/xhand_data/handover_random_1007/splits.json new file mode 100644 index 0000000000000000000000000000000000000000..d3d697812d88a3532fa5325b4556cd404f4b381a --- /dev/null +++ b/applications/steering/xhand_data/handover_random_1007/splits.json @@ -0,0 +1,110 @@ +{ + "train": [ + "episode_0002_20261007_093201", + "episode_0003_20261007_093233", + "episode_0004_20261007_093303", + "episode_0006_20261007_093408", + "episode_0009_20261007_093532", + "episode_0010_20261007_093600", + "episode_0011_20261007_093657", + "episode_0012_20261007_093722", + "episode_0013_20261007_093750", + "episode_0014_20261007_093824", + "episode_0015_20261007_093855", + "episode_0018_20261007_094012", + "episode_0021_20261007_094156", + "episode_0022_20261007_094231", + "episode_0023_20261007_094300", + "episode_0024_20261007_094328", + "episode_0025_20261007_094355", + "episode_0026_20261007_094425", + "episode_0027_20261007_094455", + "episode_0028_20261007_094527", + "episode_0029_20261007_094601", + "episode_0030_20261007_094633", + "episode_0031_20261007_094659", + "episode_0032_20261007_094730", + "episode_0033_20261007_094758", + "episode_0034_20261007_094835", + "episode_0036_20261007_094924", + "episode_0038_20261007_095034", + "episode_0040_20261007_095130", + "episode_0041_20261007_095156", + "episode_0042_20261007_095223", + "episode_0043_20261007_095251", + "episode_0044_20261007_095318", + "episode_0046_20261007_095417", + "episode_0047_20261007_095445", + "episode_0048_20261007_095516", + "episode_0049_20261007_095545", + "episode_0050_20261007_095616", + "episode_0051_20261007_095646", + "episode_0052_20261007_095716", + "episode_0053_20261007_095748", + "episode_0054_20261007_095820", + "episode_0055_20261007_095850", + "episode_0056_20261007_095922", + "episode_0057_20261007_095950", + "episode_0058_20261007_100018", + "episode_0059_20261007_100048", + "episode_0060_20261007_100116", + "episode_0061_20261007_100544", + "episode_0062_20261007_100610", + "episode_0063_20261007_100635", + "episode_0064_20261007_100702", + "episode_0065_20261007_100741", + "episode_0068_20261007_100853", + "episode_0069_20261007_100945", + "episode_0070_20261007_101013", + "episode_0071_20261007_101041", + "episode_0072_20261007_101115", + "episode_0073_20261007_101152", + "episode_0074_20261007_101227", + "episode_0075_20261007_101305", + "episode_0076_20261007_101332", + "episode_0077_20261007_101400", + "episode_0078_20261007_101432", + "episode_0079_20261007_101502", + "episode_0080_20261007_101533", + "episode_0081_20261007_101604", + "episode_0083_20261007_101703", + "episode_0084_20261007_101737", + "episode_0085_20261007_101807", + "episode_0086_20261007_101838", + "episode_0087_20261007_101908", + "episode_0088_20261007_101941", + "episode_0089_20261007_102013", + "episode_0090_20261007_102042", + "episode_0091_20261007_102112", + "episode_0092_20261007_102142", + "episode_0093_20261007_102210", + "episode_0094_20261007_102240", + "episode_0096_20261007_102327", + "episode_0097_20261007_102357", + "episode_0098_20261007_102431", + "episode_0099_20261007_102500", + "episode_0100_20261007_102528", + "episode_0101_20261007_102557", + "episode_0102_20261007_102622", + "episode_0103_20261007_102658", + "episode_0104_20261007_102725", + "episode_0105_20261007_102807", + "episode_0106_20261007_102836", + "episode_0107_20261007_102905", + "episode_0108_20261007_102934", + "episode_0109_20261007_103001", + "episode_0110_20261007_103027", + "episode_0111_20261007_103054", + "episode_0112_20261007_103118", + "episode_0114_20261007_103214", + "episode_0115_20261007_103245", + "episode_0116_20261007_103311", + "episode_0117_20261007_103338" + ], + "heldout": [ + "episode_0001_20261007_093126", + "episode_0045_20261007_095346", + "episode_0082_20261007_101633", + "episode_0119_20261007_103433" + ] +} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0001_20261009_111407/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0001_20261009_111407/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..066a7ca6216e4b3c79914ba1959bec192b6dc743 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0001_20261009_111407/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0001_20261009_111407", "meta": {"split": "train"}, "act": [10, 332], "T": 333, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0002_20261009_124703/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0002_20261009_124703/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..62193efde1b29fbf8175db14b7aeebeb2a380099 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0002_20261009_124703/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0002_20261009_124703", "meta": {"split": "train"}, "act": [23, 374], "T": 375, "maps": "H4sIAPa51moC/+3dCXebSNaAYaCKVftmS/Lu7Onumfn+/5/75CTiXtIi3kRVIb3PmXNGzrhHtgJvF0UBUQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwBvFsbyK5YsoTvhsAAQlSeoumSQ2pq6VsXW5+JQAhJArm+wTFRWpSdP6z9O87pjLnyc+NPIDgCiymYyjBrnN83qslZb19zg8MkwTPfTj7weAyEqzH1JF0zKte2VNVvcqczjAylSjDL0CoJRDW/dqMcikV0lWZapXx01W3PpFrt7IGP5+AIjB2NbHg6uR9MpUZbV//dStDtPR7FXSda9spt5tNpIvZkP1XaQSCM94Jr26nOR1r6JhVffqx17u6OdR81dxN8eDMi8XRdV6IV9s5/q72DQAevXM4MfGanjVxflBkw3k9eBWejW9t/q72DSA4KwubX3oczHPC2nUoCrU93W4/xq1YLWs1zPEsU06Wc+gezX8cFF/sfisxlQ5vQLCc7mRUcVy16vIfa+i+rAvTgapvE67OT1o9fHgo/Qq+6J6VVk2DSA4663smYtFe6/yDn8GI0eA430XY2OzjpaLqvQWj6v6vcsv8klMh3M2DSA4mys5EzaXXlW7/5Q/v0hd9qo+XbfrVVdT3mrolD9u6z8dfLmRbxmzZQAh9mqiepXts7Q7TKp+zWVlnfcqqXNlKsmVcXBIlj7Iy9Fn6VVGr4AAba8P9up616tf5wrz3w+ijm5/3JckyX5uKbbqusYOe/XY0qsJWwYQdK+Gi0W2z9LDKiqzzFGv9o3a9Updbu3i6kH7qIZUn+pe2YJeAWH3ar2UXn3e1L0qXPUqURNWJnOyYLPRq8dr6dWULQMIulfXy0W97OjLNirTM+vVwxXjK6AvvbpfSa++1mONqHD1o5hcv3bTK5lvz0b0CuhPrx689Epdf1NIo6ybXhndq3t6BfSmV4+7XqX/7lXZ8VGg6pUcdNrCTa/uda9kLVbBegYgPKnq1QfdqxsPvSrpFYCX9eqjl17JKiujbghhS/e9uqNXQOC9ugmyV6mjXt3RK6CXvfp0Ib36duuqV3LVjRk479Wy0asNvQL60qvPnntlda8qD+MregX0pldfvPeqcN0rPb4aql7l9AoIslf166+7Xu1ff7tz1SuZv7Lu56/srXp36ZXh/gxA4OOrb5eLfD/a+e6hV6bUvXJyQ+L0diXHgHeytCKdcAEhEPT46vtaxlfuetW2XtRRr+r7IZv8Th2aTnieFxD0+Or7Zl7PX32/P4teZapX2b26HGiibhaY5GwnQGi9+kv16i/pVeWsV7n7Xt3I8ybsnbylmai3NwXbCRBar/7eql49uOqVut4519c7O+lVfi29Wt6pcdRUNcqWbCdAEL3ahNorJ+cHm70qWnrFXBYQhEz36spzrzL3vbpSvbrVvVK/dUqvgFB6NVa9yoLpVeahV+q4bzpQveLZqUB4vboOqFeOxldb3auKXgGB96p+/c+uV9F59apo69VspD4j/aNwbAgEMb7650aNrx5d9Srx2quN6tXNUPVK3RC5caqSsRbgTa7GV/+5k/HVd2e9kvGVbcxfOQlD2daruboep9DrRVO2GcBjr8bSq1ldjF2vil/P8/rZq87WeKvjQTVR5KpXa9Wra3UMOJ+pb9JrG9RYyzLWAhz36lrm2733yhxsV4dzRlWjV+qmDIuF+iZ13jBVvWIeHjjjXlnnvRqsL+vXq2s1Z7Vcqm/SvVKfQ8axIeBUpnr1Xffq4bdeObg8xuhE6UXl3fVq2OiVmrNaXahvUmsbMnVs6OaSIQCHevXt1kevWsZX9ArAH3r19WZWT898vz/DXqk59pX8eTRSvcpLegWE0KsvQfUqcfHrN3p1Ra+A3vTq07X06tudq16p9aKph17Jcd9q23I8OOJ4EAhCqnr1sJlLr252vfo5mVV66VXqpFcDvZ5ho3u1UlGr6BUQXK/W0quvnntlXfVq9fZeZfQKcGsrvbq7VL26rr/D3f3b6RWAF/bq9sJvr6y+HsfD8eCa40GgN7262fVqv/7qy1X9HR0/bcFvrzYregX0p1dXda+uV6pXW3pFr4Bwe7VdSq8+++hV5rxXle7VpfRqonplR/QKCK5XG92rzVmMr9p6Fa2WjK+AkHu1Vr365KNXanyVBtQrxldAeL26XKheraPC/vzi5x1UOrt3inQp1b3KPB8PLjkeBELu1cVibvdZ+riW3dLV+KrZKycPdqBXQE97tWr2ytH4ynjt1balVxf0CgixV/Vdy+dz6dXjhavxFb0C8NJebZ/rVcd75eFeZa56dfH2XnE/ZMBfr6aqVw8r371yM9++PXw9TmP+akivgKB7de+hV5mHXm3arh9sW3/F8yYAetWXXjG+AoLo1czz8aCHXg1Ur1bcrw/oT6/0fPuDh/l2772aHe4V5weB8Hq18N6r1GuvtvQK6OX4ysv8Ve65VxwPAr3p1cxdr+JT6BXz7YC/Xk1Ur+7k/FgXe2WIvVrSKyBwG+nVWPXqtttehXI8WLU9z2vJ+isgwF5t6NW+VxN6BfSlVyPVq5uFq17Zll7FwfRqQK+A4Ho1VL269t4r9+OrLb0Cejm+olf0CmB8Fdjx4OoFvSrpFRBir2wovcq76pW6r1b2wvEVvQJC7JUJplce5ts5HgT62aurM+nV5SvHVym9AujV72MXN9fm0SvgtHplT6tX6tfJ6BXQ116NDs9fueuVvvdB3v37/alXC3oFBN2rserVjbPrcaQfeUC9YnwFhN0rfX+G0+1VA70C6FVvxlesvwLoVV/GV6y/AvrZK30/mWsPvcpCPR6kV0DYvXK2/socboGr9QxcPwj0sldP6xkO9Krj9Qxt9+vran07vQJOoVfe14sWqToeDLNXnB8Ewhlf+exVTq8AvLBXer2omm8/6V6xngHoZ6+mh9e3e+pV0vn7RflvveL5OEBvejVTvbpd+e1V4blXrc8fpFeAz17VL+fzef3aR68K971qPN/5iuelAvQq5F5d0iugj71aqF7d0St6BdCrP/QqU6/dHw8yfwX0p1dL3SvZjzveK9WYqlK9Kt08f7C1V5wfBILu1YXq1b2XXkkMSve9WtIrIPhe1esZLtV6hvtLV71SY6qB515NWnrFelEgkF6t615tVK8e1h56NZTJ7LJy36sNvQJ606ut516V9ArAC3t15aVX6hhwpHo1cHB+sKBXQM96Ndy/vFW9+rBx1SvVqLGEoRq66dW2rVcLegUE2Kv65d1Czg9+2LrqlWqU6sVg1FWudK+ytl5NVK8s95MBwhtfPSxkfPXRWa+q+haj1UxGW8Oxk9++1L1a617Nn+9VSq8Ab736oHt15apXA+nVXEZbw4mjXqn79a3HLb1S1+Ck6r7yqWXzAZxaX9a9+rRY2P3d1D9du+rVsJ5SqhY/6vXzT6cdvZ1Rr4v2Xi3pFRB0rz777tXSca/yqNjKecDFhcyZjZfy/raiV0Aovapfflsu6j3wy62rXo3C71VOr4DQxlffd72y59SrLCo2ch5wvhzKD7WYHO6VVZ+GNWw+gEtWja/+WsrO+1V61cUoImnrVT3fPnPSq3wr81Tz+UD1SuaybJnRKyCIXqkb1v29UL26c9WrsfRq5b5XG+nVbF6pXo3oFUCvfq/GWNYzrNT6q5mTXz/fyPvMZtKr4UKODdNGryy9AnxJVa/+o3r13VOv9lNFIze9yrbyPmPVq6SlV4nulaFXgLde/Vf36t59rwaeezV9vleMrwCvvVrTq3/3yjC+AujVi3s1p1cAftthQ+rVhd9eTcqWXqX0CqBXTyb1OwTVqwG9AujVy3o1dtUrWUc/HtMrgF6F26u00Su5rtm+qFcJ2w/gq1f/o1fSq4peAfTqz70qwu5VY/1VxP0ZAHr1FI8F4ysAL+vVXx56NexDr9RPznw7cN69ysPrVaZ7pRvF+Arw16v/89UrE0ivctWrUvXKtvSK8RVAr4LqVdLeK8ZXAL3y1KuR9Cp9Ua8YXwHeevW/c+yV3Kd9NHp+fGUs4yuAXgXQq+Hz4yvD+UGAXtErAEfoVRfCWc8gvXrJ/BW9As59fOVzveiz81cRvQIYXzG+AvDmXrXcT8Zpr3yOr5i/AugVvQJAr+gVcF69anleKr16vlesFwXC6NUdvTrUK64fBPzRz3f+h169rlccDwLeevW36tU3T70qw+4V1zsD9OqHgedeta2/KugVQK8C69UwU72Se42m9AoIr1d/nXuv8kO9SugVEHivvt567pWr53k9ezzYGF81G0WvAG+9+rYMp1cjv72y9AoIj1W9+qJ69eXGT6+qQHo1p1dAeL1aS68+q1599tCrykOvNrpXmepVQa+AkHv1aSmR+HxNr35ivh0IvVeffPRqRa86EMfu3zKRj8YkuXxhk5h9Dsfp1ceAejXkePBYPCQisfJI7MyqdW2Z5ZpL9LpXY3rV7VjHw2XZSSofZZ5O5DxGYTmGxpF69UH16uOV517N6NWxxjruf0yTyUdZpjP5LEtj2efwnl5d1K8fz7xX49PsVeq3V1U6y+kV6NXRe5XTq657ldArHKlXD6pXH7rtVXKoV6XnXk1Op1cxvcI5ja+89Gop1+N46dXpHA82emWdv2uSpapX08YGxz6HPo+vRvV+/9Sr/b+KB1MPx4Mt66/0+qHGybZwe5V47pV6z9JO6BW66NVKevW4dd+rYhFF+2RUE8+9yg/3KkkO/xb0Kkrks4nVMqvSjPQHbnrx+SFUlxfD/cvb1dzsx/EPzno1rL/K5/Kv33LsJteNXslBjJpzoVd/bJQaXcWJDEPlIyuSqv5uG+1DFj99A4vd8Y5e3ahe3W889GrmuFeJ7lUykF4ZevWuXplGr4q2Xhl6hdf2alX3anMhvbq5dLU/DQ72qhg52avV/WTo1evF6uNI6BWc9upC9Wp74b5X2XR3IBZKr7J+98roXnWWBXoFxy6Wda/mqleXS1c/QFXPt6cTmW/Ph07evNGricxf2Zmae8/M4QMgetX4OBLVq6SlV/X51adexfQKr+/VYP9ycim9Wsxd/QDl4V4N/PZq2tarfpyPD6pXOb3CsSwXdRnSy1ndK0fn56IfK65+bdx2KM93TktHvZrKrjetW2SaverfefeAepXH9ApHs5jLSGY9NbKbyr8Wu5XVvTID6ZUt3PdqpnqlxlompVf0CmGYzw73KnHVq1R6Vcn1OPSqD71qzLe/rleWXuENZrPKb69s3aukSOP9D/P0gzjYmrMr6ZWZG+nVWOapjO3fbtWTXrHAHa80napeTVSvjPSq083K1Oe1k9zGld33ysYOtuZGrxZtvcp797dKr3BuvcoOzlJ00qtfb5OZZKD2Mp+9GqlnTNCrl/UqoVfo2mTS6FW9bZWqV51uV7v/7187VPzUq3S/aZvEea/UeseR2uHVJ0Gv6BXo1c9tOG32ysHtWnSvrO7VUO+I9IpeIQzjsfTqcuypV79epup40HOvBonaKVN6dZxeGXoFB73qshxqfGV345pfbYiNi14lmdyWcNcrubeMqRpXDNOro/XK0Cu8w3AkK8kvRtKrQk+9d1mOuO7V02Y+kF45eJRKkqtemYWs6U+Kfi8NSkxMr3CKvRp67lUkvdpt8fte7V676FXR2qt+70r0CqdpEECvjPSqSutdoaNexWpfMoXMX8WJ6lWen1CvUq+9ytT9GUy0f3jrz/vJ0Cu8UjWQK19WQ+lV7qdXpZVdwXa0K6teldKraKR6FWX9fm66716Z53oV0SvQqxftylb3ana4V+kp9aqzLLy9V4yv8CZlJb1aDs6hV1ZdylxdyX1MR0u1+6T9fu5UoL2yka3PBUdRZOkVXt0rmapZNHqVuulVLM9JSeK4vvdwHMfdvKtJ1UKNwfX4cK9s2uu/VXqF01SUjV4VnnuVOe2VHapejVf0il4h9F7J7TPnlfQq073qcruSXsW7Xu33rbi7XmVy+bId3cga0fGF7lXW67/VgHqVH+xVRK/wFnkhvZo1epX46FW9CcdxR/dnsLlM2KXju1X9xXQ9pVfd9yqiV+i2V4m7Xhk1vnLSK3lu2XQzo1fdHw9GHA/i7TLVq2l5sFdR172KpVf7qazdH3XUq7SQBbLp5EGeCzvb6l7lvf5bZf4KJ9qrXHo1cd6ruNmr+uWP/+5mmWOjV9N71asrekWvQK9e2qvYb68YX3XTK3X9IMeD6KZXqfQq7rxX/26Um15ljK+cjq/oFY7Yq/HhXiVB9Spu/eKYx4OGXnU0vuJ4EO8ZbuheFf7HVwded3k8qM8PbulV1+Mrq3sV0yu8s1dlgL1S3Xo6bXjU8RW9OlavYnoFB73KDvfK5Xx79K8uvaBX8RunuJq9ulXrRbdqvajRz5fu4/OdkyB7xfEg3tur+PD4KnE3vnqmV4e//8f/+t5eTW5Urzb0il4h+F7V29bI0/Hgm3v10hku/USutFDr2ydXy/qLyXqi/gl61UGvWH+Fd7KqV8PG+MqEcjz4/vFVa6/G20X9xexyof4RevXKXiWvGl9x/SDe1qtUbqU5zPX4ykWvXO2+Lb2y4/Vcjgcv5uofyenV8XvF/RlAr16w+6Yv6dWKXjG+Qk97lZxor2yu7391KfdDnuj79RX0qtNeMX+FN/ZK5q8G59er1bD+YrykV+/oFecHEUiv4pPt1XCp7i+qnkfP8WAXvTKcH8Q7GdWrKjP182msWiId932ApR+1YzP9vIn5oP5iOFO/ZU6v6BUC7JVt9iqV8VV8MgOsZq/U87wG06qlVxm9el2vuP8VPPQqkvGVbJZxz3/J1l5VE1nsPpioXzOjV532ivODeGOv5PxgmR2ev4r63iv9qB2T6uc7j2RxQzWmV932ytArHLdXlToeTE7meLDRK6tOfBYDmacqhqpXqX7+YP9yTa9wXr3q/in0LnuVHNyVozgrpEtZqbpkLb2iVwi7V6n0yupe9X581dqrnF7RK/SqV8n59irNpEtpobrUGFzSqxf0Kn7+fsiG63Hw3l6Zs+5VSq86H1/lB8ZXrGfAG7ds2ZyLNFG9Ss6hV+q4z+b0ylmvGF/hCL0yJ9+rWHblON71ytRJoled94rjQXTUq932Z0/ml9R3gG/0yhorvcr0L0yvjtYr5ttxzF4Z3avs5HuVNHol4ytDr17/PvpAm1713f8DnQKdgtDdBgA=", "force": "H4sIAPa51moC/+3Ze1xN2R4A8DJlMpPqmMgUeqqmJkwvOmFaa5Xp0qhU3Ct5F/JID4rJDeVRciMVMl2Sx+hGXpNBocgkhVEeJdf0pocipaZkWus4595r79Y69//9++d8zl7fz2/t9dh7r7W3goIQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIcT/Hzk66g7yuLVH4h3cQwOY1tQTgEqYz3QDNffI5YxDMoGTRwbTwYIBUEHhGtMVtKrBZ2+7HZ7qjQc0t6ZkPMy01AXBm5KpbiQKgUUZ+0Gc0X2qi4tKhWHJWeDqVHye/cfTyCuwK+pXoGfQSs1XfqEGzlErBqYZT6gud28HjL6TDboPnaG64CpFlHbzMOh5uI/qTjQMQn1zAaCw7VR3Z6UasveKAYFXN1LdJouhaN+/o8G2lfOobpKSFvoiJBIUZO2gOmV9HeJEhXupzvKSDjk/b53dVBcTMBJNUNsGkgN3Ud0n03WJezyB7oqadBE2LDfOUx8FZCWDpFdJcjlR1X6qq0UGxDUlpNDPz8oQ1c9PYfafQYgRmuJ3CPiY/Eh1U+JGo98LUoD6u5NUl/WpMXGpthepznmqCXqxNwE4bS6hOv2DJmQ8cmfWUV21iRkqXBcOsgcqU6/LexEStzxSlepinc2Je5+mQ3XqZWboTl0YcH1mT3X1jebE+d13YTp8rakp+VDdO92viUv7ZivVhS6xQNeuhYDgkmimO1U+Fxj8uJ/qIhTN0cXnTiDT/AjV/WRijlJt7YDewgyqq2wzRX2zH3Qvu0x1Ci+M0exNo4FWQg7TeS0eBWyP36K6k7cNST6jsgKquxJpQFzcALrbHT6KuF/X3Ka6WRtGIFGhFtCbR3eVTV8SN3bSXaoreTQcLdn1GQj1f8SYL5rEbfF4IpdLTXlGdT83icj5OTrSnY6WRt/4aoDd4kqqy76jhnxdtIHR17VUd7RLlYyvTSTb2d41Aat/qKe6ipufoalFemDaikaqWzZIpe95OYSZz//aQOKKg19Q3cN/KSONHkVmOwa7fkIcazx8WgeQ8WjIeEp1uRGKxMVW0+fLeUNF0n9FruVM11ZsAXwXP6Q6pf29ELfjlHUR1S1v7IWvb7c7jBhFd9+f6ST5rMryqW5iRQcsUylzgNXXmQ7nC2+l53us/xqe6Hju8J1yHtPheuVx8uSbcfcVce/Vr9HH16WVtGOQYzbVae9pIW5d8CWqm5LTTNpRfv081YmXNcNtbjlMh/NhF/HbWapTPdJK3F/3n2f2n3toi1wO91+O9y9yueuu9H45920L6ReWk/Yfq97ZYS+JC47NYjp8fl1L6G7VnkbSL6x644wk+VjzJbmrBb6sVwLLU68wHW6HajR9Ph9Ib4HATxU8epPHzGeppwPe1N9gzj/NoWZMh8cNr0uOBd6k3++3NcMAp7EAebEd3isPnEx3OyyaiLOwoq8j9Kob5MonGvYc4nXY3WGF9H5ZV09crCr9fpo2rQ4qvV0EUjrp6405gbWw1HAByK76jersDtbAsa5LQGQk3VXsroaXxCFMZ/OuBl6YuAJoapdQ3cVfaok7m3ef6XasXg+6V5VSXVJFA8nnaEx/vmmrNBP3pOkB1U1oaIXTU9xAfVAZ1X0R1Qaz1zsznV9IOxw8HQEYQ3/uF3p1wZ3t8jljsTvTbc+TuM6UWqbD+wq9APlcdkwj0+F5FTi3mekOHfwbM19S0B9wdq4fMIpqYTpcb74vO9/j8A1AZVezXO5MLL3eMVYStzT6lVwueRnbLdy+Gvgky+dY+YIX9JB6D/u1MfOFTN4EepPamP2C3ak97czx7Vm4RS4XOXoLs15p/7Hyyet6IzvldtnaW+V2y3e9pe9rEztgxPu/A+0bdHf0+Bt4STeI6eovvoZnOrzBtjXyufgwejuShuB17FJge5o+HjOevSTXmzxulPtaUHjuFdPh81PcTL/eIl43kfOL297IdOKFrkAplL4f7FVuhGFBM4Dzevr9z/5dHWwb7AF6N/5nn4z/8zn8PJe6Hl0FmCMq5DgPUS1Z50hd7MpY2LNnMuccZj6pgkoiC5lzuVMORTXe/brLrpJ9qPMPX6GzFvN5XfxefZm7ZTMO1Xas4jjPxCqyrrvmIqn3erE1GpmzltclvRomy7f4Zxt03Gw1x2lvroE10weDf6yQ5Ov2s0PHzLw4bmhhNdn33A+Q5LvuKkaL07nv5MSf10C9tEEgfJnk+bvxshipWaZy+rnsRCVZF0vdXFN7lOtswsk3V1xG2iF17rPEyH/BT5x86y1KiEtbIlnn1A+3461XtfomcfGrJOu1DdF25L3rx26hZx68Ml8TGAUVS/plJX+9Dp9nkXZ4r5Psf7UaxOje4UyOMxIfJflMN+XK3Ig3WRznNfEgjGwzBIfcvyPOMN0ehXpc4TiRdzxph9ThfJ8eNuL036OACDJflp4DpCzTx57XZTX7kX2AY6gVKYspt0eWHsYc93LLDLK+lzqcj89ptE8h1+8+zTGkzOe+PW97i/baQOVFYWD8nNHE/bB7Im//jbMzg/i7xu+HDKgO5zmetxM4aWvJXMHmDI57mT8UtnrG9R2XjKlvhRgpibjv6BcZDoYdX8aAhmNBpGxAmh1vvuy/DOqbB1EgeIo9KTN3tOV1+nMVIX4/Pi/dgZQll1qhg2/OcZybegNwDw0AeI2K/w9PtOZ1585WAbz+861zIWWm062RTvtljvvKvxRc1ZgJ3NT9SVncDWu0opQ7r3zrisi5nUnYSsrObrBGgfY3OW5f+lVQCZ0A7mua+3b9BYDvp9+fOEbKHJX468XunxdtQIBTpizf8D/yOG7LmNMAv8fE3/Sk+SyCuO3F12qOjjoYWZIry9efG/BeAXgE3CJlQybayO3wOffnEtIKSJlmtw1ve+svHQEHTjx0kJapRfXv/M+3yRyeV3wOf//C7qneBVm9blfzeR2ud/Koo7J+6c9NOq8C8LcwqZt0oZTj8Pc5/N4xPj2RlE3otEaNJ4t5HbnegxNl/XfM7EG/zrdnp8zx5ev0PECO5c/aSnWKdxPIsaDucNm8p7nlT1ZSnV5a9IdjbuTXfRq/w/cq/JtRZUl+TyvzO7zW/djx9Ys0H57T0n7m++aO9+bk+apS5iAdNz6H31mQ+WSeRpzGPUs0pKSa4zTKZ32oN07m+OaB1PXtCWVumi23vfgdzX+7LpElb3ulLhF5ElcRbcnbf/ie8eFJTNxR92+o9Uodzsfn3B6Y/c8x7D6+r/0JRd2/bZg6AAA="} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0003_20261009_111538/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0003_20261009_111538/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..683bb5084f1ebe8c7190c12bed22de36f37499c8 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0003_20261009_111538/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0003_20261009_111538", "meta": {"split": "train"}, "act": [10, 389], "T": 390, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0013_20261009_112110/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0013_20261009_112110/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..9697100b0cad9e7faa3cba78997fcce6881fcdcc --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0013_20261009_112110/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0013_20261009_112110", "meta": {"split": "train"}, "act": [15, 322], "T": 323, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0018_20261009_112339/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0018_20261009_112339/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..2be5f550665f54f00f8b34043af918aae77b6344 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0018_20261009_112339/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0018_20261009_112339", "meta": {"split": "train"}, "act": [20, 258], "T": 259, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0020_20261009_112439/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0020_20261009_112439/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e4517947aa2030a3de2587c96496ca7d05ad85dc --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0020_20261009_112439/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0020_20261009_112439", "meta": {"split": "train"}, "act": [15, 296], "T": 297, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0027_20261009_112910/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0027_20261009_112910/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..c9bd6c12aa81d5dcd9166863ca437d758e066dec --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0027_20261009_112910/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0027_20261009_112910", "meta": {"split": "train"}, "act": [7, 327], "T": 328, "maps": "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", "force": "H4sIAAG61moC/+3ae1BUVRwH8LWGhzKhKCLyfvrCGY0BYkCWuycoG0ByMkccngPVNGaAQiFYIA+XJINlGQohYBQXCUISBkKRBRkE4hGNKEojS0BhPpBHJGBRw3/dvRfud+DmouP5c+fDefx+55z727sIBALBvo3bKAHQRrpLXQWqbomrXRbdx1sdrnyu97+urtQe+pu6hBcgF9gvc0XGjZP5qiQ31KVBaH7zOVrL/x5ycecdeXXbB3ygMxB3oxlbxwwR8jk/Kj4HW2/hNL9xBvvje1zBqcNQ/Opkltg52vK56u+uJ9jmiwt6rwVOSf+3e1KwZ7Vwsf2hzy1NUyH1tOSNMk7HzttoEnQ+5nt+8L/pEoW8umewofd9X4kOxWd/6PM3rW4TxWd/fb29i99/YL2GnnP4PgDHfd54bA4NLqoYFq0jnuqG7mf0fl5Af3G66cInNi7f631G2vYMTdV8339+ny6ptqC6nYf8lgVbQPvPLuwK5Ezy3USIe0+7EnI6Q9OQ2zWhTfjMx8yoBjSuYsU3kDvquAyan0Ertg6TGHfINe95F3IRvx+HnFpINuR6L30HuWt2lZDbWlABueCabyF317MIcp9+IoPcjpyzkBsWF0BOmFwIuZPXMOdfUILtg75iyP0mwRyRY3E2LcXikrDuNOQ2VeVicTbPgdy+V7Ig13kuk1cX/yiD+I+miBC3z+8MpysJTSf9wxc53eBNKakN1IVc5qge5GxsLLldnpTEam3kdHrHM4itmSGn2xEiJb+QRs5n12GZhOz+OJTTGZhIiOJIPOe4PdMnyMaE9zmdT4uYuFvv4nR+/knEcMKZ06WtTcD23zvx2P6zx9zO25GQi2rdDblkw22QmzyqBzl59BT0nB4f/wFyPz2Kg1zmgQ2QQ+srsyBH7H2JilrecBa03lHtdMitlR+D3EduOyFnEeAFObSOvSrZD7mV1nmQe2NFC+SCnbog9+La65Cz2f0zrw4dd9m9liXt0HXw7VSVj6Xu0H2PniPX7iu89veSqA9yU779kHMvHoec861/IOdWoA49Ly33PIb6W+otZQCLc/uRHuwe12+H3EOHesh5yFsh1zBwEXJqotOQExl/CbnXkn2WtPvQNxxyX4+dgJzG/VRe+1P0YnVi+F8pkKs1CsDuqzvYeymdIqxei3SQ8erEttlYPmKSISfcjO0D//5oyHWUeEBuLMsIcjPH1CGnS/6A6nsn0Spex63Rb6ZU4dD5Te/fLOIzLuj3Mr7jfDfbnldn4r1mwf1dNlzJyJFTGWG4ygYxhcSF7f0Tm7P1rOF0v5Y5EK3XVxGudZh5niOftVoz3J9NQTTXUzVIxszXcLrSe49JWKMWw2k5JdGcpksPSbVi1nprFIVKn3WRcOerDCdtOy+i1/GdZEZ2meHk+RW0z7ZuKSfXY+s4XX1KMTFKGWA4n2p6HWG4V0qCxMMMF+xAz9EF3wQSInFhxMU0Ubl+jiLL07wYzuVsNc01xbgQ4cRBhpO8SY+VV9EG8uOBQwznrUEfNyJLi5R+lcRwAYpOmtt7SI3VmX9Bf+9To87uztyR05zxiCYpTBcz3JB3Fc2NhuoQI3EK5zqiynVZnaKSPj+3dQbEr+gkZz5GDPRZnXJ+Z53BUCrDlRc30tz9aGMyJl+4M0+l/+7XHmtKJh8w51fYkktzkR7m5GCfhOHW3zhFc6/6mLK6XKsPGK6tmuku1FIMxzY//3oDmmtVWLDGT9ll3TRkdcr3eHf5bdcHt6QM15U3TSEuLK6S4djikr88h+bs/PVZ46f59+kFucCH61nXW1F2HnJmtR2Ucj7YnPK4c+VD2b3dZAW52X3K5tqMMxmOLS6TM9HQOlCnnLfZ/KprpxOuOmyufaD8fwCz+5TN6b8cCPWn7OZaR4Q0gULOB9Lfv+yp6I5AMwAA"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0033_20261009_113244/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0033_20261009_113244/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..b75e54f112d9b53f739697e2669554bb42428878 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0033_20261009_113244/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0033_20261009_113244", "meta": {"split": "train"}, "act": [14, 321], "T": 322, "maps": "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", "force": "H4sIAAO61moC/+3af1CTdRwHcOjMMhZO4RhcEKDABi5AYvwQfHjYM1AMO1wn0RmEBh7aTCg8Oq1jpgkorFFLr1VI6fmjUxwyOTDP28kqDwIRRsTogC5A++FaaITlWa67iIcxnvfBc7EDv39xz73u830+31+f7zYcHO43qK04uxJyF8wEwuSELA5xmd+fhpyulIScw0sNcXY9zul+0PvJ647bds+0xCEup49H/vs3d7M7iTiyX0Ai/aLxlOUim87cWTXzc3XKFZuPqiuQ02hJEnHmQQ/Ira5VQS6zhwuNs3xJP7R/J5s3WjNuhJxPq2zajpbH7o9tuszb7466zLwiyMmN5dD7XT5zFHJcThY57X0+hfUnL1LOyJ7q8xNA+aaUJENOGZI37fkYu16UjxyAzrWUQ2G2nW7v6N6pHP4Iite6KQjKo1ivxtbVJOfuVPbbZPUSPZ9RF6p6mGQznr27seuADWf3+c5QQ99v7L1kLowL2+M3WxrtHqF/Asv9phMxE++K3g/svZEtn0J5pPi5YfddjnZOrdn/sx7NxvMA3udzoO6z3sbci1lpLI/fnJuP+82+Gtv7g+UWKqmGau6AlzkecVnlXDFU6yu2QU5qUohnwzIg/dqh80C5bAs0H0JpJeS0mjOQc73TALn+rg7IBZcuhNbLvqa1kNu+SQ25h3ZpINcl6oHc76q7kJPrFkPrVFnjA7nzSyMhp1qwAnKc3FTIefKeh9ygywuQU2x7jtV+d17LgJzwFuaIbuz9DG7pkJMYpZBLbFgLuYgKCnK3PGIhd2dNAOS+pZyx9bd+PuT4jh3QPkp4tgJyjjtK/3P1hM0a6xYtheKZHnCF3Ou9ndD5p/SsgpxZVgg5nXDJrLhPFvTthdbLBpkcciPFuyCneWML5IwHNkIuVpUNuUtXsXOX814ydh4cXI3t3/NJkHsldxXkTgyQkKutISA3TyfGnFMU5L7IEbHqik1YXR0WR2B14ecwrM5IwiF3dyQIcu8kCyF3VhiM3UuasfqRdsMXq0cSL8jlDzwGuYud7pBLLXOD3Gv7eZBr7F2E3V8WPwq5+aUPQk60xzRat8wXHW3WkqV7hqD6tigDc98Z/oDcQa6DmM14ZZwbrObxzRB2f3nrw35W892R0BvPZr/1hVfj2ewXjVf4WQPkDh3DPpf9MnIOctm9n0AuOuoEq/HYdssMb0Put/VFkHta8SrkGk1bIdfmkoZ9jh8Kt3J5ek+SKZ7XuhDxuoLtJFM833ldYp8jC+KZXM1+d+r4pTIr93ilhPZMLQ6c0A1XxNKe7TwaTJ0q+cDKbVY7056JEqOo7mtVVi7ifQ/aszbvldT12xeY82giqCczmhldSFEcVas0WDlFqC/tWUw0MaEbHy+9LWbCfuu+NtDmSBoZPaGrbI+huSPLI6lUb72183amfZ7JOieCXAkRTjWGfWm9Jsd9z/qT53LqWJCOce1KFaGQc8oNok4PMp9N2sMC6s8C5jNxTZqASv2Lud/WwkBKWH0FcglvtjO6p5yCqMCtBkbnvzCQqh7uhvJA4r1MYG63ls+qU/MFVPbgV5DT5LcwupM9mLPEu9zcxOicZQJq34Z2KN6Lvxqh90NciwvQ7w+NRE46H8oj9noAo7P8DssLC6B4SXpGl5SH9WsZF6Z4/9SPw3zYZck+n9zddCIS8wOgeBbH1vhZ/o/CMn6M81ZPEGpvLJ7FMeZxbx2sygTyQN298bO4ifbl+N9Ny076Q/ujLtwf2ucWV/Bjx5Tm7W+ZExnCUDIAAA=="} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0038_20261009_113551/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0038_20261009_113551/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..8f63ad6c8c676ddeed1cbd016a13195439d4d023 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0038_20261009_113551/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0038_20261009_113551", "meta": {"split": "train"}, "act": [10, 332], "T": 333, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0051_20261009_114603/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0051_20261009_114603/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..8bbb8b5ad0bbfdf4c963e3c1354df38c37d309b1 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0051_20261009_114603/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0051_20261009_114603", "meta": {"split": "train"}, "act": [17, 323], "T": 324, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0059_20261009_115017/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0059_20261009_115017/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e696d88962cbc2fd74a72e03f53a81c3e929b3b7 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0059_20261009_115017/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0059_20261009_115017", "meta": {"split": "held-out"}, "act": [17, 284], "T": 285, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0060_20261009_115048/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0060_20261009_115048/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..94a832425182e1369610e3f140bc299ac5637dc7 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0060_20261009_115048/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0060_20261009_115048", "meta": {"split": "train"}, "act": [11, 254], "T": 255, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0062_20261009_115203/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0062_20261009_115203/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..69368ba3ea647d837013e40bb87e4eb3a63bf603 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0062_20261009_115203/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0062_20261009_115203", "meta": {"split": "train"}, "act": [14, 231], "T": 232, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0069_20261009_115546/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0069_20261009_115546/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..399f7d3566b34e589ed1bb152091c9e14973d1d0 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0069_20261009_115546/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0069_20261009_115546", "meta": {"split": "train"}, "act": [8, 243], "T": 244, "maps": "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", "force": "H4sIAAy61moC/+3af0wTVxwA8KpYChVSAQnDScXIFKaQYUWR0uu7Q1ooso11OKhKkc0NhrKxQTL3i6QTo2HCMHOiicLEkCEjJJsZzCEyScYcouIgtcO1EUYUwqz8kAQYG92W1vZe974ul023fv+DfHjfe9977933LvB4PJ58+XkZDxBF3zIgBx4vrpOy/uCVIgM54HjyMYPTv1laFQga74GIshQppy4mV8alW/rFMYpLZ3b3lUPcuf2rQC6g9GOQM81p53QeXNeZc/cQB3T/Qs8hbV49xWXehyoWxlKcOjc9bP2pu2J5rnDFP7gOoPv3gdjnf9EP/d/i3zrvXeEKV7jivxaVjJaCvC90KL4CvTuNhDyJIC7jleMg52U2I/6BeKIdk4XR2yu6iE5SEkePfDcOyj2gnkdDnGh6Dg2rthvIyQr5IBcXTXaVB3UUyhb84YQjTu+vqSlcvv68gDhe76ILIFd8W4Agjm+UoJ/eJNclv6kQvRs9Qbxv2YfK0LVfholu5xOfoBW6W0S3WtaM8q78gEjvMy3dV9BgiB6R+ti+1/vRp5euEvPuaZtCyc16ojPHzKW/0V8nuhyzEOQ+H/UAuaR5Aro1dYDo3hLzaVXuENEFbBXQfb5TRJcoEdAJH3kR10vnlAe9YdCH6GonRXRbfQB5PK03yKlNQvrXE4uJrlAooF9KEhNd4m432mhYSXTvd08hX/Q40Z3qu4OCVoUTnSaiB1VsiiS6qw2dILdkRyNqV64lug8314DckLgc+f+4nnwObSlEG4M2EF1g8Ivo9h1y3vjXloNcm+6GHDKPMcNRkGtvfQPmjofKIfMtGjZQXDpLjPfGglwlRXHqZDcRyEkrN4Kcz+ifjvA9omZv3P2Nx5HjL6E5rV94WBTIHU5YA3LzWyUgB9lHlni5GDbf0FIlp86Um8ipe2Y0AdgnKjkd7+64CuQK3JM5dfHpSb877T4dBXGkeG9IYecO7YzC9gtfu8db3UxxJD3dcxiR1kGW3w06dQLfh97rbvovY/wWhWFdkbetzskLnmKCv4zAz+vIJuvv55akMCsu4/eBXmurc7Gfmnnk7Rism2y2nX97r6mZ6YMyrBP/bHtOT51QM7H78K7RI4joTlYMoXUtM4jk0vafRQal0epqs5/Fuqq6U2iPxmB1M8+nMgf82Od6T6oGbb5r65+VK5/Djnd2Igud6b5oderH0ph+Tyl2vk/v6rC6ptPpIKcaTmdm1x/LzQiyZk2j1e0+rWGER6JZrvyiAn22Vmd1kyF451klRTluKru8OOetC0Svtj1qdR+E4F3Xdc9ZY5LfWxdn49n1+9vSmNBb7H5rzH9ADnF11Q12znJ9q/PZ67m9s9DOWe7HoIT9HNN2RNq5xe9o8O7SAjtnFG1hcOero1sn2srgznXRmjOUo0tTKFjuXFU5y7VcYJ/Xjt+zLa7kMruvcPx/C3k1LK9/9TbsfAVB/nbzrduVwXwfpSLmtbgXBsjntc/JDAbyXLQ43Dwcoz9PC3Li+Zlgh6tzQF6wHOIc67wsJpOBPLcjarQgV9qbAXL3U2fO8i6MpaDXV2bUMpC+O6cWX+e/6yx5u3bQnF2fND8T6xz3h+X+Qpyz8Uz9BRTEOe7fhoLtoLzOHOt7Vj3b/QZEJwHFICYAAA=="} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0086_20261009_120636/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0086_20261009_120636/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..6de576151ee69a6b91769f91622ecb9871bd2d0b --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0086_20261009_120636/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0086_20261009_120636", "meta": {"split": "train"}, "act": [10, 386], "T": 387, "maps": "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7FWBV/N4leAV0aRXf6a8+qX2eBXjFVHzvPoT49UXx18Xr45denVg761GxcVeeSvkVc56kGi6V3+s5lffqGl+1TqvCvZfES03v2qxV33rXnWXXQ/y+SDhlXfvv6v14FctrwfzVLzaSZ17VXT65V71ap1fJcOY9SDRTF79Z+PVXxl/xbJX2elEIhk9uSO3p3HmVbc+rx7MrzaW8YrzB6mtbe+KV//eePXT45+37FUai1fb4tXRvnuvkj3386uZvAqVVyFeEeXaq982Xn1h/LPmywcWvBrcH4GngzF8cit241VHedVTXu2798oTr+QH6Q3ToUxAi/zU11i21gOviPDK817+p8arN8ZfEq+ObHhlJglpdGdTebVn773GCopOX3l1cEO86g+sb/MJrzaUV90ZvArwivDqoVffUV590apXwyQKTr1K7myYKU3kzKuhMJEeiVdh3/78qpjwSu34747wiug8r3YEiJe/Zrz6qfEXrHoVJEaFNL0zVF7tWvSqO82rm/IDDOzPr87xKhOv8iLLxasIr4gmvfqyGTp/efx58crCKTKhHMGQZXeEiOgocONVd0N5dXxTrVTdeGXu8jHqRObH6ozyKV7JLvaAgxkIrx569TNmDH9u/DmrXkXaq9t95ZXF1dhUr07EKxfzqxyviJb36g0zhj87/qxVr2LlVX5bHImPLH5WH/eUV5viUlavV/JjnfEqxSuiybGzq7z6rPLqM+bL+5a9Km7LfvD4uA6vLr1NvBq68cocpTvsJsoruYogXhGd79WnjFc/Of4Jq14lmfaqUF5ZHI7aq95Wv1FehXhFNOnVJ9fbq2SqVzfdeOWXepVqr4Z4RTSbVx83Y/gz40+JVxZO6Zvq1VELverjFRFerZpXZevB0eR6UHnVwSuic7x63YzhT9fkVdRurzamesXxokRnvHpNefVJZ151bufMr97qVaG94vh2orNefQyvXHhV4BVRQ73KW7f/asKrDeVVdwavuJ4M4dWbXn20Hq9u1TC/6uMVUbO9+ojy6secedVZJa/CFfIqwyuiM17JBT1f/pGBK6/SCa8y5ZXF4SgHOq2SV735vGL/FeHVm1592IzVnxh/wqFXaQ1ebZefj4NXRA3x6ofwysXx7ZOfD/bkx+ry+SDRzF59qBavureSmr267PZ8nAde7Trzyg/8Gn6vosgckR9nifwECYfn0zJe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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0087_20261009_120728/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0087_20261009_120728/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..e2e0152a3b18a391a9f70b07a3c7e12bf3195f2f --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0087_20261009_120728/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0087_20261009_120728", "meta": {"split": "train"}, "act": [14, 383], "T": 384, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0092_20261009_121048/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0092_20261009_121048/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..fa3bec2ff2db579fc71aba76c9871dc163f41dce --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0092_20261009_121048/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0092_20261009_121048", "meta": {"split": "train"}, "act": [9, 362], "T": 363, "maps": "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", "force": "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"} \ No newline at end of file diff --git a/applications/steering/xhand_data/tissue_random_1009/episode_0098_20261009_121506/viewer.json b/applications/steering/xhand_data/tissue_random_1009/episode_0098_20261009_121506/viewer.json new file mode 100644 index 0000000000000000000000000000000000000000..41861617d4429686a73abf053f30b72ba16239f8 --- /dev/null +++ b/applications/steering/xhand_data/tissue_random_1009/episode_0098_20261009_121506/viewer.json @@ -0,0 +1 @@ +{"label": "episode_0098_20261009_121506", "meta": {"split": "train"}, "act": [8, 333], "T": 334, "maps": "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", "force": "H4sIABe61moC/+3ae1TMWRwA8FnblqOsKTkqr1mSRyonYlPN/O61nockjy27y+hs1olDosgjs3pI6OGVaJlVGxEKWUpTsrYjod28T0hJrKSzHdk8avu1i/n1m/l9v7vN2dpd98+Zz/ne1/fe372/8xOJ/mUl31+KYb62XRlt/ynqwt/EUIbekGlzcotLMl02PSfclsG4VFcp81fjMQdtZBg3eJwYFbskvV5rvBJ66M1/OfI4VL1y7z7a681b8Pa/66WyljrO+Ak49flVVrowmHhCzs3E7O18RNqh8q8wfQpqPkQhC3DtmyNnUP0VcJzxw9YbMJbB5ItQvL/TXyGnnn9Kj+Uo5zZjTsudcczb9fHJWe15Gip5E0Oxo732cQmuQeUpxxVHofoh0U9A5YvQOKvnvVBeqecB2p2OaHFe6Tr/OGMktK+pF08JziH3K6GiPh+iaf2lmHjyrTmo/V7IqcdT9NZDxVMYPZW2tL+tVTh59Q+6tt5f9X2tLRbsOQzdX+R5Deuw9XLWeRs8n7aWU38OCvZX7Tmta6eouyzFjDPWCZa8BTp1us5TXe9r2PnA7kM6dzrOU/X7guBZpI0/L1ttv0fmy/+t6HpdVuu1Z3TpsEXhsLBV9nv0OtdxKb4qJRi3fZQ/ym127kgxztx1BspNqvVGufZxCpTLvROMctFWm+h/YV2a3a5A5VX+5AgSUFANznG7eeXE7dee4NgMeOBIJzYMA92FIYvp2McDQRexOoR+HWYGOtszETR+twh03c130LrYc039ZeIWa3+vYruHeoQkg+OSZJNIjfNjQXfCdD9N2xIGukM+B+mdwDWgKwlIpcrdnqDzKT5OA5PhtZ6y8QTKjZ6povaSbqDz2JdFVXJT0KV2zqZ6xjaoeisyLEFnZpFLP39FQFc/N4dufDoSdNvez6ZFV71AJ3bPpoeKvwLdz/EqlFteo6LVMoXOHFsvU7McdL4VmSh3cXMmPWUB53OHexk0UrkBdF9GnaTGpXEoV3YPXpcm0Rk08Rjs7G+foC+UaaB7viqd+omPgu5m3hG6Mg520+cdplMbVKA7diyF1nW7BrrraXtpzPES0A09mEDNz+mB+6SX2TfUYI8l6Hwzt9Hzek6gG28VRUPlU1GuNHp2k2P6j9G6P9ck+NLxqXPBeE+vu9O9xrB7tERKDay9QOc02JyeDJoNOhupHp0TPhl0+y/eJoEmFHRhQy4QrxvOoLMclEIm+sPP1drVShKQ1Bd0Yv1w8irGCHQezmOIXi84r1KyBpPtlx+Bebp9RCTTo+g06BhTgnJsmZichHI7FkXjzsa3QlFu0/6tKIc5HzSd2QJXoly2eDrK9Y8dgXKj+rqi3Lvyrrwrui93Ryeh7nTJVo9QDnPuLi4VE8x5mjWX+8wG3XrHcoI5/4aGm6DuN4aDhlPXVy6gu/tQSlNK7UF3/porxexzBRs8KWZ/jY31opU9xoHOMmMR/XaYI+hWMQF0mZ876G6tCKYfJTiD7qUqlFpb9wHdWbKBtmsQgc7bOZreefYCzD/fhCg6eekT0LmLt6Jc1v1NdLVhP7i/K9aixqXzkmDUODd0UqDuh90/DELFS4pVUJf9IaA7sFlBjzoEg86uAtG+eBdZiHgZTav9TNCx37m5/LAQ5RL9Z9OdyfD66HTdk/q9gM8zs/q5ofrLnqXLrNcJOkVClXTAj46gY+8f8x2GUe+XG8F4bv2GUKdpEcLtGzFDtqbcBvV+xyNkAA0aDb97nR9ihXKGw/ui42Ha90Vlb5SrSuyJcrGPLhKofSU/GTfuA9uJ+Oangk5iVyi7dvSWzN0nUHjeOg5tvF/6kr25/PmtqXvv7V2GcZAWpMSRKQvP8dyhddx3jjZ+mYScvcFzXmEPSHO33vc5z1V5cu/whmcySKSSf/+ySL3JcVZLDhOV3JTnnPWvcJydfTqZNJL/PvWXsVkcV7jnMFlyvhvPGa3N4Dj7hl1E0/vZdnciOU4l30LK5nbnuQmKZRzHrkkvKwnPWfb0IerzwTqDwK485xY0neOSE73JpPhOPJeiMuLU6180nNx9os9zc/0zOc+ew/a9yEy/Wt68PRjYgfPb8TMGGh0zrYwTL+ZxHfN9chXPcd7fN/Zj8OhS5ubDSiLYj0bnfb+AKehSTqB+7Ey+yvgvvcJzH9fncNzWsHvMwUtFoGPbZz/hFM/5Fm/hOGvrBOblymMoJz2SwnPhPZp/k6hAOTaeywcHEPGUaAfW2zgfrGusW7C/7PcbbPs0OfVx/uM7DyWzozIRnA9t9VZ75PHGT1N/NcXT5Pjx4P6+rleTk6QvQ7UPGy/62fxm86HZidJG8eL9NnWncLw/80qTax5Pm9MUr9acf44oDN/H6Qfr9J3WIPK08a7SxVs43us9cOk04fx7Im56H1v/HRWcD/b527Q3abhvqddbojii1anXq7wQ2+RIcGfheB6RWp16+147TfcZTl415Ytm13zeXt+VefnsMYvnNMWTuJryHLunwuuNnY9cBjO/K6qDGXi9iUS7Tjrw2qfpewbj/K6o/o4vkHDc7ySoIYEwNAAA"} \ No newline at end of file