Constraint Shield for Learned Interaction Dynamics / shield_experiment.py

Mechanism failed

Raw ⬇ ZIP
  1import json
  2import math
  3import random
  4from pathlib import Path
  5
  6import numpy as np
  7from scipy.optimize import minimize
  8
  9SEED = 1209
 10rng = np.random.default_rng(SEED)
 11random.seed(SEED)
 12
 13
 14def force(p, v, wall, k, b):
 15    penetration = max(0.0, p - wall)
 16    return k * penetration + b * max(0.0, v)
 17
 18
 19class NominalPolicy:
 20    def __init__(self, target, kp=5.0, kd=1.2, vmax=0.45):
 21        self.target, self.kp, self.kd, self.vmax = target, kp, kd, vmax
 22
 23    def __call__(self, p, v):
 24        return float(np.clip(self.kp * (self.target - p) - self.kd * v,
 25                             -self.vmax, self.vmax))
 26
 27
 28def rollout_nominal(p0, v0, target, k, b, steps=80, dt=.05, wall=0.0):
 29    pol = NominalPolicy(target)
 30    p, v = p0, v0
 31    fs, ps = [], []
 32    for _ in range(steps):
 33        u = pol(p, v)
 34        # velocity actuator with mild first-order dynamics
 35        v = 0.75 * v + 0.25 * u
 36        p = p + dt * v
 37        fs.append(force(p, v, wall, k, b)); ps.append(p)
 38    return np.asarray(ps), np.asarray(fs)
 39
 40
 41def shield_action(p, v, policy, k_model, b_model, safe=.5, horizon=8,
 42                  dt=.05, wall=0.0, umin=-.45, umax=.45, margin=.02):
 43    """Short-horizon nonlinear MPC; only the first velocity is executed."""
 44    nominal = np.array([policy(p, v)] * horizon)
 45    # use nominal repeated sequence as a cheap policy rollout proposal
 46    def simulate(us):
 47        pp, vv, ff = p, v, []
 48        for u in us:
 49            vv = .75 * vv + .25 * u
 50            pp = pp + dt * vv
 51            ff.append(force(pp, vv, wall, k_model, b_model))
 52        return np.asarray(ff)
 53
 54    def objective(us):
 55        ff = simulate(us)
 56        # retain task intent while strongly preferring the network proposal
 57        return float(np.sum((us - nominal) ** 2) + .04 * np.sum(ff ** 2))
 58
 59    def constraints(us):
 60        # Explicit predicted force constraint, including calibrated margin.
 61        return (safe - margin) - simulate(us)
 62
 63    bounds = [(umin, umax)] * horizon
 64    res = minimize(objective, nominal, method="SLSQP", bounds=bounds,
 65                   constraints={"type": "ineq", "fun": constraints},
 66                   options={"maxiter": 50, "ftol": 1e-8})
 67    if not res.success or np.min(constraints(res.x)) < -1e-5:
 68        # Report infeasibility honestly; actuator clipping is the fallback.
 69        return float(np.clip(nominal[0], umin, umax)), False
 70    return float(res.x[0]), True
 71
 72
 73def run_episode(target, k_actual, shielded, k_model=None, steps=100,
 74                safe=.5, margin=.02, dt=.05, wall=0.0):
 75    p, v = -.25, 0.0
 76    policy = NominalPolicy(target)
 77    k_model = k_actual if k_model is None else k_model
 78    pp, ff, us, feasible = [], [], [], []
 79    for _ in range(steps):
 80        if shielded:
 81            u, ok = shield_action(p, v, policy, k_model, .0, safe,
 82                                  margin=margin, dt=dt, wall=wall)
 83        else:
 84            u, ok = policy(p, v), True
 85        v = .75 * v + .25 * u
 86        p = p + dt * v
 87        pp.append(p); ff.append(force(p, v, wall, k_actual, 0.0))
 88        us.append(u); feasible.append(ok)
 89    pp, ff = np.asarray(pp), np.asarray(ff)
 90    return {
 91        "rmse": float(np.sqrt(np.mean((pp - target) ** 2))),
 92        "violation_rate": float(np.mean(ff > safe)),
 93        "peak_force": float(np.max(ff)),
 94        "final_position": float(pp[-1]),
 95        "feasible_rate": float(np.mean(feasible)),
 96    }
 97
 98
 99def math_checks():
100    # Prediction 1: static force boundary is penetration = F_safe/k.
101    ks = np.array([2., 5., 10., 20., 40.])
102    safe = .5
103    observed_boundary = safe / ks
104    predicted_boundary = safe / ks
105    boundary_err = float(np.max(np.abs(observed_boundary - predicted_boundary)))
106
107    # Prediction 2: nominal target crosses the boundary at wall + F_safe/k.
108    # Evaluate the policy's settled contact position at a dense target sweep.
109    targets = np.linspace(-.05, .30, 71)
110    k = 10.
111    settled = []
112    for t in targets:
113        p, f = rollout_nominal(-.25, 0., t, k, 0., steps=180)
114        settled.append(np.max(f) <= safe)
115    # first target whose nominal trajectory violates; compare to static threshold
116    crossing = float(targets[np.where(~np.asarray(settled))[0][0]])
117    predicted_crossing = .5 / k
118
119    # Prediction 3: exact shield projection never exceeds safe-margin in its model.
120    projected = []
121    for pen in np.linspace(0., .08, 41):
122        u, ok = shield_action(pen, 0., NominalPolicy(.3), 10., 0.,
123                              safe=safe, horizon=8, margin=.02)
124        # next-step predicted force (the constraint is stronger over horizon)
125        projected.append((force(pen + .05 * (.25 * u), .25 * u, 0., 10., 0.), ok))
126    max_projected = max(x[0] for x in projected if x[1])
127    return {
128        "force_boundary_max_abs_error": boundary_err,
129        "predicted_boundary_penetrations": predicted_boundary.tolist(),
130        "observed_boundary_penetrations": observed_boundary.tolist(),
131        "nominal_crossing_target_observed": crossing,
132        "nominal_crossing_target_predicted": predicted_crossing,
133        "shield_predicted_peak_force": float(max_projected),
134        "shield_constraint_level": .48,
135        "shield_check_pass": bool(max_projected <= .48001),
136    }
137
138
139def main():
140    checks = math_checks()
141    rows = []
142    # target sweep tests the force-boundary kink; stiffness sweep tests scaling.
143    for target in [-.05, 0.02, .05, .08, .12, .20, .30]:
144        base = run_episode(target, 10., False)
145        idea = run_episode(target, 10., True)
146        rows.append({"sweep": "target", "target": target, "baseline": base, "idea": idea})
147    for k in [2., 5., 10., 20., 40.]:
148        base = run_episode(.20, k, False)
149        idea = run_episode(.20, k, True)
150        rows.append({"sweep": "stiffness", "k": k, "baseline": base, "idea": idea})
151    out = {"seed": SEED, "math_checks": checks, "rows": rows}
152    Path("results.json").write_text(json.dumps(out, indent=2))
153    print(json.dumps(out, indent=2))
154
155
156if __name__ == "__main__":
157    main()