Constraint Shield for Learned Interaction Dynamics / shield_experiment.py
Mechanism failed
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()