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.
- aurasync/__init__.py +0 -0
- aurasync/conditions.py +84 -0
- aurasync/controller.py +36 -0
- aurasync/estimator.py +251 -0
- aurasync/event_camera.py +196 -0
- aurasync/frame_camera.py +163 -0
- aurasync/imu.py +98 -0
- aurasync/monitor.py +80 -0
- aurasync/physics.py +62 -0
- aurasync/safety.py +173 -0
- aurasync/scheduler.py +191 -0
- aurasync/simulate.py +168 -0
- aurasync/v4/__init__.py +0 -0
- aurasync/v4/geo_pipeline.py +61 -0
- aurasync/v4/geometry.py +97 -0
- aurasync/v4/pipeline.py +274 -0
- aurasync/v41/__init__.py +0 -0
- aurasync/v41/pipeline.py +269 -0
- aurasync_engine-0.4.1.dist-info/METADATA +132 -0
- aurasync_engine-0.4.1.dist-info/RECORD +23 -0
- aurasync_engine-0.4.1.dist-info/WHEEL +5 -0
- aurasync_engine-0.4.1.dist-info/licenses/LICENSE +21 -0
- aurasync_engine-0.4.1.dist-info/top_level.txt +1 -0
aurasync/frame_camera.py
ADDED
|
@@ -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
|