"""GlobMP as the projector of DiRecT's sampler on D3IL avoiding-v0 (drop-in for IpoptProjector in policy/direct.py). DiRecT projects the clean-trajectory estimate X_ref (H = 16 transitions, normalized) onto { X : s_{t+1} = A s_t + B a_t + c, every keep-out cleared by its margin } minimizing 1/2 ||X - X_ref||^2, imposing the keep-outs at the 16 states. GlobMP solves the same projection with the keep-outs imposed for EVERY t: * variables: the predicted end-effector positions p_1..p_{H-1} (real coordinates); the motion between consecutive positions is the straight segment, i.e. a C^0 spline of H-1 degree-1 Bezier pieces with p_0 fixed at the current position and a free end. Because B[2:4] (the response of the actual position to the action) is invertible, the actions and the commanded-position states are affine in p, so X = F(p) is affine and the cost is DiRecT's own 1/2 ||F(p) - X_ref||^2 over all channels; * every keep-out disc (radius + DiRecT's margin) and both half-planes (moved inward by the margin) are sphere and half-space blocks of the released globmp package on every segment, with GlobMP's own inflation (5 mm) as the slack of its tolerance: the margin holds at every point of the piecewise-linear motion, not only at the 16 states; * Algorithm 1 restores feasibility from the unconstrained projection, Algorithm 2 (gradient projection with the convex repair) lowers the cost under a per-call deadline. The last action a_{H-1} is unconstrained and kept. Env: GPA_AV_DEADLINE (s per projection, default 1.0), GPA_AV_WORKERS (processes), GLOBMP_ROOT, GPA_AV_DEBUG. """ from __future__ import annotations import os import sys import time from concurrent.futures import ProcessPoolExecutor import numpy as np from projector import IpoptProjector _GLOBMP_ROOT = os.environ.get('GLOBMP_ROOT', os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))) class _Affine: """X_dof = F(p): positions p (H-1, 2), real coordinates -> DiRecT's flat DOF vector (normalized), given s0 and a_{H-1}.""" def __init__(self, A, B, c, H, xr, yr, to_dof): self.A, self.B, self.c, self.H = A, B, c, H self.Bp_inv = np.linalg.inv(B[2:4]); self.xr, self.yr = xr, yr; self.to_dof = to_dof def norm(self, P): (x0, x1), (y0, y1) = self.xr, self.yr return np.stack([(P[..., 0] - x0) / (x1 - x0) * 2 - 1, (P[..., 1] - y0) / (y1 - y0) * 2 - 1], -1) def traj(self, P, s0, a_last): Pn = self.norm(P); s = np.asarray(s0, float).copy(); acts, states = [], [s.copy()] for t in range(self.H - 1): a = self.Bp_inv @ (Pn[t] - self.A[2:4] @ s - self.c[2:4]) s = self.A @ s + self.B @ a + self.c acts.append(a); states.append(s.copy()) acts.append(np.asarray(a_last, float)) return np.concatenate([np.array(acts), np.array(states)], 1) def __call__(self, P, s0, a_last): return self.to_dof(self.traj(P, s0, a_last)) def linear(self, s0, a_last): """F(p) = J vec(p) + x0, vec row-major over (H-1, 2).""" n = (self.H - 1) * 2; x0 = self(np.zeros((self.H - 1, 2)), s0, a_last) J = np.empty((x0.size, n)) for j in range(n): e = np.zeros(n); e[j] = 1.0 J[:, j] = self(e.reshape(-1, 2), s0, a_last) - x0 return J, x0 def action_linear(self, s0, a_last): """Actions a_0..a_{H-2} (normalized, row-major over (H-1, 2)) = Ja vec(p) + a0: affine in the positions.""" n = (self.H - 1) * 2; ad = 2 def acts(P): return self.traj(P, s0, a_last)[:self.H - 1, :ad].reshape(-1) a0 = acts(np.zeros((self.H - 1, 2))); Ja = np.empty((a0.size, n)) for j in range(n): e = np.zeros(n); e[j] = 1.0; Ja[:, j] = acts(e.reshape(-1, 2)) - a0 return Ja, a0 def _chart(Problem, n, M, start, **kw): """Problem with M degree-1 pieces, gamma(0) = start, C^0 joins and a FREE end: the chart is rebuilt from the start and junction conditions only (a change of chart; Algorithms 1-2 are unchanged).""" import torch p = Problem(n, 1, 1, 0, M, start, start, ell_j=0, **kw) DT = p.H.dtype; P = 2 * M; cols, Bc = [], [] c = torch.zeros(P, dtype=DT); c[0] = 1.0; cols.append(c); Bc.append(torch.as_tensor(start, dtype=DT)) for i in range(M - 1): c = torch.zeros(P, dtype=DT); c[2 * i + 1] = 1.0; c[2 * i + 2] = -1.0; cols.append(c); Bc.append(torch.zeros(n, dtype=DT)) A = torch.stack(cols, 1); B = torch.stack(Bc, 1); nc = A.shape[1] Api = torch.linalg.inv(A.T @ A) @ A.T _, _, Vt = torch.linalg.svd(A.T, full_matrices=True) p.A, p.B, p.Gamma0, p.N, p.d_f = A, B, (B @ Api).contiguous(), Vt[-(P - nc):, :].T.contiguous(), P - nc p._init_blocks() return p def _problem_class(Problem): import torch class ProjCost: """DiRecT's projection cost 1/2 ||Mz vec(Z) + b||^2 on the chart (x and y are coupled by the dynamics).""" def set_projection_cost(self, J, x0, X_ref): idx = [2 * t + 1 for t in range(self.M)] # knot t+1 = end of piece t DT = self.Gamma0.dtype; Nk = self.N[idx, :] # (M, d_f) K = torch.zeros(2 * self.M, 2 * self.d_f, dtype=DT) # vec(knots) = K vec(Z) + k0 for i in range(2): K[i::2, i * self.d_f:(i + 1) * self.d_f] = Nk k0 = self.Gamma0[:, idx].T.reshape(-1) Jt = torch.as_tensor(J, dtype=DT) self.Mz = Jt @ K; self.bz = Jt @ k0 + torch.as_tensor(x0 - X_ref, dtype=DT); self._K, self._k0 = K, k0 ev = torch.linalg.eigvalsh(self.Mz.T @ self.Mz); self.beta = float(ev.max()); self.alpha = float(ev.min()) def set_action_bounds(self, Ja, a0, lo, hi, rate=None): """lo <= a_t <= hi for every planned action: linear in Z, imposed as constant-polynomial blocks (a constant is in the cone of nonnegative polynomials iff it is >= 0), so restoration and repair treat them like any block.""" DT = self.Gamma0.dtype; Jt = torch.as_tensor(Ja, dtype=DT); A_ = Jt @ self._K; b_ = Jt @ self._k0 + torch.as_tensor(a0, dtype=DT) m = Jt.shape[0]; lo_ = torch.as_tensor(np.tile(lo, m // 2), dtype=DT); hi_ = torch.as_tensor(np.tile(hi, m // 2), dtype=DT) sc = float(os.environ.get('GPA_AV_ACTSCALE', 1e-3)) # row scale to the obstacles' m^2 units (same constraint) G = [A_, -A_]; h = [b_ - lo_, hi_ - b_] if rate is not None: # |a_{t+1} - a_t| <= rate, per coordinate Dm = torch.zeros(m - 2, m, dtype=DT); Dm[:, 2:] += torch.eye(m - 2, dtype=DT); Dm[:, :-2] -= torch.eye(m - 2, dtype=DT) r_ = torch.as_tensor(np.tile(rate, (m - 2) // 2), dtype=DT); DA = Dm @ A_; Db = Dm @ b_ G += [-DA, DA]; h += [r_ - Db, r_ + Db] # r - (a_{t+1}-a_t) >= 0, r + (a_{t+1}-a_t) >= 0 self._linG = sc * torch.cat(G, 0); self._linh = sc * torch.cat(h, 0); self._actsc = sc def set_growing_discs(self, discs): """discs: list of (centre, radius, segment): the disc holds on that segment only. Used for a margin that grows with the number of steps a planned position is executed open loop before the next replan.""" self._gd = [(torch.as_tensor(c, dtype=self.Gamma0.dtype), float(r), int(a)) for c, r, a in discs] def pi_all(self, Z): base = super().pi_all(Z); extra = []; types = list(self.types) from globmp import spline as bern gd = getattr(self, '_gd', None) if gd: segs = self.segs(Z) # (M, n, d+1) for i, (c, r, a) in enumerate(gd): Gc = segs[a] - c.view(-1, 1) * self.u.view(1, -1) Mm = Gc.T @ Gc - ((r + self.delta) ** 2) * self.uu extra.append(bern.S_map(Mm, self.d).unsqueeze(0)); types.append(('sphg', i, a)) G = getattr(self, '_linG', None) if G is not None: v = G @ Z.reshape(-1) + self._linh extra.append(v.unsqueeze(1).expand(-1, base.shape[1])); types += [('act', i, 0) for i in range(v.shape[0])] self.types = types return torch.cat([base] + extra, 0) if extra else base def tolvec(self): """Action rows: tolerance 1e-8 on the squared cone distance (a violation of at most ~6e-5 in normalized units, far inside the 2% slack of the bounds); the default 1e-12 stalls restoration on an active bound.""" tv = super().tolvec(); dl = self.delta for b, t in enumerate(self.types): if t[0] == 'act': tv[b] = 1e-8 * getattr(self, '_actsc', 1.0) ** 2 elif t[0] == 'sphg': tv[b] = (2 * self._gd[t[1]][1] * dl + dl * dl) ** 2 # as for the package's spheres return tv def cost(self, Z): r = self.Mz @ Z.reshape(-1) + self.bz return 0.5 * (r * r).sum() def cost_grad(self, Z): return (self.Mz.T @ (self.Mz @ Z.reshape(-1) + self.bz)).reshape(Z.shape) def cost_min(self): return torch.linalg.lstsq(self.Mz, -self.bz.unsqueeze(1)).solution.reshape(2, self.d_f).contiguous() def knots(self, Z): return self.Gamma(Z)[:, [2 * t + 1 for t in range(self.M)]].T return type('GlobMPProjProblem', (ProjCost, Problem), {}) class _Solver: def __init__(self, circles, planes, margin, deadline, H, delta0=0.005, grow=0.0, rp=8, recover=0.01): if _GLOBMP_ROOT not in sys.path: sys.path.insert(0, _GLOBMP_ROOT) import torch torch.set_num_threads(1) from globmp import Problem self.PP = _problem_class(Problem) self.circles = [(np.array(c, float), float(r)) for c, r in circles] self.planes = [(float(s), float(b)) for s, b in planes] # feasible: s x + b - y >= 0 self.margin, self.deadline, self.H, self.delta0 = float(margin), float(deadline), int(H), float(delta0) self.grow, self.rp, self.recover = float(grow), int(rp), float(recover) self.cold_deadline = float(os.environ.get('GPA_AV_COLD_DEADLINE', 12.0)) def clearance(self, q): c = [np.hypot(*(q - cc)) - r for cc, r in self.circles] c += [(s * q[0] + b - q[1]) / np.hypot(s, 1.0) for s, b in self.planes] return min(c) def solve(self, p0, J, x0, X_ref, Ja=None, a0=None, lo=None, hi=None, rate=None, Zw=None, Kg=None): """Returns (knot positions (H-1, 2) or None, ok, chart point Z or None). Zw: the previous denoising step's solution for the same state (feasible here: same constraints), so restoration returns at once and Algorithm 2 descends the new cost from a feasible start. Kg: a guess of the knots (the previous plan shifted by the executed steps), used when there is no Zw.""" import torch c0 = self.clearance(p0) if c0 <= 0: return None, False, None # the robot is already inside # DiRecT's margin is part of the geometry (radii raised, half-planes moved inward), as in its IPOPT projection; # GlobMP's own inflation delta is only the slack of its feasibility tolerance, so the curve keeps at least the # margin at every t. The start must be strictly feasible: near a keep-out the margin shrinks to 0.8 c0. mg = min(self.margin, 0.8 * c0); dl = min(self.delta0, 0.5 * (c0 - mg)) p = _chart(self.PP, 2, self.H - 1, p0, delta=dl, mode='cone', bb=False) p.set_projection_cost(J, x0, X_ref) if Ja is not None: p.set_action_bounds(Ja, a0, lo, hi, rate) # the environment clips actions outside these # Margin per segment: the start may be closer than the margin (mg = 0.8 c0 on segment 0), but the plan must win the # margin back, by at most `recover` metres per step, so a robot that drifted near a keep-out is steered away # instead of grazing it for the whole plan. Segment a then keeps min(margin, mg + recover a) (+ grow a). rec = self.recover m_seg = [min(self.margin, mg + rec * a) + self.grow * a for a in range(self.H - 1)] for cc, r in self.circles: p.add_sphere(cc, r + mg) # every segment, the start's margin extra = [(cc, r + m_seg[a], a) for cc, r in self.circles for a in range(1, self.H - 1) if m_seg[a] > mg + 1e-9] if extra: p.set_growing_discs(extra) for s, b in self.planes: # unit normal: offsets are distances nn = np.hypot(s, 1.0) for a in range(self.H - 1): p.add_halfspace([s / nn, -1.0 / nn], -b / nn + m_seg[a], segs=[a]) t0 = time.time(); tv = p.tolvec() dlt = self.deadline if Zw is not None else self.cold_deadline # cold start: the plan's first projection if Zw is not None: Z = torch.as_tensor(Zw, dtype=p.Gamma0.dtype).contiguous() elif Kg is not None: # least-squares chart point of the guessed knots r = torch.as_tensor(np.asarray(Kg, float).reshape(-1), dtype=p.Gamma0.dtype) - p._k0 Z = torch.linalg.lstsq(p._K, r.unsqueeze(1)).solution.reshape(2, p.d_f).contiguous() else: Z = p.cost_min() Z, eta, ok = p.project(Z, tv, cap=20000, deadline=t0 + 0.6 * dlt) if not ok and Zw is None and Kg is not None: # the guess did not restore: plain start Z, eta, ok = p.project(p.cost_min(), tv, cap=20000, deadline=t0 + 0.9 * dlt) if ok: Z, eta, _ = p.solve(Z, tv, blocks=50, deadline=t0 + dlt) return p.knots(Z).detach().cpu().numpy(), bool(ok), (Z.detach().cpu().numpy() if ok else None) _W = None def _init_worker(cfg): global _W _W = _Solver(**cfg) def _work(args): return _W.solve(*args) class GlobMPProjector(IpoptProjector): """Same interface as IpoptProjector (project, project_batch, dof conversions); GlobMP inside.""" def __init__(self, *a, **kw): super().__init__(*a, **kw) assert self.dynamics is not None, 'the GlobMP projector maps positions to actions through the dynamics model' A = np.asarray(self.dynamics['A'], float); B = np.asarray(self.dynamics['B'], float); c = np.asarray(self.dynamics['c'], float) self._F = _Affine(A, B, c, self.horizon, (self._xmin, self._xmax), (self._ymin, self._ymax), self.full_trajectory_to_dof) self._cfg = dict(circles=[(o.center, o.radius) for o in self.circular_obstacles], planes=[(o.slope, o.intercept) for o in self.planar_obstacles], margin=self.obstacle_margin, deadline=float(os.environ.get('GPA_AV_DEADLINE', 1.0)), H=self.horizon, grow=float(os.environ.get('GPA_AV_GROW', 0.0)), rp=int(os.environ.get('GPA_AV_REPLAN', 8)), recover=float(os.environ.get('GPA_AV_RECOVER', 0.01))) self._pool = None; self._local = None # The environment clips each action to dx in [-0.0125, 0.0125], dy in [0, 0.0125] (m per step): impose these, # 2% inside, in normalized units, so every planned action is executed as planned. am = np.asarray(self.normalizer.normalizers['actions'].mins, float); aM = np.asarray(self.normalizer.normalizers['actions'].maxs, float) lo_r = np.array([-0.0125, 0.0]); hi_r = np.array([0.0125, 0.0125]); sl = 0.02 * (hi_r - lo_r) self._alo = (lo_r + sl - am) / (aM - am) * 2 - 1; self._ahi = (hi_r - sl - am) / (aM - am) * 2 - 1 self._use_alim = os.environ.get('GPA_AV_ACTLIM', '1') == '1' # action-rate limit per step: the 99th percentile of the 96 demonstrations (dx 6.5 mm, dy 8.8 mm), normalized rr = np.array([float(os.environ.get('GPA_AV_RATE_DX', 0.0065)), float(os.environ.get('GPA_AV_RATE_DY', 0.0088))]) self._arate = None if os.environ.get('GPA_AV_RATE', '1') != '1' else rr / (aM - am) * 2 def _prep(self, X_ref, s0): X_ref = np.asarray(X_ref, float).flatten(); s0 = np.asarray(s0, float).flatten() a_last = self.dof_to_full_trajectory(X_ref, s0)[-1, :self.action_dim] J, x0 = self._F.linear(s0, a_last) p0 = np.array([self._unnorm_x(s0[2]), self._unnorm_y(s0[3])]) if not self._use_alim: return (p0, J, x0, X_ref, None, None, None, None, None), a_last, s0 Ja, a0 = self._F.action_linear(s0, a_last) return (p0, J, x0, X_ref, Ja, a0, self._alo, self._ahi, self._arate), a_last, s0 def _finish(self, out, prep): (P, ok, _), (args, a_last, s0) = out, prep if P is None: return args[3], 'failed', 0 return self._F(P, s0, a_last), ('optimal' if ok else 'failed'), 0 def project(self, X_ref, s0, reg_weight=None, a_prev=None): if self._local is None: self._local = _Solver(**self._cfg) pr = self._prep(X_ref, s0) return self._finish(self._local.solve(*pr[0]), pr) def _warm(self, b, s0, p0): """(Zw, Kg) for batch slot b: the last solution if the state is unchanged, else the last plan shifted.""" if os.environ.get('GPA_AV_WARM', '1') != '1': return None, None w = self._wst.get(b) if w is None: return None, None ws0, Z, K = w if np.allclose(ws0, s0, atol=1e-9): return Z, None rp = self._cfg['rp']; Kg = np.concatenate([K[rp:], np.repeat(K[-1:], rp, 0)], 0) return (None, Kg) if np.hypot(*(Kg[0] - p0)) < 0.05 else (None, None) def project_batch(self, X_refs, s0s, n_workers=1, reg_weight=None, a_prevs=None, **kw): X_refs = np.asarray(X_refs); s0s = np.asarray(s0s) prep = [self._prep(X_refs[b], s0s[b]) for b in range(len(X_refs))] if not hasattr(self, '_wst'): self._wst = {} jobs = [pr[0] + self._warm(b, pr[2], pr[0][0]) for b, pr in enumerate(prep)] nw = int(os.environ.get('GPA_AV_WORKERS', n_workers)) if nw > 1: if self._pool is None: import multiprocessing as _mp self._pool = ProcessPoolExecutor(max_workers=nw, mp_context=_mp.get_context('spawn'), initializer=_init_worker, initargs=(self._cfg,)) outs = list(self._pool.map(_work, jobs)) else: if self._local is None: self._local = _Solver(**self._cfg) outs = [self._local.solve(*j) for j in jobs] for b, (o, pr) in enumerate(zip(outs, prep)): # remember each slot's last solution if o[2] is not None: self._wst[b] = (pr[2].copy(), o[2], o[0]) res = [self._finish(o, pr) for o, pr in zip(outs, prep)] if os.environ.get('GPA_AV_DEBUG'): print(f"[gpa-av] {res[0][1]} |X-Xref|max={np.abs(res[0][0] - prep[0][0][3]).max():.3f}", flush=True) return np.stack([r[0] for r in res]), [r[1] for r in res], [r[2] for r in res]