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/v4/pipeline.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""AuraSync V4: Detect -> Correct -> Recover.
|
|
2
|
+
|
|
3
|
+
Analogy: a ship's navigator (the neural observer) dead-reckons and reads the
|
|
4
|
+
instruments; a lookout (EventGeometry) shouts bearings to the mast whenever he can
|
|
5
|
+
see it. Small disagreements are averaged in. A large, *repeated*, *self-consistent*
|
|
6
|
+
disagreement means the navigator has lost the plot, so his position is reset to the
|
|
7
|
+
lookout's bearing. If the lookout is unsure (fog, spray), nobody resets anything;
|
|
8
|
+
the captain is simply told the position is uncertain.
|
|
9
|
+
|
|
10
|
+
Module A ConsistencyDetector per-tick anomaly score (CUSUM of the geometric
|
|
11
|
+
innovation), alarm + timestamp, reliability, reasons
|
|
12
|
+
Module B V4Pipeline correction gated Kalman fusion (theta, x) / confirmed
|
|
13
|
+
re-initialisation / uncertainty inflation
|
|
14
|
+
Module C covariance after every step is the posterior of the fusion, and is
|
|
15
|
+
inflated when the evidence contradicts it without
|
|
16
|
+
allowing a correction
|
|
17
|
+
No ground truth is used: inputs are the sensor surface, the controls and the
|
|
18
|
+
observer's own estimate.
|
|
19
|
+
"""
|
|
20
|
+
from collections import deque
|
|
21
|
+
import numpy as np
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
from ..scheduler import TriggeredEventPipeline, FRESH
|
|
25
|
+
from .geometry import EventGeometry
|
|
26
|
+
|
|
27
|
+
DT = 1e-3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConsistencyDetector:
|
|
31
|
+
"""Per-tick consistency check between the estimate and independent event geometry."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cusum_k=4.0, cusum_h=40.0, sign_deg=3.0, stale_ticks=30, motion_speed=0.3,
|
|
34
|
+
burst_factor=4.0, l=0.25):
|
|
35
|
+
self.k, self.h = cusum_k, cusum_h
|
|
36
|
+
self.sign = np.radians(sign_deg)
|
|
37
|
+
self.stale_ticks, self.motion_speed, self.burst_factor, self.l = stale_ticks, motion_speed, burst_factor, l
|
|
38
|
+
self.reset()
|
|
39
|
+
|
|
40
|
+
def reset(self):
|
|
41
|
+
self.g = 0.0
|
|
42
|
+
self.alarm = False
|
|
43
|
+
self.t_detect = None
|
|
44
|
+
self.last_valid = 0
|
|
45
|
+
self.rel = 0.0
|
|
46
|
+
self.ev_baseline = None
|
|
47
|
+
self.reasons = []
|
|
48
|
+
|
|
49
|
+
def update(self, k, s_hat, Sigma, m, n_fresh):
|
|
50
|
+
reasons = []
|
|
51
|
+
d2 = None
|
|
52
|
+
# running estimate of the normal fresh-event count (for burst detection)
|
|
53
|
+
self.ev_baseline = n_fresh if self.ev_baseline is None else 0.99 * self.ev_baseline + 0.01 * n_fresh
|
|
54
|
+
burst = n_fresh > self.burst_factor * max(self.ev_baseline, 5.0)
|
|
55
|
+
if m is not None and m["valid"]:
|
|
56
|
+
nu = m["theta"] - s_hat[2]
|
|
57
|
+
d2 = nu ** 2 / (Sigma[2, 2] + m["sigma_theta"] ** 2)
|
|
58
|
+
# CUSUM on the normalised innovation; each tick adds at most 16 - k (= 12), so a single
|
|
59
|
+
# outlier can never raise the alarm alone (>= 4 consecutive strong disagreements needed)
|
|
60
|
+
self.g = max(0.0, self.g + min(d2, 16.0) - self.k)
|
|
61
|
+
self.last_valid = k
|
|
62
|
+
self.rel = 0.9 * self.rel + 0.1 * m["quality"]
|
|
63
|
+
if d2 > 9:
|
|
64
|
+
reasons.append("angle_mismatch")
|
|
65
|
+
if np.sign(m["theta"]) != np.sign(s_hat[2]) and min(abs(m["theta"]), abs(s_hat[2])) > self.sign:
|
|
66
|
+
reasons.append("sign_mismatch")
|
|
67
|
+
else:
|
|
68
|
+
self.rel *= 0.98
|
|
69
|
+
v_top = s_hat[1] + 2 * self.l * s_hat[3] * np.cos(s_hat[2])
|
|
70
|
+
if abs(v_top) > self.motion_speed and n_fresh < 5:
|
|
71
|
+
reasons.append("missing_events") # estimate says motion, sensor sees none
|
|
72
|
+
if k - self.last_valid > self.stale_ticks:
|
|
73
|
+
reasons.append("stale_evidence")
|
|
74
|
+
if burst:
|
|
75
|
+
reasons.append("noise_burst")
|
|
76
|
+
self.alarm = self.g > self.h
|
|
77
|
+
if self.alarm and self.t_detect is None:
|
|
78
|
+
self.t_detect = k
|
|
79
|
+
if not self.alarm and self.g == 0.0:
|
|
80
|
+
self.t_detect = None
|
|
81
|
+
self.reasons = reasons
|
|
82
|
+
return dict(score=self.g / self.h, alarm=self.alarm, t_detect=self.t_detect, reliability=self.rel,
|
|
83
|
+
reasons=reasons, d2=d2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class V4Pipeline(TriggeredEventPipeline):
|
|
87
|
+
"""V3 observer + event geometry + detector + gated correction.
|
|
88
|
+
|
|
89
|
+
correct apply gated fusion and confirmed re-initialisation (False -> detection only)
|
|
90
|
+
inflate inflate the covariance when the detector alarms without a usable correction
|
|
91
|
+
hidden_reset 'zero' | 'keep': what happens to the observer's SSM memory on re-initialisation
|
|
92
|
+
fuse_x also fuse the event-measured pivot position
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, models, mode="triggered", correct=True, inflate=True, hidden_reset="zero", fuse_x=True,
|
|
96
|
+
gate=9.0, confirm_n=4, confirm_window=12, confirm_spread_deg=3.0, cooldown=40,
|
|
97
|
+
inflate_sigma_deg=10.0, sigma_x_floor=0.004, neural_fusion="overwrite", min_gap=0,
|
|
98
|
+
residual_trigger=None, name=None, **kw):
|
|
99
|
+
super().__init__(models, mode=mode, **kw)
|
|
100
|
+
self.min_gap = int(min_gap) # Module E: hard compute budget (>= min_gap ticks between corrections)
|
|
101
|
+
# Module E: also trigger on the event-consistency residual (default: only when a budget is set,
|
|
102
|
+
# so the unbudgeted V4 is exactly the design selected on validation)
|
|
103
|
+
self.residual_trigger = (min_gap > 0) if residual_trigger is None else residual_trigger
|
|
104
|
+
self._last_d2 = None
|
|
105
|
+
self.neural_fusion = neural_fusion # 'overwrite' (V3 semantics) | 'kalman' (full-state measurement)
|
|
106
|
+
# | 'kalman_pos' (measurement of x and theta only; velocities come
|
|
107
|
+
# from physics + the measurement stream via the cross-covariance)
|
|
108
|
+
self.geo = EventGeometry()
|
|
109
|
+
self.det = ConsistencyDetector()
|
|
110
|
+
self.correct_on, self.inflate_on, self.hidden_reset, self.fuse_x = correct, inflate, hidden_reset, fuse_x
|
|
111
|
+
self.gate, self.confirm_n, self.confirm_window = gate, confirm_n, confirm_window
|
|
112
|
+
self.confirm_spread = np.radians(confirm_spread_deg)
|
|
113
|
+
self.cooldown, self.inflate_sigma, self.sigma_x_floor = cooldown, np.radians(inflate_sigma_deg), sigma_x_floor
|
|
114
|
+
tag = ("" if correct else "-nocorr") + ("" if inflate else "-noinfl") + ("" if hidden_reset == "zero" else "-keeph")
|
|
115
|
+
self.name = name or f"v4-{mode}{tag}"
|
|
116
|
+
|
|
117
|
+
def reset(self, s0):
|
|
118
|
+
super().reset(s0)
|
|
119
|
+
self.geo.rng = np.random.default_rng(self.seed + 1)
|
|
120
|
+
self.det.reset()
|
|
121
|
+
self.buf = deque(maxlen=32) # (k, theta_meas, sigma, x_pivot) of recent valid measurements
|
|
122
|
+
self.last_reinit = -10 ** 9
|
|
123
|
+
self.cap_state = None # state at the capture of the in-flight neural job
|
|
124
|
+
self.ev_log = [] # correction / reinit / inflation events (diagnostics)
|
|
125
|
+
self.det_log = []
|
|
126
|
+
|
|
127
|
+
# ---- neural job bookkeeping: apply a finished correction as a DELTA ---------------------
|
|
128
|
+
def _trigger(self, surface):
|
|
129
|
+
"""V3 triggers (burst, doubt, safety margin, max gap) + the consistency residual,
|
|
130
|
+
all subject to a hard budget of one correction per `min_gap` ticks."""
|
|
131
|
+
if self.min_gap and self.k - self.last_capture < self.min_gap:
|
|
132
|
+
return None
|
|
133
|
+
why = super()._trigger(surface)
|
|
134
|
+
if why is None and self.residual_trigger and self._last_d2 is not None and self._last_d2 > self.gate:
|
|
135
|
+
self.log["triggers"]["residual"] = self.log["triggers"].get("residual", 0)
|
|
136
|
+
why = "residual"
|
|
137
|
+
return why
|
|
138
|
+
|
|
139
|
+
def _correct(self, surface, s_pred, gap):
|
|
140
|
+
out = super()._correct(surface, s_pred, gap)
|
|
141
|
+
self.cap_state = np.array(s_pred, float)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def _finish_job(self, k):
|
|
145
|
+
"""Like V3, but the neural correction is applied as a delta on top of the current
|
|
146
|
+
(possibly event-corrected) state; identical to V3 when no event correction happened."""
|
|
147
|
+
cap_k, done_k, s_c, S_c, h_new = self.job
|
|
148
|
+
if done_k - cap_k > self.deadline and k >= cap_k + self.deadline:
|
|
149
|
+
self.log["misses"] += 1; self.job = None
|
|
150
|
+
return False
|
|
151
|
+
if k < done_k:
|
|
152
|
+
self.log["busy_ticks"] += 1
|
|
153
|
+
return False
|
|
154
|
+
s_base = self.cap_state.copy()
|
|
155
|
+
for uu in list(self.u_hist)[-(k - cap_k):] if k > cap_k else []:
|
|
156
|
+
s_c, S_c = self._propagate(s_c, S_c, uu)
|
|
157
|
+
s_base, _ = self._propagate(s_base, S_c, uu)
|
|
158
|
+
if self.neural_fusion.startswith("kalman"):
|
|
159
|
+
self._fuse_neural(s_c, S_c)
|
|
160
|
+
self.h = h_new
|
|
161
|
+
else:
|
|
162
|
+
self.s = self.s + (s_c - s_base)
|
|
163
|
+
self.Sigma, self.h = S_c, h_new
|
|
164
|
+
self.job = None
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def _fuse_neural(self, s_c, S_c):
|
|
168
|
+
"""Treat the neural observer's output as a measurement with its calibrated (diagonal)
|
|
169
|
+
covariance, weighted against the current fused estimate. 'kalman_pos' uses only the
|
|
170
|
+
position components: the network's velocity outputs were found (validation) to be
|
|
171
|
+
confidently wrong after large hits."""
|
|
172
|
+
idx = [0, 2] if self.neural_fusion == "kalman_pos" else [0, 1, 2, 3]
|
|
173
|
+
H = np.eye(4)[idx]
|
|
174
|
+
R = np.diag(np.clip(np.diag(S_c)[idx], 1e-12, None))
|
|
175
|
+
S = H @ self.Sigma @ H.T + R
|
|
176
|
+
K = np.linalg.solve(S, H @ self.Sigma).T
|
|
177
|
+
self.s = self.s + K @ (s_c[idx] - self.s[idx])
|
|
178
|
+
I_KH = np.eye(4) - K @ H
|
|
179
|
+
self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
|
|
180
|
+
|
|
181
|
+
def update(self, k, t, s_true, surface, u):
|
|
182
|
+
self.k = k
|
|
183
|
+
self.u_hist.append(u)
|
|
184
|
+
corrected = False
|
|
185
|
+
if self.mode == "dense":
|
|
186
|
+
s_pred, S_pred = self._propagate(self.s, self.Sigma, u)
|
|
187
|
+
s_c, S_c, h_new = self._correct(surface, s_pred, 1)
|
|
188
|
+
if self.neural_fusion.startswith("kalman"):
|
|
189
|
+
self.s, self.Sigma = s_pred, S_pred
|
|
190
|
+
self._fuse_neural(s_c, S_c); self.h = h_new
|
|
191
|
+
else:
|
|
192
|
+
self.s, self.Sigma, self.h = s_c, S_c, h_new
|
|
193
|
+
self.log["calls"] += 1
|
|
194
|
+
corrected = True
|
|
195
|
+
else:
|
|
196
|
+
self.s, self.Sigma = self._propagate(self.s, self.Sigma, u)
|
|
197
|
+
if self.job is not None:
|
|
198
|
+
corrected = self._finish_job(k)
|
|
199
|
+
if self.job is None:
|
|
200
|
+
why = self._trigger(surface)
|
|
201
|
+
if why is not None:
|
|
202
|
+
gap = max(1, k - self.last_capture)
|
|
203
|
+
s_c, S_c, h_new = self._correct(surface, self.s, gap)
|
|
204
|
+
lat_ticks = int(np.ceil(self.rng.choice(self.lat) / DT))
|
|
205
|
+
self.job = (k, k + lat_ticks, s_c, S_c, h_new)
|
|
206
|
+
self.last_capture = k
|
|
207
|
+
self.log["calls"] += 1; self.log["triggers"][why] += 1
|
|
208
|
+
# ---- Module A: independent event geometry + consistency detection ------------------
|
|
209
|
+
m = self.geo.measure(surface, self.s[0])
|
|
210
|
+
n_fresh = int(((surface[0] >= FRESH) | (surface[1] >= FRESH)).sum())
|
|
211
|
+
d = self.det.update(k, self.s, self.Sigma, m, n_fresh)
|
|
212
|
+
self._last_d2 = d["d2"]
|
|
213
|
+
if m is not None and m["valid"] and "noise_burst" not in d["reasons"]:
|
|
214
|
+
self.buf.append((k, m["theta"], m["sigma_theta"], m["x_pivot"]))
|
|
215
|
+
action = None
|
|
216
|
+
# ---- Module B: correction ------------------------------------------------------------
|
|
217
|
+
if self.correct_on and m is not None and m["valid"] and "noise_burst" not in d["reasons"]:
|
|
218
|
+
if d["d2"] is not None and d["d2"] <= self.gate:
|
|
219
|
+
self._fuse(m); action = "fuse"
|
|
220
|
+
elif d["alarm"] and k - self.last_reinit >= self.cooldown and self._confirmed(k):
|
|
221
|
+
self._reinit(k); action = "reinit"
|
|
222
|
+
# ---- Module C: inflate when contradicted but not correctable --------------------------
|
|
223
|
+
if action is None and self.inflate_on and d["alarm"]:
|
|
224
|
+
if self.Sigma[2, 2] < self.inflate_sigma ** 2:
|
|
225
|
+
self.Sigma[2, 2] = self.inflate_sigma ** 2
|
|
226
|
+
self.Sigma[3, 3] = max(self.Sigma[3, 3], 4.0)
|
|
227
|
+
action = "inflate"
|
|
228
|
+
if action in ("reinit", "inflate"):
|
|
229
|
+
self.ev_log.append(dict(k=k, action=action, reasons=list(d["reasons"]), score=d["score"]))
|
|
230
|
+
self.det_log.append((d["score"], d["alarm"], action == "fuse", action == "reinit", m is not None and m["valid"]))
|
|
231
|
+
self.out.append((self.s.copy(), self.Sigma.copy()))
|
|
232
|
+
self.log["sigma_th"].append(np.sqrt(self.Sigma[2, 2])); self.log["corrected"].append(corrected)
|
|
233
|
+
|
|
234
|
+
def _fuse(self, m):
|
|
235
|
+
"""Kalman update with the event measurement of theta (and the pivot x)."""
|
|
236
|
+
H = [np.array([0, 0, 1.0, 0])]; z = [m["theta"]]; R = [m["sigma_theta"] ** 2]
|
|
237
|
+
if self.fuse_x:
|
|
238
|
+
H.append(np.array([1.0, 0, 0, 0])); z.append(m["x_pivot"])
|
|
239
|
+
R.append(max(self.sigma_x_floor, 0.5 / self.geo.S / np.sqrt(m["n_inliers"])) ** 2)
|
|
240
|
+
H, z, R = np.stack(H), np.array(z), np.diag(R)
|
|
241
|
+
S = H @ self.Sigma @ H.T + R
|
|
242
|
+
K = np.linalg.solve(S, H @ self.Sigma).T
|
|
243
|
+
self.s = self.s + K @ (z - H @ self.s)
|
|
244
|
+
I_KH = np.eye(4) - K @ H
|
|
245
|
+
self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
|
|
246
|
+
|
|
247
|
+
def _confirmed(self, k):
|
|
248
|
+
"""Re-initialise only on several recent, mutually consistent measurements."""
|
|
249
|
+
recent = [b for b in self.buf if k - b[0] <= self.confirm_window]
|
|
250
|
+
if len(recent) < self.confirm_n:
|
|
251
|
+
return False
|
|
252
|
+
th = np.array([b[1] for b in recent])
|
|
253
|
+
return float(np.abs(th - np.median(th)).max()) <= self.confirm_spread
|
|
254
|
+
|
|
255
|
+
def _reinit(self, k):
|
|
256
|
+
recent = [b for b in self.buf if k - b[0] <= self.confirm_window]
|
|
257
|
+
ks = np.array([b[0] for b in recent], float); th = np.array([b[1] for b in recent])
|
|
258
|
+
before = self.s.copy()
|
|
259
|
+
theta = float(np.median(th))
|
|
260
|
+
if ks.max() - ks.min() >= 6: # rate from the measurement history
|
|
261
|
+
slope = float(np.polyfit((ks - ks.max()) * DT, th, 1)[0])
|
|
262
|
+
else:
|
|
263
|
+
slope = self.s[3]
|
|
264
|
+
self.s[2], self.s[3] = theta, slope
|
|
265
|
+
if self.fuse_x:
|
|
266
|
+
self.s[0] = float(np.median([b[3] for b in recent]))
|
|
267
|
+
sig_m = float(np.median([b[2] for b in recent]))
|
|
268
|
+
self.Sigma = np.diag([max(self.Sigma[0, 0], 1e-4), max(self.Sigma[1, 1], 0.04), sig_m ** 2, 1.0])
|
|
269
|
+
if self.hidden_reset == "zero":
|
|
270
|
+
self.h = [m.init_hidden(1) for m in self.models]
|
|
271
|
+
self.job = None # in-flight correction is based on the old state
|
|
272
|
+
self.last_reinit = k
|
|
273
|
+
self.det.g = 0.0
|
|
274
|
+
self.ev_log.append(dict(k=k, action="reinit_detail", before=before.tolist(), after=self.s.tolist()))
|
aurasync/v41/__init__.py
ADDED
|
File without changes
|
aurasync/v41/pipeline.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""AuraSync V4.1: geometry-first, evidence-aware estimation with selective neural assistance.
|
|
2
|
+
|
|
3
|
+
Analogy: a surveyor with a theodolite (event geometry) does the work. Before trusting a
|
|
4
|
+
reading he asks what kind of silence or noise he is facing:
|
|
5
|
+
QUIET nothing moves, the instrument is alive, and nothing *should* move
|
|
6
|
+
-> the last fix still holds; uncertainty stays bounded
|
|
7
|
+
UNOBSERVED something is moving (activity in the pole region) or should be moving
|
|
8
|
+
(the estimate predicts motion), but no usable fit -> uncertainty grows
|
|
9
|
+
with the full process noise: absence of evidence is NOT proof of stillness
|
|
10
|
+
CONTRADICTORY a good-looking fit disagrees with the estimate -> wait for corroboration
|
|
11
|
+
(re-initialise after several consistent fits), meanwhile widen the
|
|
12
|
+
uncertainty so a real change cannot be locked out
|
|
13
|
+
CORRUPT burst of events far above the background, or fits that disagree with
|
|
14
|
+
each other -> ignore the sensor for now, widen uncertainty
|
|
15
|
+
Only when the theodolite is not enough (UNOBSERVED long enough, unresolved contradiction,
|
|
16
|
+
corruption, or high uncertainty) does he radio the expensive helper (the V3 neural
|
|
17
|
+
observer), within a budget, with a periodic check-in at most `max_interval` ticks apart.
|
|
18
|
+
The helper's answer is accepted only through a gate, and only for positions (x, theta):
|
|
19
|
+
velocities stay with the physics + geometry filter.
|
|
20
|
+
|
|
21
|
+
neural=None gives the network-free variant ("D-ev": Baseline D + the evidence fix).
|
|
22
|
+
No ground truth is used: inputs are the time surface, the applied controls and the
|
|
23
|
+
estimator's own state.
|
|
24
|
+
"""
|
|
25
|
+
from collections import deque
|
|
26
|
+
import numpy as np
|
|
27
|
+
import torch
|
|
28
|
+
|
|
29
|
+
from ..physics import PlantParams, rk4_step, linearise
|
|
30
|
+
from ..estimator import SCALE
|
|
31
|
+
from ..scheduler import FRESH
|
|
32
|
+
from ..v4.geometry import EventGeometry
|
|
33
|
+
|
|
34
|
+
DT = 1e-3
|
|
35
|
+
SCALE_NP = SCALE.numpy().astype(np.float64)
|
|
36
|
+
QUIET, UNOBSERVED, FUSED, CONTRADICTORY, CORRUPT, REINIT = range(6)
|
|
37
|
+
STATE_NAMES = ["quiet", "unobserved", "fused", "contradictory", "corrupt", "reinit"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EvidencePipeline:
|
|
41
|
+
def __init__(self, Q, neural=None, temp=None, latency_samples=(0.002,), deadline=6,
|
|
42
|
+
# measurement model (calibrated on validation seeds, results/v41/geometry_calibration.json)
|
|
43
|
+
meas_inflate=1.4, low_support_inflate=1.6, sigma_x_floor=0.004, gate=9.0,
|
|
44
|
+
# evidence classification
|
|
45
|
+
roi_quiet=4, v_quiet=0.15, alive_window=10, sigma_quiet_deg=3.0, sigma_dquiet=0.5,
|
|
46
|
+
burst_factor=4.0, burst_min=40, consistency_deg=15.0,
|
|
47
|
+
# contradictions
|
|
48
|
+
confirm_n=4, confirm_window=12, confirm_spread_deg=3.0, cooldown=40, contra_growth=1.3,
|
|
49
|
+
corrupt_q_deg=1.0,
|
|
50
|
+
# selective neural assistance (Module E)
|
|
51
|
+
min_gap=10, max_interval=50, unobs_ticks=3, sigma_req_deg=3.0, neural_gate=9.0,
|
|
52
|
+
Q_quiet=None, plant=None, seed=0, name=None):
|
|
53
|
+
self.p = plant or PlantParams()
|
|
54
|
+
self.A, _ = linearise(self.p, DT)
|
|
55
|
+
self.Q = Q
|
|
56
|
+
self.Q_quiet = np.diag([1e-9, 1e-5, 1e-8, 1e-4]) if Q_quiet is None else Q_quiet
|
|
57
|
+
self.geo = EventGeometry(seed=seed + 1)
|
|
58
|
+
self.models = None if neural is None else [m.eval() for m in (neural if isinstance(neural, (list, tuple)) else [neural])]
|
|
59
|
+
self.temp = np.ones(4) if temp is None else np.asarray(temp, float)
|
|
60
|
+
self.lat = np.asarray(latency_samples, float)
|
|
61
|
+
self.deadline = deadline
|
|
62
|
+
self.mi, self.mi_low, self.sx, self.gate = meas_inflate, low_support_inflate, sigma_x_floor, gate
|
|
63
|
+
self.roi_quiet, self.v_quiet, self.alive_window = roi_quiet, v_quiet, alive_window
|
|
64
|
+
self.sig_quiet, self.sig_dquiet = np.radians(sigma_quiet_deg), sigma_dquiet
|
|
65
|
+
self.burst_factor, self.burst_min, self.consistency = burst_factor, burst_min, np.radians(consistency_deg)
|
|
66
|
+
self.confirm_n, self.confirm_window, self.confirm_spread = confirm_n, confirm_window, np.radians(confirm_spread_deg)
|
|
67
|
+
self.cooldown, self.contra_growth, self.q_corrupt = cooldown, contra_growth, np.radians(corrupt_q_deg) ** 2
|
|
68
|
+
self.min_gap, self.max_interval, self.unobs_ticks = min_gap, max_interval, unobs_ticks
|
|
69
|
+
self.sigma_req, self.neural_gate = np.radians(sigma_req_deg), neural_gate
|
|
70
|
+
self.seed = seed
|
|
71
|
+
self.name = name or ("v41" if neural is not None else "geo-ev")
|
|
72
|
+
self.delay_ticks = 0
|
|
73
|
+
|
|
74
|
+
# ---- interface --------------------------------------------------------------------------
|
|
75
|
+
def reset(self, s0):
|
|
76
|
+
self.rng = np.random.default_rng(self.seed)
|
|
77
|
+
self.geo.rng = np.random.default_rng(self.seed + 1)
|
|
78
|
+
self.s = np.asarray(s0, float).copy()
|
|
79
|
+
self.Sigma = np.diag((0.05 * SCALE_NP) ** 2)
|
|
80
|
+
self.k = -1
|
|
81
|
+
self.u_hist = deque(maxlen=256)
|
|
82
|
+
self.buf = deque(maxlen=32) # recent valid fits (k, theta, sigma, x)
|
|
83
|
+
self.cbuf = deque(maxlen=32) # recent fits that CONTRADICTED the estimate
|
|
84
|
+
self.last_alive = -10 ** 9 # last tick with any background/scene activity
|
|
85
|
+
self.ev_base = None
|
|
86
|
+
self.unobs_run = 0
|
|
87
|
+
self.last_reinit = -10 ** 9
|
|
88
|
+
self.last_call = -10 ** 9
|
|
89
|
+
self.job = None # (capture_k, done_k, s_corr, Sigma_corr, h_new)
|
|
90
|
+
self.h = None if self.models is None else [m.init_hidden(1) for m in self.models]
|
|
91
|
+
self.state = QUIET
|
|
92
|
+
self.log = dict(calls=0, misses=0, accepted=0, rejected=0, states=[0] * 6, triggers=dict(
|
|
93
|
+
unobserved=0, contradictory=0, corrupt=0, doubt=0, check=0), sigma_th=[], state_seq=[])
|
|
94
|
+
|
|
95
|
+
def estimate(self):
|
|
96
|
+
return self.s
|
|
97
|
+
|
|
98
|
+
def covariance(self):
|
|
99
|
+
return self.Sigma
|
|
100
|
+
|
|
101
|
+
def age_ticks(self):
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
# ---- neural helper (identical computation to the V3 observer) ----------------------------
|
|
105
|
+
@torch.inference_mode()
|
|
106
|
+
def _neural(self, surface, s_pred, gap):
|
|
107
|
+
ts = torch.from_numpy(np.ascontiguousarray(surface, dtype=np.float32))[None]
|
|
108
|
+
sp = torch.tensor(s_pred, dtype=torch.float32)[None]
|
|
109
|
+
g = torch.tensor([float(gap)])
|
|
110
|
+
outs, vars_, hs = [], [], []
|
|
111
|
+
for m, h in zip(self.models, self.h):
|
|
112
|
+
z, act = m.encode(ts)
|
|
113
|
+
s_new, h_new, _, lv = m.correct(z, act, h, sp, g)
|
|
114
|
+
outs.append(s_new[0].double().numpy()); hs.append(h_new)
|
|
115
|
+
vars_.append((np.exp(lv[0].double().numpy()) * SCALE_NP ** 2) * self.temp ** 2)
|
|
116
|
+
return np.mean(outs, 0), np.diag(np.mean(vars_, 0)), hs
|
|
117
|
+
|
|
118
|
+
def _propagate(self, s, Sigma, u, Q):
|
|
119
|
+
return rk4_step(s, u, DT, self.p), self.A @ Sigma @ self.A.T + Q
|
|
120
|
+
|
|
121
|
+
# ---- Kalman helpers ---------------------------------------------------------------------
|
|
122
|
+
def _kf(self, idx, z, R):
|
|
123
|
+
H = np.eye(4)[idx]
|
|
124
|
+
S = H @ self.Sigma @ H.T + R
|
|
125
|
+
K = np.linalg.solve(S, H @ self.Sigma).T
|
|
126
|
+
self.s = self.s + K @ (z - H @ self.s)
|
|
127
|
+
I_KH = np.eye(4) - K @ H
|
|
128
|
+
self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
|
|
129
|
+
|
|
130
|
+
def _cap(self, i, sig):
|
|
131
|
+
"""Bound the std of state i at sig by scaling its row/column (keeps Sigma PSD)."""
|
|
132
|
+
v = self.Sigma[i, i]
|
|
133
|
+
if v > sig ** 2:
|
|
134
|
+
f = sig / np.sqrt(v)
|
|
135
|
+
self.Sigma[i, :] *= f; self.Sigma[:, i] *= f
|
|
136
|
+
|
|
137
|
+
def _widen(self, i, var):
|
|
138
|
+
self.Sigma[i, i] += var
|
|
139
|
+
|
|
140
|
+
# ---- one tick ---------------------------------------------------------------------------
|
|
141
|
+
def update(self, k, t, s_true, surface, u):
|
|
142
|
+
self.k = k
|
|
143
|
+
self.u_hist.append(u)
|
|
144
|
+
# evidence counts (no model, no truth)
|
|
145
|
+
ev = self.geo.events(surface)
|
|
146
|
+
roi = (self.geo.wy >= self.geo.y_lo) & (self.geo.wy <= self.geo.y_hi) & (np.abs(self.geo.wx - self.s[0]) <= self.geo.band)
|
|
147
|
+
n_glob, n_roi = int(ev.sum()), int((ev & roi).sum())
|
|
148
|
+
if n_glob > 0:
|
|
149
|
+
self.last_alive = k
|
|
150
|
+
burst = self.ev_base is not None and n_glob > max(self.burst_factor * self.ev_base, self.burst_min)
|
|
151
|
+
self.ev_base = n_glob if self.ev_base is None else (0.995 * self.ev_base + 0.005 * min(n_glob, 3 * self.ev_base + 5))
|
|
152
|
+
alive = k - self.last_alive <= self.alive_window
|
|
153
|
+
v_hat = abs(self.s[1] + 2 * self.geo.l * self.s[3] * np.cos(self.s[2]))
|
|
154
|
+
|
|
155
|
+
m = None if burst else self.geo.measure(surface, self.s[0])
|
|
156
|
+
valid = m is not None and m["valid"]
|
|
157
|
+
# classify the evidence BEFORE propagation decides how much process noise to add
|
|
158
|
+
if burst:
|
|
159
|
+
state = CORRUPT
|
|
160
|
+
elif valid:
|
|
161
|
+
recent = [b[1] for b in self.buf if k - b[0] <= 5]
|
|
162
|
+
if recent and abs(m["theta"] - np.median(recent)) > self.consistency and len(recent) >= 3:
|
|
163
|
+
state = CORRUPT # fits disagree with each other
|
|
164
|
+
else:
|
|
165
|
+
state = FUSED # provisional; gate decides below
|
|
166
|
+
elif n_roi < self.roi_quiet and v_hat < self.v_quiet and alive:
|
|
167
|
+
state = QUIET
|
|
168
|
+
else:
|
|
169
|
+
state = UNOBSERVED
|
|
170
|
+
|
|
171
|
+
# 1. physics prediction: full process noise unless the scene is verified quiet
|
|
172
|
+
self.s, self.Sigma = self._propagate(self.s, self.Sigma, u, self.Q_quiet if state == QUIET else self.Q)
|
|
173
|
+
if state == QUIET:
|
|
174
|
+
self._cap(2, self.sig_quiet); self._cap(3, self.sig_dquiet)
|
|
175
|
+
|
|
176
|
+
# 2. finished neural job -> gated, position-only fusion
|
|
177
|
+
if self.job is not None:
|
|
178
|
+
self._finish_job(k)
|
|
179
|
+
|
|
180
|
+
# 3. geometry
|
|
181
|
+
if state in (FUSED,):
|
|
182
|
+
sig = m["sigma_theta"] * (self.mi_low if (m["span"] < 0.6 or m["n_inliers"] < 12) else self.mi)
|
|
183
|
+
sx = max(self.sx, 0.5 / self.geo.S / np.sqrt(m["n_inliers"])) * self.mi
|
|
184
|
+
nu = m["theta"] - self.s[2]
|
|
185
|
+
d2 = nu ** 2 / (self.Sigma[2, 2] + sig ** 2)
|
|
186
|
+
self.buf.append((k, m["theta"], sig, m["x_pivot"]))
|
|
187
|
+
if d2 <= self.gate:
|
|
188
|
+
self._kf([2, 0], np.array([m["theta"], m["x_pivot"]]), np.diag([sig ** 2, sx ** 2]))
|
|
189
|
+
self.cbuf.clear()
|
|
190
|
+
else:
|
|
191
|
+
state = CONTRADICTORY
|
|
192
|
+
self.cbuf.append((k, m["theta"], sig, m["x_pivot"]))
|
|
193
|
+
if k - self.last_reinit >= self.cooldown and self._confirmed(k):
|
|
194
|
+
self._reinit(k); state = REINIT
|
|
195
|
+
else: # covariance matching, bounded growth
|
|
196
|
+
target = nu ** 2 / self.gate - sig ** 2
|
|
197
|
+
self.Sigma[2, 2] = max(self.Sigma[2, 2], min(target, self.Sigma[2, 2] * self.contra_growth ** 2))
|
|
198
|
+
elif state == CORRUPT:
|
|
199
|
+
self._widen(2, self.q_corrupt)
|
|
200
|
+
self.unobs_run = self.unobs_run + 1 if state == UNOBSERVED else 0
|
|
201
|
+
|
|
202
|
+
# 4. selective neural assistance
|
|
203
|
+
if self.models is not None and self.job is None and k - self.last_call >= self.min_gap:
|
|
204
|
+
why = None
|
|
205
|
+
if state == UNOBSERVED and self.unobs_run >= self.unobs_ticks:
|
|
206
|
+
why = "unobserved"
|
|
207
|
+
elif state == CONTRADICTORY:
|
|
208
|
+
why = "contradictory"
|
|
209
|
+
elif state == CORRUPT:
|
|
210
|
+
why = "corrupt"
|
|
211
|
+
elif np.sqrt(self.Sigma[2, 2]) > self.sigma_req:
|
|
212
|
+
why = "doubt"
|
|
213
|
+
elif k - self.last_call >= self.max_interval:
|
|
214
|
+
why = "check"
|
|
215
|
+
if why is not None:
|
|
216
|
+
gap = max(1, k - self.last_call) if self.last_call > -10 ** 8 else 1
|
|
217
|
+
s_c, S_c, h_new = self._neural(surface, self.s, min(gap, 1000))
|
|
218
|
+
lat = int(np.ceil(self.rng.choice(self.lat) / DT))
|
|
219
|
+
self.job = (k, k + lat, s_c, S_c, h_new, self.s.copy())
|
|
220
|
+
self.last_call = k
|
|
221
|
+
self.log["calls"] += 1; self.log["triggers"][why] += 1
|
|
222
|
+
|
|
223
|
+
self.state = state
|
|
224
|
+
self.log["states"][state] += 1
|
|
225
|
+
self.log["state_seq"].append(state)
|
|
226
|
+
self.log["sigma_th"].append(np.sqrt(self.Sigma[2, 2]))
|
|
227
|
+
|
|
228
|
+
def _finish_job(self, k):
|
|
229
|
+
cap_k, done_k, s_c, S_c, h_new, s_cap = self.job
|
|
230
|
+
if done_k - cap_k > self.deadline and k >= cap_k + self.deadline:
|
|
231
|
+
self.log["misses"] += 1; self.job = None
|
|
232
|
+
return
|
|
233
|
+
if k < done_k:
|
|
234
|
+
return
|
|
235
|
+
Sc = S_c.copy()
|
|
236
|
+
for uu in list(self.u_hist)[-(k - cap_k):] if k > cap_k else []:
|
|
237
|
+
s_c, Sc = self._propagate(s_c, Sc, uu, self.Q)
|
|
238
|
+
self.h = h_new
|
|
239
|
+
R = np.diag(np.clip(np.diag(Sc)[[0, 2]], 1e-12, None))
|
|
240
|
+
nu = s_c[2] - self.s[2]
|
|
241
|
+
d2 = nu ** 2 / (self.Sigma[2, 2] + R[1, 1])
|
|
242
|
+
if d2 <= self.neural_gate:
|
|
243
|
+
self._kf([0, 2], s_c[[0, 2]], R) # positions only; velocities via cross-covariance
|
|
244
|
+
self.log["accepted"] += 1
|
|
245
|
+
else:
|
|
246
|
+
self.log["rejected"] += 1
|
|
247
|
+
self.job = None
|
|
248
|
+
|
|
249
|
+
def _confirmed(self, k):
|
|
250
|
+
recent = [b for b in self.cbuf if k - b[0] <= self.confirm_window]
|
|
251
|
+
if len(recent) < self.confirm_n:
|
|
252
|
+
return False
|
|
253
|
+
th = np.array([b[1] for b in recent])
|
|
254
|
+
return float(np.abs(th - np.median(th)).max()) <= self.confirm_spread
|
|
255
|
+
|
|
256
|
+
def _reinit(self, k):
|
|
257
|
+
recent = [b for b in self.cbuf if k - b[0] <= self.confirm_window]
|
|
258
|
+
ks = np.array([b[0] for b in recent], float); th = np.array([b[1] for b in recent])
|
|
259
|
+
self.s[2] = float(np.median(th))
|
|
260
|
+
if ks.max() - ks.min() >= 6:
|
|
261
|
+
self.s[3] = float(np.polyfit((ks - ks.max()) * DT, th, 1)[0])
|
|
262
|
+
self.s[0] = float(np.median([b[3] for b in recent]))
|
|
263
|
+
sig_m = float(np.median([b[2] for b in recent]))
|
|
264
|
+
self.Sigma = np.diag([max(self.Sigma[0, 0], 1e-4), max(self.Sigma[1, 1], 0.04), sig_m ** 2, 1.0])
|
|
265
|
+
if self.models is not None:
|
|
266
|
+
self.h = [m.init_hidden(1) for m in self.models]
|
|
267
|
+
self.job = None
|
|
268
|
+
self.last_reinit = k
|
|
269
|
+
self.cbuf.clear()
|