File size: 19,404 Bytes
f8faa82
 
 
 
 
 
 
 
 
 
 
 
 
 
26689d8
f8faa82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26689d8
f8faa82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26689d8
f8faa82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""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;
  * the restoration (globmp/restore.py) makes the unconstrained projection feasible, 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; the restoration and gradient projection 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 gradient projection 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]