aurasync-engine 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ """Learned frame-camera baseline (fair comparison for the event stack).
2
+
3
+ The phase-1 frame baselines were handed the TRUE state at capture time. Here
4
+ the frame stack has to *see* the state the same way the event stack does:
5
+
6
+ sensor global-shutter grayscale camera on the same 96x32 scene,
7
+ exposure-integrated motion blur and read noise
8
+ estimator FrameEstimator: ResNet-lite trunk over the last 3 frames plus a
9
+ shared per-frame spatial-softmax keypoint encoder (the same
10
+ sub-pixel tool the event observer uses), conditioned on frame
11
+ period and exposure. Regresses the full state at capture time.
12
+ pipeline LearnedFramePipeline: at each frame arrival (capture + latency)
13
+ the estimate is rolled forward to "now" through the same nominal
14
+ physics model the event observer uses, replaying the controls
15
+ applied since capture (latency compensation, a Smith predictor).
16
+ Between frames the estimate keeps propagating at 1 kHz.
17
+ mode="zoh" disables both (naive hold, for the ablation).
18
+
19
+ Analogy: the frame stack is a goalkeeper watching a strobe light. Between
20
+ flashes he still knows physics, so he extrapolates the ball's path rather than
21
+ freezing on the last flash. The question is whether that beats someone who
22
+ sees a continuous trickle of motion (the event camera).
23
+ """
24
+ from collections import deque
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+ from .physics import PlantParams, rk4_step
31
+ from .event_camera import SceneRenderer
32
+ from .estimator import SpatialSoftmaxKeypoints, SCALE
33
+ from .conditions import NOMINAL
34
+
35
+ N_FRAMES = 3
36
+ READ_NOISE = 0.02 # std of per-pixel read noise (scene intensities are 0.15..0.9)
37
+
38
+
39
+ def blur_frame(history, exposure_ticks):
40
+ """Average the last `exposure_ticks` sharp 1 ms renders = shutter open over that window."""
41
+ return np.mean(np.stack(list(history)[-exposure_ticks:]), 0)
42
+
43
+
44
+ class BasicBlock(nn.Module):
45
+ def __init__(self, cin, cout, stride=1):
46
+ super().__init__()
47
+ self.c1 = nn.Conv2d(cin, cout, 3, stride, 1, bias=False)
48
+ self.b1 = nn.BatchNorm2d(cout)
49
+ self.c2 = nn.Conv2d(cout, cout, 3, 1, 1, bias=False)
50
+ self.b2 = nn.BatchNorm2d(cout)
51
+ self.skip = None if (stride == 1 and cin == cout) else nn.Sequential(
52
+ nn.Conv2d(cin, cout, 1, stride, bias=False), nn.BatchNorm2d(cout))
53
+
54
+ def forward(self, x):
55
+ y = F.relu(self.b1(self.c1(x)))
56
+ y = self.b2(self.c2(y))
57
+ return F.relu(y + (x if self.skip is None else self.skip(x)))
58
+
59
+
60
+ class FrameEstimator(nn.Module):
61
+ """frames (N, 3, H, W) newest-first, cond (N, 2) = [frame period/33ms, exposure/8ms] -> state (N, 4)."""
62
+
63
+ def __init__(self, H=32, W=96, K=12):
64
+ super().__init__()
65
+ yy, xx = torch.meshgrid(torch.linspace(-1, 1, H), torch.linspace(-1, 1, W), indexing="ij")
66
+ self.register_buffer("coords", torch.stack([xx, yy])[None])
67
+ self.trunk = nn.Sequential(
68
+ nn.Conv2d(N_FRAMES + 2, 16, 3, 1, 1, bias=False), nn.BatchNorm2d(16), nn.ReLU(),
69
+ BasicBlock(16, 16), BasicBlock(16, 32, 2), BasicBlock(32, 32),
70
+ BasicBlock(32, 48, 2), BasicBlock(48, 48, 2))
71
+ self.kp = SpatialSoftmaxKeypoints(1, H, W, K=K)
72
+ self.fc = nn.Linear(48 * (H // 8) * (W // 8), 128)
73
+ self.head = nn.Sequential(nn.Linear(128 + N_FRAMES * self.kp.out_dim + 2, 128), nn.GELU(),
74
+ nn.Linear(128, 64), nn.GELU(), nn.Linear(64, 4))
75
+ self.register_buffer("scale", SCALE.clone())
76
+
77
+ def forward(self, frames, cond):
78
+ n = frames.shape[0]
79
+ x = (frames - 0.5) / 0.3
80
+ g = F.gelu(self.fc(self.trunk(torch.cat([x, self.coords.expand(n, -1, -1, -1)], 1)).flatten(1)))
81
+ kps = self.kp(x.reshape(n * N_FRAMES, 1, *x.shape[2:])).view(n, -1)
82
+ return self.head(torch.cat([g, kps, cond], 1)) * self.scale
83
+
84
+
85
+ def cond_vector(fps, exposure):
86
+ return torch.tensor([[(1.0 / fps) / 0.0333, exposure / 0.008]], dtype=torch.float32)
87
+
88
+
89
+ class LearnedFramePipeline:
90
+ """Frame camera + FrameEstimator, with physics latency compensation (mode='physics') or naive hold ('zoh')."""
91
+ needs_true_plant = True # only used to SIMULATE the camera image of the real pole (V4 fix: V2/V3 imaged the
92
+ # nominal 0.25 m pole even when the true pole was longer, e.g. in 'ood_plant')
93
+
94
+ def __init__(self, model, fps, latency, exposure=0.004, mode="physics", l=0.25,
95
+ plant=None, seed=0, read_noise=READ_NOISE):
96
+ self.model = model.eval()
97
+ self.fps, self.latency, self.exposure, self.mode = fps, latency, exposure, mode
98
+ self.exp_ticks = max(1, int(round(exposure / 1e-3)))
99
+ self.l = l
100
+ self.p = plant or PlantParams() # nominal physics model, same as the event observer's
101
+ self.seed, self.read_noise = seed, read_noise
102
+ self.renderer = SceneRenderer()
103
+ self.scond = NOMINAL
104
+ tag = "" if mode == "physics" else "-zoh"
105
+ self.name = f"learned-frame-{fps}fps{tag}" + ("" if abs(exposure - 0.004) < 1e-9 else f"-exp{exposure*1e3:g}ms")
106
+
107
+ def set_conditions(self, cond):
108
+ """Same scene and sensor physics as the event camera (see conditions.py)."""
109
+ self.scond = cond
110
+ self.renderer = SceneRenderer(cond=cond)
111
+ base = self.base_exposure if hasattr(self, "base_exposure") else self.exposure
112
+ self.base_exposure = base
113
+ self.exposure = base if cond.is_nominal else cond.frame_exposure(base)
114
+ self.exp_ticks = max(1, int(round(self.exposure / 1e-3)))
115
+ self.noise = self.read_noise if cond.is_nominal else cond.frame_noise(self.exposure)
116
+
117
+ def reset(self, s0):
118
+ self.rng = np.random.default_rng(self.seed)
119
+ if not hasattr(self, "noise"):
120
+ self.noise = self.read_noise
121
+ self.t_now = 0.0
122
+ sharp = self.renderer.render(s0, self.p_true.l if getattr(self, "p_true", None) is not None else self.l, 0.0)
123
+ self.hist = deque([sharp] * 16, maxlen=16) # sharp 1 ms renders (for blur)
124
+ self.frames = deque(maxlen=N_FRAMES) # captured (blurred, noisy) frames
125
+ self.pending = deque() # (arrival_t, capture_t, frame stack)
126
+ self.u_hist = deque(maxlen=200) # controls applied per tick
127
+ self.s = np.array(s0, dtype=np.float64)
128
+ self.next_capture = 0.0
129
+ self.cond = cond_vector(self.fps, self.exposure) # model conditioning (fps, exposure)
130
+
131
+ def _capture(self):
132
+ img = blur_frame(self.hist, self.exp_ticks)
133
+ if not self.scond.is_nominal:
134
+ img = self.scond.frame_sensor(img)
135
+ img = img + self.rng.normal(0, self.noise, img.shape)
136
+ self.frames.appendleft(img.astype(np.float32))
137
+ while len(self.frames) < N_FRAMES: # very first capture: repeat it
138
+ self.frames.append(self.frames[-1])
139
+ return np.stack(self.frames)
140
+
141
+ @torch.inference_mode()
142
+ def update(self, k, t, s_true, surface, u):
143
+ self.u_hist.append(u)
144
+ if self.mode == "physics": # propagate current estimate across this tick
145
+ self.s = rk4_step(self.s, u, 1e-3, self.p)
146
+ # sensor simulation: the camera images the REAL pole (true length), not the nominal one
147
+ l_true = self.p_true.l if getattr(self, "p_true", None) is not None else self.l
148
+ self.hist.append(self.renderer.render(s_true, l_true, t))
149
+ if t + 1e-9 >= self.next_capture:
150
+ self.pending.append((t + self.latency, t, self._capture()))
151
+ self.next_capture += 1.0 / self.fps
152
+ while self.pending and self.pending[0][0] <= t + 1e-9:
153
+ _, t_cap, stack = self.pending.popleft()
154
+ s_cap = self.model(torch.from_numpy(stack)[None], self.cond)[0].double().numpy()
155
+ if self.mode == "physics": # roll forward from capture time to now
156
+ n = int(round((t - t_cap) / 1e-3))
157
+ us = list(self.u_hist)[-n:] if n > 0 else []
158
+ for uu in us:
159
+ s_cap = rk4_step(s_cap, uu, 1e-3, self.p)
160
+ self.s = s_cap
161
+
162
+ def estimate(self):
163
+ return self.s
aurasync/imu.py ADDED
@@ -0,0 +1,98 @@
1
+ """Proprioceptive baseline (Upgrade D): IMU on the pole + cart wheel encoder + EKF.
2
+
3
+ This is what a controls engineer would build first for a cart-pole, so any
4
+ camera-based system has to be judged against it. No camera at all, so lighting,
5
+ HDR, occlusion and event-sensor faults do not touch it; model mismatch (unseen
6
+ plant parameters, mechanical snap) does, through the EKF's physics model.
7
+
8
+ Sensors at 1 kHz, mounted at the pivot:
9
+ gyro theta_dot + bias + noise (sigma 0.005 rad/s, bias ~ N(0, 0.005 rad/s))
10
+ accelerometer specific force in the pole frame (sigma 0.05 m/s^2 per axis):
11
+ f_along = xdd*sin(th) + g*cos(th), f_perp = xdd*cos(th) - g*sin(th)
12
+ cart encoder x quantised to 0.1 mm
13
+ EKF state: [x, x_dot, theta, theta_dot, gyro_bias, cart_accel]; prediction through
14
+ the same nominal RK4 physics as every other pipeline. Cart acceleration is a
15
+ free random-walk state rather than a model prediction, so the two accelerometer
16
+ axes pin down tilt AND acceleration without trusting the plant model
17
+ (robust to unseen masses); Jacobians by finite differences.
18
+ The filter also carries a covariance, so it doubles as a filter-based
19
+ uncertainty baseline.
20
+ """
21
+ from collections import deque
22
+ import numpy as np
23
+
24
+ from .physics import PlantParams, rk4_step, deriv
25
+
26
+ DT = 1e-3
27
+ G = 9.81
28
+
29
+
30
+ class IMUPipeline:
31
+ name = "imu-ekf"
32
+ needs_true_plant = True
33
+
34
+ def __init__(self, delay_ticks=1, plant=None, gyro_sigma=0.005, acc_sigma=0.05,
35
+ enc_res=1e-4, bias_sigma=0.005):
36
+ self.p = plant or PlantParams()
37
+ self.delay_ticks = delay_ticks
38
+ self.gs, self.as_, self.res, self.bs = gyro_sigma, acc_sigma, enc_res, bias_sigma
39
+ self.Q = np.diag([1e-9, 1e-5, 1e-9, 1e-4, 1e-12, 25.0])
40
+ self.R = np.diag([enc_res ** 2 / 12 + 1e-12, gyro_sigma ** 2, acc_sigma ** 2, acc_sigma ** 2])
41
+
42
+ def reset(self, s0):
43
+ seed = int(abs(np.asarray(s0)).sum() * 1e7) % (2 ** 32)
44
+ self.rng = np.random.default_rng(seed)
45
+ self.bias = self.rng.normal(0, self.bs)
46
+ self.x = np.concatenate([np.asarray(s0, float), [0.0, 0.0]])
47
+ self.P = np.diag([1e-6, 1e-4, 1e-6, 1e-4, self.bs ** 2, 10.0])
48
+ self.buf = deque([np.asarray(s0, float)] * (self.delay_ticks + 1), maxlen=self.delay_ticks + 1)
49
+ self.cov_buf = deque([self.P[:4, :4].copy()] * (self.delay_ticks + 1), maxlen=self.delay_ticks + 1)
50
+
51
+ def _f(self, x, u):
52
+ return np.concatenate([rk4_step(x[:4], u, DT, self.p), x[4:]])
53
+
54
+ def _h(self, x, u, p=None):
55
+ """Sensor model. With p given (simulation of the TRUE sensor) the cart
56
+ acceleration comes from the true plant; inside the EKF it is the state x[5]."""
57
+ xdd = deriv(x[:4], u, p)[1] if p is not None else x[5]
58
+ th = x[2]
59
+ return np.array([x[0], x[3] + x[4],
60
+ xdd * np.sin(th) + G * np.cos(th),
61
+ xdd * np.cos(th) - G * np.sin(th)])
62
+
63
+ @staticmethod
64
+ def _jac(f, x, eps=1e-6):
65
+ f0 = f(x)
66
+ J = np.zeros((len(f0), len(x)))
67
+ for i in range(len(x)):
68
+ d = np.zeros(len(x)); d[i] = eps
69
+ J[:, i] = (f(x + d) - f(x - d)) / (2 * eps)
70
+ return J
71
+
72
+ def measure(self, s_true, u, p_true):
73
+ """Simulated sensor readings from the TRUE state and TRUE plant."""
74
+ z = self._h(np.concatenate([s_true, [self.bias, 0.0]]), u, p_true)
75
+ z[0] = np.round(s_true[0] / self.res) * self.res
76
+ z[1] += self.rng.normal(0, self.gs)
77
+ z[2:] += self.rng.normal(0, self.as_, 2)
78
+ return z
79
+
80
+ def update(self, k, t, s_true, surface, u):
81
+ z = self.measure(s_true, u, getattr(self, "p_true", None) or self.p)
82
+ F = self._jac(lambda x: self._f(x, u), self.x)
83
+ self.x = self._f(self.x, u)
84
+ self.P = F @ self.P @ F.T + self.Q
85
+ H = self._jac(lambda x: self._h(x, u), self.x)
86
+ y = z - self._h(self.x, u)
87
+ S = H @ self.P @ H.T + self.R
88
+ K = np.linalg.solve(S, H @ self.P).T
89
+ self.x = self.x + K @ y
90
+ I_KH = np.eye(6) - K @ H
91
+ self.P = I_KH @ self.P @ I_KH.T + K @ self.R @ K.T # Joseph form
92
+ self.buf.append(self.x[:4].copy()); self.cov_buf.append(self.P[:4, :4].copy())
93
+
94
+ def estimate(self):
95
+ return self.buf[0]
96
+
97
+ def covariance(self):
98
+ return self.cov_buf[0]
aurasync/monitor.py ADDED
@@ -0,0 +1,80 @@
1
+ """Event-consistency monitor: an independent check on the observer's estimate.
2
+
3
+ Analogy: a lifeguard told "the swimmer is by the red buoy" glances at where
4
+ the splashes actually are. If the splashes keep coming from somewhere else,
5
+ the report is wrong, however confident the person who gave it sounds.
6
+
7
+ Why it is needed: the learned variance head stays confident while the observer
8
+ diverges onto a wrong (even mirror-image) pole angle, because the divergence
9
+ happens on trajectories it never saw in training. This monitor makes no use of
10
+ the network at all; it checks the estimate against raw events:
11
+ * a moving edge fires events where the pole actually is
12
+ * inside a fan around the estimated pivot (where the pole could be), compare
13
+ the density of fresh events (< 2 ms old) close to the PREDICTED pole
14
+ (<= 2 px) with the density elsewhere in the fan (> 3 px away)
15
+ * densities, not counts, so uniform background noise cancels out
16
+ r = d_far / (d_near + d_far) ~0 consistent, ~0.5 uninformative, -> 1 contradicted
17
+ * ticks with fewer than `min_events` fresh events in the fan carry no
18
+ information (quiet scene) and leave the running score unchanged
19
+ * m = exponential moving average of r over informative ticks
20
+ The monitor turns into an uncertainty by inflating the observer's variance:
21
+ sigma_theta^2 <- sigma_theta^2 + (beta * max(0, m - m0))^2
22
+ with m0 and beta fitted on validation episodes only.
23
+ """
24
+ import numpy as np
25
+
26
+ from .event_camera import SceneRenderer
27
+
28
+ FRESH2 = float(np.exp(-2e-3 / 0.008)) # fast time-surface value of an event < 2 ms old
29
+
30
+
31
+ class EventConsistencyMonitor:
32
+ def __init__(self, l=0.25, fan_deg=60.0, near_px=2.0, far_px=3.0, min_events=6, ema=0.1,
33
+ m0=0.25, beta=0.5):
34
+ self.r = SceneRenderer()
35
+ self.l, self.fan = l, np.radians(fan_deg)
36
+ self.near_px, self.far_px, self.min_events, self.ema = near_px, far_px, min_events, ema
37
+ self.m0, self.beta = m0, beta
38
+ self.reset()
39
+
40
+ def reset(self):
41
+ self.m = self.m0
42
+ self.info_ticks = 0
43
+
44
+ def raw_score(self, s, surface):
45
+ """r for one tick, or None if the tick carries no information."""
46
+ S, wx, wy = self.r.S, self.r.wx, self.r.wy
47
+ x, th = float(s[0]), float(s[2])
48
+ px, py = x, 0.04
49
+ dx, dy = wx - px, wy - py
50
+ dist_piv = np.hypot(dx, dy)
51
+ ang = np.arctan2(dx, dy) # 0 = straight up, + = right
52
+ fan = (dist_piv <= 2 * self.l + 3 / S) & (dist_piv >= 0.06) & (np.abs(ang) <= self.fan)
53
+ tx, ty = px + 2 * self.l * np.sin(th), py + 2 * self.l * np.cos(th)
54
+ vx, vy = tx - px, ty - py
55
+ t = np.clip((dx * vx + dy * vy) / (vx * vx + vy * vy), 0, 1)
56
+ d_pole_px = np.hypot(wx - (px + t * vx), wy - (py + t * vy)) * S
57
+ near = fan & (d_pole_px <= self.near_px)
58
+ far = fan & (d_pole_px > self.far_px)
59
+ ev = (surface[0] >= FRESH2) | (surface[1] >= FRESH2)
60
+ n_near, n_far = int((ev & near).sum()), int((ev & far).sum())
61
+ if n_near + n_far < self.min_events or near.sum() == 0 or far.sum() == 0:
62
+ return None
63
+ d_near, d_far = n_near / near.sum(), n_far / far.sum()
64
+ return d_far / (d_near + d_far + 1e-9)
65
+
66
+ def update(self, s, surface):
67
+ r = self.raw_score(s, surface)
68
+ if r is not None:
69
+ self.m += self.ema * (r - self.m)
70
+ self.info_ticks += 1
71
+ return self.m
72
+
73
+ def inflate(self, sigma_theta):
74
+ return np.sqrt(sigma_theta ** 2 + (self.beta * max(0.0, self.m - self.m0)) ** 2)
75
+
76
+
77
+ def monitor_trajectory(est, surf, l=0.25, **kw):
78
+ """Run the monitor over a recorded trajectory. est: (T, 4); surf: (T, 4, H, W). Returns m_t."""
79
+ mon = EventConsistencyMonitor(l=l, **kw)
80
+ return np.array([mon.update(est[k], surf[k]) for k in range(len(est))])
aurasync/physics.py ADDED
@@ -0,0 +1,62 @@
1
+ """Kinetic plant: an inverted pendulum on a force-driven cart.
2
+
3
+ This is the "unstable kinetic system" AuraSync must protect. The pole is short
4
+ (0.5 m), so it falls fast: its natural time constant sqrt(l/g) is ~160 ms, which
5
+ means a 25-40 ms perception delay eats a large share of the recovery window.
6
+
7
+ State s = [x, x_dot, theta, theta_dot]; theta = 0 is upright, positive tilts right.
8
+ """
9
+ from dataclasses import dataclass, replace
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class PlantParams:
15
+ M: float = 1.0 # cart mass [kg]
16
+ m: float = 0.1 # pole mass [kg]
17
+ l: float = 0.25 # pole half-length [m] (full pole = 0.5 m)
18
+ g: float = 9.81
19
+ b_cart: float = 0.1 # cart viscous friction
20
+ b_pole: float = 0.002 # pivot friction
21
+ u_max: float = 20.0 # actuator force limit [N]
22
+ x_limit: float = 1.0 # track half-length [m]
23
+
24
+ def perturbed(self, rng, frac=0.3):
25
+ """Domain randomisation: scale masses / length by up to +-frac."""
26
+ s = lambda: 1.0 + rng.uniform(-frac, frac)
27
+ return replace(self, M=self.M * s(), m=self.m * s(), l=self.l * (1 + rng.uniform(-0.1, 0.1)))
28
+
29
+
30
+ def deriv(s, u, p: PlantParams):
31
+ x, xd, th, thd = s
32
+ sin, cos = np.sin(th), np.cos(th)
33
+ total = p.M + p.m
34
+ temp = (u + p.m * p.l * thd ** 2 * sin - p.b_cart * xd) / total
35
+ thdd = (p.g * sin - cos * temp - p.b_pole * thd / (p.m * p.l)) / (
36
+ p.l * (4.0 / 3.0 - p.m * cos ** 2 / total))
37
+ xdd = temp - p.m * p.l * thdd * cos / total
38
+ return np.array([xd, xdd, thd, thdd])
39
+
40
+
41
+ def rk4_step(s, u, dt, p: PlantParams):
42
+ k1 = deriv(s, u, p)
43
+ k2 = deriv(s + 0.5 * dt * k1, u, p)
44
+ k3 = deriv(s + 0.5 * dt * k2, u, p)
45
+ k4 = deriv(s + dt * k3, u, p)
46
+ return s + dt / 6.0 * (k1 + 2 * k2 + 2 * k3 + k4)
47
+
48
+
49
+ def failed(s, p: PlantParams, max_angle=0.8):
50
+ """Kinetic failure = pole past ~46 degrees or cart off the track."""
51
+ return abs(s[2]) > max_angle or abs(s[0]) > p.x_limit
52
+
53
+
54
+ def linearise(p: PlantParams, dt, eps=1e-6):
55
+ """Discrete-time (A, B) about the upright equilibrium via finite differences."""
56
+ s0 = np.zeros(4)
57
+ A = np.zeros((4, 4))
58
+ for i in range(4):
59
+ d = np.zeros(4); d[i] = eps
60
+ A[:, i] = (rk4_step(s0 + d, 0.0, dt, p) - rk4_step(s0 - d, 0.0, dt, p)) / (2 * eps)
61
+ B = ((rk4_step(s0, eps, dt, p) - rk4_step(s0, -eps, dt, p)) / (2 * eps)).reshape(4, 1)
62
+ return A, B
aurasync/safety.py ADDED
@@ -0,0 +1,173 @@
1
+ """Uncertainty- and delay-aware safety shield (Upgrade B).
2
+
3
+ Analogy: a driving instructor with a second brake pedal. The student (LQR)
4
+ steers; the instructor only intervenes when the car is heading for the kerb
5
+ faster than it could stop, and leans in earlier when visibility is poor
6
+ (high estimate uncertainty) or when the student's view is out of date (delay).
7
+
8
+ Formulation (discrete-time control barrier function, one input, closed form):
9
+ Look-ahead coordinates (relative degree 1 in the force u):
10
+ psi = theta + c_th * theta_dot chi = x + x_dot |x_dot| / (2 a_brake)
11
+ Safe set S = { |psi| <= psi_max and |chi| <= chi_max }. If |psi| <= psi_max
12
+ is kept forever, theta converges into the same band (first-order filter), so
13
+ this bounds the pole angle itself.
14
+ At each 1 ms tick, with the estimate first rolled forward over its age
15
+ (delay compensation, replaying the controls already applied), the safe set is
16
+ tightened by the estimate's uncertainty and the discrete CBF condition is
17
+ imposed on the tightened barriers:
18
+ h~ = (psi_max - kappa * sigma_psi) -/+ psi, h~(s_{k+1}) >= (1 - gamma) h~(s_k)
19
+ (same for chi). sigma_psi is the barrier's standard deviation from the
20
+ estimator covariance propagated one step through the linearised physics, so
21
+ a vaguer or older estimate keeps the pole further from the edge (floor: 25%
22
+ of the nominal set). Dynamics are affine in u, so each constraint is an interval on u;
23
+ the QP min (u - u_lqr)^2 becomes a clip onto the intersection with the
24
+ actuator limits.
25
+ Priorities and fallback (hard limits before soft targets):
26
+ HARD track end |chi| <= chi_max, chi = x + x_dot|x_dot| / (2 a_brake): position plus the
27
+ distance needed to stop at a_brake = 12 m/s^2 (actuator limit ~18 m/s^2)
28
+ must stay inside 0.95 m (track 1.0 m) -> leaving it is a failure
29
+ HARD pole |psi| <= psi_hard (35 deg, falling is 46 deg)
30
+ SOFT envelope |psi| <= psi_max (10 deg, the safety target), pursued only while
31
+ |chi| <= chi_soft (0.6 m): the last part of the track is a reserve the
32
+ soft target may never spend, so chasing it cannot strand the cart;
33
+ and only from states with |psi| <= 2 psi_max (reachability gate, latched
34
+ until settled: |psi| <= psi_max, |x_dot| < 0.3 m/s, |theta_dot| < 0.5 rad/s).
35
+ After a hit too big to contain, chasing the target mid-recovery fights
36
+ the nominal controller and walks the cart off the track
37
+ 1. hard + soft + actuator feasible -> project u_lqr
38
+ 2. else drop the soft envelope -> project onto hard + actuator (logged 'soft_dropped')
39
+ 3. else infeasible -> do no harm: pass the nominal command through (logged).
40
+ An earlier variant that slammed the actuator to the
41
+ most-violated barrier lost runs plain LQR survives.
42
+ (v3 first shipped with the soft envelope ranked above the track limit; on test trials
43
+ that drove the cart off the track in 21% of oracle runs. Fixed before reporting.)
44
+ Stale estimate (sigma_theta > stale_deg) -> the nominal command comes from a
45
+ conservative low-gain LQR before shielding (logged).
46
+ """
47
+ import time
48
+ from collections import deque
49
+ import numpy as np
50
+
51
+ from .physics import PlantParams, rk4_step, linearise
52
+ from .controller import LQRController
53
+
54
+ DT = 1e-3
55
+
56
+
57
+ class SafetyShield:
58
+ def __init__(self, plant=None, psi_max_deg=10.0, c_th=0.10, chi_max=0.95, a_brake=12.0,
59
+ gamma=0.03, kappa=2.0, stale_deg=4.0, Q_proc=None, psi_hard_deg=35.0, chi_soft=0.6,
60
+ gamma_hard=0.03, soft_gate=2.0):
61
+ self.p = plant or PlantParams()
62
+ self.psi_max = np.radians(psi_max_deg)
63
+ self.psi_hard = np.radians(psi_hard_deg)
64
+ self.c_th, self.chi_max, self.a_brake, self.chi_soft = c_th, chi_max, a_brake, chi_soft
65
+ self.gamma, self.kappa, self.gamma_hard, self.soft_gate = gamma, kappa, gamma_hard, soft_gate
66
+ self.soft_off = False
67
+ self.stale = np.radians(stale_deg)
68
+ self.A, _ = linearise(self.p, DT)
69
+ self.Q = np.diag([1e-9, 1e-5, 1e-8, 1e-4]) if Q_proc is None else Q_proc
70
+ self.psi = lambda z: z[2] + c_th * z[3]
71
+ self.chi = lambda z: z[0] + z[1] * abs(z[1]) / (2 * a_brake)
72
+ self.fallback_ctrl = LQRController(self.p, Q=(10.0, 1.0, 100.0, 2.0), R=0.1)
73
+ self.u_max = self.p.u_max
74
+
75
+ @staticmethod
76
+ def _interval(a0, a1, lo, hi):
77
+ """{u : lo <= a0 + a1 u <= hi} as (u_lo, u_hi)."""
78
+ if abs(a1) < 1e-12:
79
+ return (-np.inf, np.inf) if lo <= a0 <= hi else (np.inf, -np.inf)
80
+ u1, u2 = (lo - a0) / a1, (hi - a0) / a1
81
+ return (min(u1, u2), max(u1, u2))
82
+
83
+ def _bounds(self, s, Sigma, fn, h_max, gamma=None):
84
+ """Interval on u for the pair of barriers h_max -/+ fn(s_next) (fn affine in u over one tick)."""
85
+ n0 = rk4_step(s, 0.0, DT, self.p)
86
+ n1 = rk4_step(s, 1.0, DT, self.p)
87
+ a0, a1, cur = fn(n0), fn(n1) - fn(n0), fn(s)
88
+ g = np.array([(fn(n0 + e) - fn(n0 - e)) / 2e-6 for e in np.eye(4) * 1e-6]) # barrier gradient
89
+ S1 = self.A @ Sigma @ self.A.T + self.Q
90
+ m = self.kappa * np.sqrt(max(g @ S1 @ g, 0.0))
91
+ hm = max(h_max - m, 0.25 * h_max) # robust tightened safe set |g.s| <= h_max - m
92
+ gm = self.gamma if gamma is None else gamma
93
+ hi = hm - (1 - gm) * (hm - cur)
94
+ lo = -hm + (1 - gm) * (hm + cur)
95
+ return self._interval(a0, a1, lo, hi), (a0, a1, lo, hi)
96
+
97
+ def filter(self, s, Sigma, u_nom):
98
+ """Returns (u_safe, mode). mode: 'pass', 'modified', 'soft_dropped', 'infeasible'."""
99
+ (sl, sh), _ = self._bounds(s, Sigma, self.psi, self.psi_max) # soft
100
+ (pl, ph), pk = self._bounds(s, Sigma, self.psi, self.psi_hard, self.gamma_hard) # hard
101
+ (cl, ch), ck = self._bounds(s, Sigma, self.chi, self.chi_max, self.gamma_hard) # hard
102
+ (rl, rh), _ = self._bounds(s, Sigma, self.chi, self.chi_soft) # track reserve for soft stage
103
+ ul, uh = -self.u_max, self.u_max
104
+ L, H = max(sl, rl, pl, cl, ul), min(sh, rh, ph, ch, uh)
105
+ a = abs(self.psi(s))
106
+ if a > self.soft_gate * self.psi_max: # soft target out of reach: stop chasing it ...
107
+ self.soft_off = True
108
+ elif a <= self.psi_max and abs(s[1]) < 0.3 and abs(s[3]) < 0.5: # ... until settled again (latch)
109
+ self.soft_off = False
110
+ if self.soft_off:
111
+ L, H = 1.0, -1.0
112
+ if L <= H:
113
+ u = float(np.clip(u_nom, L, H))
114
+ return u, ("pass" if abs(u - u_nom) < 1e-9 else "modified")
115
+ L, H = max(pl, cl, ul), min(ph, ch, uh)
116
+ if L <= H:
117
+ return float(np.clip(u_nom, L, H)), "soft_dropped"
118
+ # hard limits conflict: no command can be certified -> do no harm, defer to the nominal controller
119
+ return float(np.clip(u_nom, ul, uh)), "infeasible"
120
+
121
+
122
+ class ShieldedController:
123
+ """LQR -> (stale check) -> delay compensation -> SafetyShield.
124
+
125
+ Reads the pipeline's covariance (`covariance()`) and estimate age
126
+ (`age_ticks()`) when it offers them; otherwise uses `sigma_const` and the
127
+ pipeline's fixed delay."""
128
+
129
+ def __init__(self, lqr, shield, pipeline, sigma_const=None, enabled=True):
130
+ self.lqr, self.shield, self.pipe = lqr, shield, pipeline
131
+ self.sigma_const = np.diag(np.square(sigma_const)) if sigma_const is not None else np.zeros((4, 4))
132
+ self.enabled = enabled
133
+ self.reset()
134
+
135
+ def reset(self):
136
+ self.u_hist = deque(maxlen=64)
137
+ self.stats = dict(pass_=0, modified=0, soft_dropped=0, infeasible=0, stale=0, ticks=0)
138
+ self.times = []
139
+
140
+ def _age(self):
141
+ if hasattr(self.pipe, "age_ticks"):
142
+ return int(self.pipe.age_ticks())
143
+ return int(getattr(self.pipe, "delay_ticks", 0))
144
+
145
+ def _cov(self):
146
+ if hasattr(self.pipe, "covariance"):
147
+ return np.asarray(self.pipe.covariance())
148
+ return self.sigma_const
149
+
150
+ def act(self, est):
151
+ t0 = time.perf_counter()
152
+ s = np.asarray(est, float)
153
+ Sigma = self._cov()
154
+ age = self._age()
155
+ us = list(self.u_hist)[-age:] if age > 0 else []
156
+ for u in us: # delay compensation
157
+ s = rk4_step(s, u, DT, self.shield.p)
158
+ Sigma = self.shield.A @ Sigma @ self.shield.A.T + self.shield.Q
159
+ self.stats["ticks"] += 1
160
+ stale = np.sqrt(max(Sigma[2, 2], 0)) > self.shield.stale
161
+ if stale:
162
+ self.stats["stale"] += 1
163
+ u_nom = self.shield.fallback_ctrl.act(s)
164
+ else:
165
+ u_nom = self.lqr.act(s)
166
+ if self.enabled:
167
+ u, mode = self.shield.filter(s, Sigma, u_nom)
168
+ self.stats["pass_" if mode == "pass" else mode] += 1
169
+ else:
170
+ u = u_nom
171
+ self.u_hist.append(u)
172
+ self.times.append(time.perf_counter() - t0)
173
+ return u