Trajectory-Learned Actuator-Aware Funnel Network / funnel_mvp.py
Beats tuned baseline
1import json
2from pathlib import Path
3import numpy as np
4
5SEED = 1339
6DT = 0.002
7T = 2.0
8TS = np.arange(0.0, T + DT / 2, DT)
9ALPHA = 0.10
10EPS = 0.02
11
12
13def smooth_positive(y, width=31):
14 width = int(width) | 1
15 pad = width // 2
16 yp = np.pad(y, (pad, pad), mode="edge")
17 return np.convolve(yp, np.ones(width) / width, mode="valid") + 1e-8
18
19
20def demonstrations(rng, n=160):
21 x0 = rng.uniform(-1.0, 1.0, n)
22 k = rng.normal(1.0, 0.07, n)
23 x = x0[:, None] * np.exp(-k[:, None] * TS[None, :])
24 x += rng.normal(0.0, 0.006, x.shape)
25 return x
26
27
28def infer_funnel(xdemo):
29 raw = EPS + np.quantile(np.abs(xdemo), 1.0 - ALPHA, axis=0)
30 return smooth_positive(raw)
31
32
33def rollout(x0, ubar, k_request, rho, aware):
34 x = float(x0)
35 xs, us, gains = [], [], []
36 for j in range(len(TS)):
37 r = float(rho[j])
38 k = min(k_request, ubar / r) if aware else k_request
39 u = float(np.clip(-k * x, -ubar, ubar))
40 xs.append(x)
41 us.append(u)
42 gains.append(k)
43 x += DT * u
44 return np.asarray(xs), np.asarray(us), np.asarray(gains)
45
46
47def fit_decay(x):
48 sel = (TS >= 0.15) & (TS <= 0.8) & (np.abs(x) > 1e-5)
49 return -float(np.polyfit(TS[sel], np.log(np.abs(x[sel])), 1)[0])
50
51
52def main():
53 rng = np.random.default_rng(SEED)
54 demos = demonstrations(rng)
55 rho = infer_funnel(demos)
56 # Prediction 1: actuator-aware requested gain is clipped at k_max=ubar/rho.
57 # Use the initial funnel radius, where the scalar bound is sharpest.
58 rho0 = float(rho[0])
59 ubar = 0.35
60 predicted_kmax = ubar / rho0
61 gain_rows = []
62 for requested in [0.5 * predicted_kmax, 0.9 * predicted_kmax,
63 1.1 * predicted_kmax, 2.0 * predicted_kmax]:
64 _, _, gains = rollout(1.0, ubar, requested, rho, True)
65 gain_rows.append({"requested": requested,
66 "observed_initial_gain": float(gains[0]),
67 "ratio_to_bound": float(gains[0] / predicted_kmax)})
68
69 # Prediction 2: below the bound, normalized error decays as exp(-k t),
70 # while above it actuator clipping causes a slower initial decay.
71 decay_rows = []
72 for requested in [0.5 * predicted_kmax, 0.9 * predicted_kmax,
73 1.1 * predicted_kmax, 2.0 * predicted_kmax]:
74 for aware in [False, True]:
75 x, u, gains = rollout(1.0, ubar, requested, rho, aware)
76 decay_rows.append({"requested": requested, "aware": aware,
77 "predicted_decay": requested,
78 "observed_decay": fit_decay(x),
79 "max_abs_u": float(np.max(np.abs(u))),
80 "max_normalized_error": float(np.max(np.abs(x) / rho)),
81 "violation_fraction": float(np.mean(np.abs(x) > rho))})
82
83 # Prediction 3: increasing actuator authority increases the feasible gain
84 # linearly, k_max=ubar/rho0, and the aware policy realizes that scaling.
85 scaling_rows = []
86 requested = 10.0
87 for limit in [0.15, 0.25, 0.35, 0.50, 0.70]:
88 _, _, gains = rollout(1.0, limit, requested, rho, True)
89 predicted = limit / rho0
90 scaling_rows.append({"ubar": limit, "predicted_kmax": predicted,
91 "observed_initial_gain": float(gains[0]),
92 "relative_error": float(abs(gains[0] - predicted) / predicted)})
93
94 # Held-out initial states: compare fixed-gain saturated baseline to funnel-aware law.
95 heldout = np.linspace(-1.0, 1.0, 41)
96 k_baseline = 2.0 * predicted_kmax
97 comparisons = []
98 for aware in [False, True]:
99 vals = []
100 for x0 in heldout:
101 x, u, _ = rollout(x0, ubar, k_baseline, rho, aware)
102 vals.append([abs(x[-1]), np.max(np.abs(x) / rho), np.mean(np.abs(x) > rho), np.mean(np.abs(u))])
103 a = np.asarray(vals)
104 comparisons.append({"controller": "funnel_aware" if aware else "fixed_gain_saturated",
105 "final_abs_error_mean": float(a[:, 0].mean()),
106 "max_normalized_error_mean": float(a[:, 1].mean()),
107 "violation_fraction_mean": float(a[:, 2].mean()),
108 "mean_abs_control": float(a[:, 3].mean())})
109
110 # Cheap numerical derivative check of the normalized-error identity.
111 x, _, _ = rollout(0.8, ubar, 0.5 * predicted_kmax, rho, True)
112 z = x / rho
113 zd_num = np.gradient(z, DT)
114 edot = np.gradient(x, DT)
115 rhodot = np.gradient(rho, DT)
116 zd_formula = edot / rho - x * rhodot / (rho ** 2)
117 identity_rel_error = float(np.max(np.abs(zd_num - zd_formula)) / (np.max(np.abs(zd_num)) + 1e-9))
118
119 results = {
120 "seed": SEED,
121 "n_demonstrations": len(demos),
122 "rho_initial": rho0,
123 "rho_final": float(rho[-1]),
124 "predictions": {
125 "actuator_boundary": {"predicted_kmax_ubar_over_rho": predicted_kmax, "sweep": gain_rows},
126 "decay_and_saturation": decay_rows,
127 "linear_authority_scaling": scaling_rows,
128 "normalized_error_identity_relative_error": identity_rel_error,
129 },
130 "comparison": comparisons,
131 }
132 Path("results.json").write_text(json.dumps(results, indent=2))
133 print(json.dumps(results, indent=2))
134
135
136if __name__ == "__main__":
137 main()