neckcompass 1.0.0__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.
- neckcompass/__init__.py +22 -0
- neckcompass/__main__.py +6 -0
- neckcompass/cli.py +281 -0
- neckcompass/path_integrator.py +233 -0
- neckcompass/ring_attractor.py +453 -0
- neckcompass-1.0.0.dist-info/METADATA +269 -0
- neckcompass-1.0.0.dist-info/RECORD +11 -0
- neckcompass-1.0.0.dist-info/WHEEL +5 -0
- neckcompass-1.0.0.dist-info/entry_points.txt +2 -0
- neckcompass-1.0.0.dist-info/licenses/LICENSE +21 -0
- neckcompass-1.0.0.dist-info/top_level.txt +1 -0
neckcompass/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""NeckCompass: Drosophila Central Complex ring-attractor compass & GPS-denied path integrator."""
|
|
2
|
+
|
|
3
|
+
from .path_integrator import FanBodyNavigator
|
|
4
|
+
from .ring_attractor import (
|
|
5
|
+
CompassDiagnostics,
|
|
6
|
+
PlasticLandmarkRing,
|
|
7
|
+
RingCompass,
|
|
8
|
+
circular_error_rad,
|
|
9
|
+
wrap_angle,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"CompassDiagnostics",
|
|
17
|
+
"FanBodyNavigator",
|
|
18
|
+
"PlasticLandmarkRing",
|
|
19
|
+
"RingCompass",
|
|
20
|
+
"circular_error_rad",
|
|
21
|
+
"wrap_angle",
|
|
22
|
+
]
|
neckcompass/__main__.py
ADDED
neckcompass/cli.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Command-line interface for NeckCompass (`neckcompass demo`, `neckcompass benchmark-quick`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .path_integrator import FanBodyNavigator
|
|
14
|
+
from .ring_attractor import RingCompass, circular_error_rad, wrap_angle
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _CircularEKF2State:
|
|
18
|
+
"""Optimal 2-state Circular Extended Kalman Filter [heading_rad, gyro_bias_rad_s]."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
init_heading_rad: float,
|
|
23
|
+
gyro_std_rad_s: float = math.radians(1.5),
|
|
24
|
+
bias_rw_rad_s_rt_s: float = math.radians(0.08),
|
|
25
|
+
mag_std_rad: float = math.radians(3.0),
|
|
26
|
+
) -> None:
|
|
27
|
+
self.psi = float(wrap_angle(init_heading_rad))
|
|
28
|
+
self.bias = 0.0
|
|
29
|
+
r_mag = float(mag_std_rad) ** 2
|
|
30
|
+
self.P = np.diag([r_mag, (math.radians(1.5)) ** 2]).astype(np.float64)
|
|
31
|
+
self.gyro_var = float(gyro_std_rad_s) ** 2
|
|
32
|
+
self.bias_rw_var = float(bias_rw_rad_s_rt_s) ** 2
|
|
33
|
+
self.r_mag = r_mag
|
|
34
|
+
|
|
35
|
+
def update(self, omega_meas: float, dt: float, mag_rad: float) -> float:
|
|
36
|
+
psi_pred = float(wrap_angle(self.psi + (omega_meas - self.bias) * dt))
|
|
37
|
+
p00, p01, p10, p11 = self.P[0, 0], self.P[0, 1], self.P[1, 0], self.P[1, 1]
|
|
38
|
+
# Exact discrete-time covariance propagation: F = [[1, -dt], [0, 1]]
|
|
39
|
+
q00 = self.gyro_var * (dt * dt)
|
|
40
|
+
q11 = self.bias_rw_var * dt
|
|
41
|
+
pp00 = p00 - dt * (p10 + p01) + (dt * dt) * p11 + q00
|
|
42
|
+
pp01 = p01 - dt * p11
|
|
43
|
+
pp10 = p10 - dt * p11
|
|
44
|
+
pp11 = p11 + q11
|
|
45
|
+
|
|
46
|
+
innov = float(wrap_angle(mag_rad - psi_pred))
|
|
47
|
+
s_cov = pp00 + self.r_mag
|
|
48
|
+
k0 = pp00 / s_cov
|
|
49
|
+
k1 = pp10 / s_cov
|
|
50
|
+
|
|
51
|
+
self.psi = float(wrap_angle(psi_pred + k0 * innov))
|
|
52
|
+
self.bias = float(self.bias + k1 * innov)
|
|
53
|
+
self.P[0, 0] = (1.0 - k0) * pp00
|
|
54
|
+
self.P[0, 1] = (1.0 - k0) * pp01
|
|
55
|
+
self.P[1, 0] = pp10 - k1 * pp00
|
|
56
|
+
self.P[1, 1] = pp11 - k1 * pp01
|
|
57
|
+
return self.psi
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_demo(seed: int = 42, n_wedges: int = 16, as_json: bool = False) -> dict:
|
|
61
|
+
rng = np.random.default_rng(seed)
|
|
62
|
+
dt = 0.02
|
|
63
|
+
speed = 1.6 # m/s
|
|
64
|
+
gyro_bias = math.radians(1.0) # 1.0 deg/s steady MEMS drift
|
|
65
|
+
|
|
66
|
+
compass = RingCompass(n_wedges=n_wedges)
|
|
67
|
+
nav = FanBodyNavigator(n_columns=16, compass=compass)
|
|
68
|
+
|
|
69
|
+
true_x, true_y, true_psi = 0.0, 0.0, 0.35
|
|
70
|
+
compass.reset(true_psi + rng.normal(0.0, math.radians(2.0)))
|
|
71
|
+
|
|
72
|
+
outbound_steps = int(90.0 / dt) # 90 s outbound survey (~144 m path)
|
|
73
|
+
spike_errs_deg: list[float] = []
|
|
74
|
+
|
|
75
|
+
for k in range(outbound_steps):
|
|
76
|
+
t = k * dt
|
|
77
|
+
omega_true = 0.22 * math.sin(0.11 * t) + 0.14 * math.cos(0.07 * t)
|
|
78
|
+
true_psi = float(wrap_angle(true_psi + omega_true * dt))
|
|
79
|
+
true_x += speed * math.cos(true_psi) * dt
|
|
80
|
+
true_y += speed * math.sin(true_psi) * dt
|
|
81
|
+
|
|
82
|
+
omega_meas = omega_true + gyro_bias + rng.normal(0.0, math.radians(1.2))
|
|
83
|
+
spike = 0.0
|
|
84
|
+
if 25.0 <= t <= 26.6:
|
|
85
|
+
spike = math.radians(118.0) * math.sin(math.pi * (t - 25.0) / 1.6)
|
|
86
|
+
elif 58.0 <= t <= 59.8:
|
|
87
|
+
spike = -math.radians(136.0) * math.sin(math.pi * (t - 58.0) / 1.8)
|
|
88
|
+
mag_meas = float(wrap_angle(true_psi + spike + rng.normal(0.0, math.radians(2.5))))
|
|
89
|
+
|
|
90
|
+
est_psi, _, _ = nav.step_with_compass(omega_meas, speed, dt, mag_heading_rad=mag_meas)
|
|
91
|
+
if abs(spike) > math.radians(60.0):
|
|
92
|
+
spike_errs_deg.append(abs(math.degrees(float(circular_error_rad(est_psi, true_psi)))))
|
|
93
|
+
|
|
94
|
+
outbound_dist = math.hypot(true_x, true_y)
|
|
95
|
+
|
|
96
|
+
# Engage blind GPS-denied return-to-home via PFL3 steering
|
|
97
|
+
max_return_steps = int(130.0 / dt)
|
|
98
|
+
for k in range(max_return_steps):
|
|
99
|
+
t = 90.0 + k * dt
|
|
100
|
+
est_dist, _ = nav.home_vector()
|
|
101
|
+
if est_dist < 0.6:
|
|
102
|
+
break
|
|
103
|
+
omega_cmd = nav.steering_cmd(max_turn_rate=0.9)
|
|
104
|
+
true_psi = float(wrap_angle(true_psi + omega_cmd * dt))
|
|
105
|
+
step_v = speed * min(1.0, max(0.25, est_dist / 3.0))
|
|
106
|
+
true_x += step_v * math.cos(true_psi) * dt
|
|
107
|
+
true_y += step_v * math.sin(true_psi) * dt
|
|
108
|
+
|
|
109
|
+
omega_meas = omega_cmd + gyro_bias + rng.normal(0.0, math.radians(1.2))
|
|
110
|
+
spike = (
|
|
111
|
+
math.radians(125.0) * math.sin(math.pi * (t - 115.0) / 1.5)
|
|
112
|
+
if 115.0 <= t <= 116.5
|
|
113
|
+
else 0.0
|
|
114
|
+
)
|
|
115
|
+
mag_meas = float(wrap_angle(true_psi + spike + rng.normal(0.0, math.radians(2.5))))
|
|
116
|
+
nav.step_with_compass(omega_meas, step_v, dt, mag_heading_rad=mag_meas)
|
|
117
|
+
|
|
118
|
+
miss_m = math.hypot(true_x, true_y)
|
|
119
|
+
summary = {
|
|
120
|
+
"version": __version__,
|
|
121
|
+
"n_wedges": n_wedges,
|
|
122
|
+
"outbound_distance_from_origin_m": round(outbound_dist, 2),
|
|
123
|
+
"max_error_during_136deg_spike_deg": round(max(spike_errs_deg) if spike_errs_deg else 0.0, 2),
|
|
124
|
+
"estimated_gyro_bias_deg_s": round(math.degrees(compass.estimated_gyro_bias_rad_s), 3),
|
|
125
|
+
"final_return_miss_m": round(miss_m, 2),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if as_json:
|
|
129
|
+
print(json.dumps(summary, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
print(f"NeckCompass v{__version__} -- Central Complex Ring Attractor & Path Integrator Demo")
|
|
132
|
+
print("-" * 74)
|
|
133
|
+
print(f" E-PG Ring Wedges : {summary['n_wedges']}")
|
|
134
|
+
print(f" Outbound Survey Displacement : {summary['outbound_distance_from_origin_m']:.2f} m (GPS blackout)")
|
|
135
|
+
print(f" Max Error in 118-136 deg Spikes: {summary['max_error_during_136deg_spike_deg']:.2f} deg")
|
|
136
|
+
print(f" Learned P-EN Gyro Bias Trim : {summary['estimated_gyro_bias_deg_s']:.3f} deg/s (true: 1.000 deg/s)")
|
|
137
|
+
print(f" Final PFL3 Return-to-Home Miss : {summary['final_return_miss_m']:.2f} m")
|
|
138
|
+
return summary
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _run_quick_benchmark(seed: int = 101, as_json: bool = False) -> dict:
|
|
142
|
+
rng = np.random.default_rng(seed)
|
|
143
|
+
dt = 0.02
|
|
144
|
+
steps = int(120.0 / dt)
|
|
145
|
+
gyro_bias_init = math.radians(0.9)
|
|
146
|
+
bias_rw_std = math.radians(0.08)
|
|
147
|
+
gyro_std = math.radians(1.5)
|
|
148
|
+
mag_std = math.radians(3.0)
|
|
149
|
+
|
|
150
|
+
def eval_heading(spiky: bool) -> dict[str, dict[str, float]]:
|
|
151
|
+
local_rng = np.random.default_rng(seed + (17 if spiky else 0))
|
|
152
|
+
psi_true = 0.2
|
|
153
|
+
gyro_bias = gyro_bias_init
|
|
154
|
+
init_mag = float(wrap_angle(psi_true + local_rng.normal(0.0, mag_std)))
|
|
155
|
+
|
|
156
|
+
nc = RingCompass(n_wedges=16, initial_heading_rad=init_mag)
|
|
157
|
+
ekf = _CircularEKF2State(
|
|
158
|
+
init_mag,
|
|
159
|
+
gyro_std_rad_s=gyro_std,
|
|
160
|
+
bias_rw_rad_s_rt_s=bias_rw_std,
|
|
161
|
+
mag_std_rad=mag_std,
|
|
162
|
+
)
|
|
163
|
+
psi_gyro = init_mag
|
|
164
|
+
psi_comp = init_mag
|
|
165
|
+
tau_comp = 2.5
|
|
166
|
+
alpha_comp = dt / (tau_comp + dt)
|
|
167
|
+
|
|
168
|
+
errs = {"neckcompass": [], "ekf": [], "complementary": [], "gyro_dr": []}
|
|
169
|
+
for k in range(steps):
|
|
170
|
+
t = k * dt
|
|
171
|
+
omega_true = 0.30 * math.sin(0.15 * t) + 0.18 * math.cos(0.09 * t)
|
|
172
|
+
psi_true = float(wrap_angle(psi_true + omega_true * dt))
|
|
173
|
+
gyro_bias += local_rng.normal(0.0, bias_rw_std * math.sqrt(dt))
|
|
174
|
+
omega_meas = omega_true + gyro_bias + local_rng.normal(0.0, gyro_std)
|
|
175
|
+
|
|
176
|
+
anom = 0.0
|
|
177
|
+
if spiky:
|
|
178
|
+
# Periodic 1.4 s ferromagnetic hull/cable spikes every 15 s
|
|
179
|
+
phase = t % 15.0
|
|
180
|
+
if 6.0 <= phase <= 7.4:
|
|
181
|
+
sign = 1.0 if (int(t // 15.0) % 2 == 0) else -1.0
|
|
182
|
+
amp = math.radians(115.0 if sign > 0 else 135.0)
|
|
183
|
+
anom = sign * amp * math.sin(math.pi * (phase - 6.0) / 1.4)
|
|
184
|
+
|
|
185
|
+
mag_meas = float(wrap_angle(psi_true + anom + local_rng.normal(0.0, mag_std)))
|
|
186
|
+
|
|
187
|
+
psi_nc = nc.update(omega_meas, dt, mag_heading_rad=mag_meas)
|
|
188
|
+
psi_ekf = ekf.update(omega_meas, dt, mag_meas)
|
|
189
|
+
psi_gyro = float(wrap_angle(psi_gyro + omega_meas * dt))
|
|
190
|
+
pred_c = float(wrap_angle(psi_comp + omega_meas * dt))
|
|
191
|
+
psi_comp = float(wrap_angle(pred_c + alpha_comp * float(wrap_angle(mag_meas - pred_c))))
|
|
192
|
+
|
|
193
|
+
if k >= int(15.0 / dt): # Steady-state window after 15 s warmup
|
|
194
|
+
errs["neckcompass"].append(abs(math.degrees(float(circular_error_rad(psi_nc, psi_true)))))
|
|
195
|
+
errs["ekf"].append(abs(math.degrees(float(circular_error_rad(psi_ekf, psi_true)))))
|
|
196
|
+
errs["complementary"].append(abs(math.degrees(float(circular_error_rad(psi_comp, psi_true)))))
|
|
197
|
+
errs["gyro_dr"].append(abs(math.degrees(float(circular_error_rad(psi_gyro, psi_true)))))
|
|
198
|
+
|
|
199
|
+
out: dict[str, dict[str, float]] = {}
|
|
200
|
+
for name, vals in errs.items():
|
|
201
|
+
arr = np.asarray(vals, dtype=np.float64)
|
|
202
|
+
out[name] = {
|
|
203
|
+
"rmse_deg": round(float(np.sqrt(np.mean(arr * arr))), 2),
|
|
204
|
+
"p95_deg": round(float(np.percentile(arr, 95)), 2),
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
clean_res = eval_heading(spiky=False)
|
|
209
|
+
spiky_res = eval_heading(spiky=True)
|
|
210
|
+
_ = rng # keep seed signature clean
|
|
211
|
+
|
|
212
|
+
result = {
|
|
213
|
+
"seed": seed,
|
|
214
|
+
"scenario_a_clean_gaussian": clean_res,
|
|
215
|
+
"scenario_b_ferromagnetic_spikes": spiky_res,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if as_json:
|
|
219
|
+
print(json.dumps(result, indent=2))
|
|
220
|
+
else:
|
|
221
|
+
print(f"NeckCompass v{__version__} -- Quick Head-to-Head Benchmark (120 s @ 50 Hz, seed={seed})")
|
|
222
|
+
print("-" * 82)
|
|
223
|
+
print(
|
|
224
|
+
f"{'Estimator':<28} | {'Clean RMSE (deg)':>16} | {'Spiky RMSE (deg)':>16} | {'Spiky P95 (deg)':>15}"
|
|
225
|
+
)
|
|
226
|
+
print("-" * 82)
|
|
227
|
+
labels = [
|
|
228
|
+
("NeckCompass (E-PG 16w)", "neckcompass"),
|
|
229
|
+
("2-State Circular EKF", "ekf"),
|
|
230
|
+
("Complementary IMU+Mag", "complementary"),
|
|
231
|
+
("Gyro Dead-Reckoning", "gyro_dr"),
|
|
232
|
+
]
|
|
233
|
+
for label, key in labels:
|
|
234
|
+
c_rmse = clean_res[key]["rmse_deg"]
|
|
235
|
+
s_rmse = spiky_res[key]["rmse_deg"]
|
|
236
|
+
s_p95 = spiky_res[key]["p95_deg"]
|
|
237
|
+
print(f"{label:<28} | {c_rmse:>16.2f} | {s_rmse:>16.2f} | {s_p95:>15.2f}")
|
|
238
|
+
print("-" * 82)
|
|
239
|
+
print(
|
|
240
|
+
"Note: Under ideal Gaussian noise, 2-State EKF is minimum-variance optimal; "
|
|
241
|
+
"under 115-135 deg ferromagnetic spikes, NeckCompass rejects off-basin outliers."
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
247
|
+
parser = argparse.ArgumentParser(
|
|
248
|
+
prog="neckcompass",
|
|
249
|
+
description="NeckCompass: Drosophila Central Complex ring-attractor compass & GPS-denied navigator.",
|
|
250
|
+
)
|
|
251
|
+
parser.add_argument("--version", action="version", version=f"neckcompass {__version__}")
|
|
252
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
253
|
+
|
|
254
|
+
p_demo = sub.add_parser("demo", help="Run GPS-denied outbound + PFL3 homebound navigation demo")
|
|
255
|
+
p_demo.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
|
|
256
|
+
p_demo.add_argument("--wedges", type=int, default=16, choices=[16, 32], help="Ring wedges (16 or 32)")
|
|
257
|
+
p_demo.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
|
|
258
|
+
|
|
259
|
+
p_bench = sub.add_parser(
|
|
260
|
+
"benchmark-quick",
|
|
261
|
+
help="Run fast head-to-head comparison (Clean Gaussian vs Ferromagnetic Spikes)",
|
|
262
|
+
)
|
|
263
|
+
p_bench.add_argument("--seed", type=int, default=101, help="Random seed (default: 101)")
|
|
264
|
+
p_bench.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
|
|
265
|
+
return parser
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
269
|
+
parser = build_parser()
|
|
270
|
+
args = parser.parse_args(argv)
|
|
271
|
+
if args.command == "demo":
|
|
272
|
+
_run_demo(seed=args.seed, n_wedges=args.wedges, as_json=args.json)
|
|
273
|
+
return 0
|
|
274
|
+
if args.command == "benchmark-quick":
|
|
275
|
+
_run_quick_benchmark(seed=args.seed, as_json=args.json)
|
|
276
|
+
return 0
|
|
277
|
+
return 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Central Complex Fan-Shaped Body (CPU4 / hDelta) path integrator and PFL3 steering.
|
|
2
|
+
|
|
3
|
+
Biophysical inspirations:
|
|
4
|
+
- Stone et al. (Current Biology, 2017): anatomically constrained Central Complex path
|
|
5
|
+
integration and homebound steering in bee/insect brain
|
|
6
|
+
- Lyu, Abbott & Maimon (Nature, 2022): 2D vector arithmetic via sinusoidal activity bumps
|
|
7
|
+
in the *Drosophila* Fan-Shaped Body
|
|
8
|
+
- Mussells Pires et al. (Nature, 2024) & Westeinde et al. (Nature, 2024): PFL3 (+/-67.5 deg
|
|
9
|
+
phase-shifted left/right steering) + PFL2 (180 deg anti-goal turn boost)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .ring_attractor import RingCompass, wrap_angle
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FanBodyNavigator:
|
|
23
|
+
"""N-column opponent CPU4 vector path integrator and PFL3/PFL2 homebound controller.
|
|
24
|
+
|
|
25
|
+
Maintains ``n_columns`` (default 16) opponent Fan-Shaped Body memory columns that
|
|
26
|
+
accumulate translational speed along the current E-PG compass heading during
|
|
27
|
+
GPS-denied outbound navigation, continuously encoding the metric displacement vector
|
|
28
|
+
from origin ``(0, 0)`` and generating neural PFL3/PFL2 yaw-rate commands to steer
|
|
29
|
+
straight back home during complete GPS blackouts.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
n_columns: int = 16,
|
|
35
|
+
compass: Optional[RingCompass] = None,
|
|
36
|
+
*,
|
|
37
|
+
leak_rate: float = 0.0,
|
|
38
|
+
steering_gain: float = 2.2,
|
|
39
|
+
pfl2_boost_gain: float = 2.5,
|
|
40
|
+
arrival_radius_m: float = 1.5,
|
|
41
|
+
) -> None:
|
|
42
|
+
if n_columns < 8 or (n_columns % 2) != 0:
|
|
43
|
+
raise ValueError(f"n_columns must be an even integer >= 8, got {n_columns}")
|
|
44
|
+
if leak_rate < 0.0:
|
|
45
|
+
raise ValueError("leak_rate must be >= 0")
|
|
46
|
+
if arrival_radius_m <= 0.0:
|
|
47
|
+
raise ValueError("arrival_radius_m must be > 0")
|
|
48
|
+
|
|
49
|
+
self.n_columns: int = int(n_columns)
|
|
50
|
+
self.compass: Optional[RingCompass] = compass
|
|
51
|
+
self.leak_rate: float = float(leak_rate)
|
|
52
|
+
self.steering_gain: float = float(steering_gain)
|
|
53
|
+
self.pfl2_boost_gain: float = float(pfl2_boost_gain)
|
|
54
|
+
self.arrival_radius_m: float = float(arrival_radius_m)
|
|
55
|
+
|
|
56
|
+
self.thetas: np.ndarray = -math.pi + (
|
|
57
|
+
2.0 * math.pi * np.arange(self.n_columns, dtype=np.float64)
|
|
58
|
+
) / self.n_columns
|
|
59
|
+
self._cos_thetas: np.ndarray = np.cos(self.thetas)
|
|
60
|
+
self._sin_thetas: np.ndarray = np.sin(self.thetas)
|
|
61
|
+
|
|
62
|
+
# Opponent CPU4 column memories (excitatory PFN drive vs 180-deg hDelta opponent)
|
|
63
|
+
self._cpu4_pos: np.ndarray = np.zeros(self.n_columns, dtype=np.float64)
|
|
64
|
+
self._cpu4_neg: np.ndarray = np.zeros(self.n_columns, dtype=np.float64)
|
|
65
|
+
self._last_heading_rad: float = compass.heading_rad if (compass and compass.initialized) else 0.0
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None:
|
|
68
|
+
"""Zero the CPU4 path-integrator memory (marks current location as new home origin)."""
|
|
69
|
+
self._cpu4_pos.fill(0.0)
|
|
70
|
+
self._cpu4_neg.fill(0.0)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def cpu4_columns(self) -> np.ndarray:
|
|
74
|
+
"""Net opponent CPU4 column activity ``(n_columns,)`` encoding outbound displacement."""
|
|
75
|
+
return self._cpu4_pos - self._cpu4_neg
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def cpu4_rates(self) -> tuple[np.ndarray, np.ndarray]:
|
|
79
|
+
"""Return copies of ``(cpu4_pos, cpu4_neg)`` non-negative opponent column accumulators."""
|
|
80
|
+
return self._cpu4_pos.copy(), self._cpu4_neg.copy()
|
|
81
|
+
|
|
82
|
+
def _resolve_heading(self, heading_rad: Optional[float]) -> float:
|
|
83
|
+
if heading_rad is not None:
|
|
84
|
+
return float(wrap_angle(heading_rad))
|
|
85
|
+
if self.compass is not None and self.compass.initialized:
|
|
86
|
+
return self.compass.heading_rad
|
|
87
|
+
return self._last_heading_rad
|
|
88
|
+
|
|
89
|
+
def update(
|
|
90
|
+
self,
|
|
91
|
+
speed_m_s: float,
|
|
92
|
+
dt: float,
|
|
93
|
+
heading_rad: Optional[float] = None,
|
|
94
|
+
) -> tuple[float, float]:
|
|
95
|
+
"""Integrate forward velocity ``speed_m_s`` for ``dt`` seconds along ``heading_rad``.
|
|
96
|
+
|
|
97
|
+
If ``heading_rad`` is omitted, reads the current heading from the attached
|
|
98
|
+
``RingCompass`` (if any). Returns ``(distance_m, home_bearing_rad)``.
|
|
99
|
+
"""
|
|
100
|
+
if dt < 0.0:
|
|
101
|
+
raise ValueError(f"dt must be non-negative, got {dt}")
|
|
102
|
+
|
|
103
|
+
psi = self._resolve_heading(heading_rad)
|
|
104
|
+
self._last_heading_rad = psi
|
|
105
|
+
|
|
106
|
+
if dt > 0.0:
|
|
107
|
+
if self.leak_rate > 0.0:
|
|
108
|
+
decay = math.exp(-self.leak_rate * dt)
|
|
109
|
+
self._cpu4_pos *= decay
|
|
110
|
+
self._cpu4_neg *= decay
|
|
111
|
+
|
|
112
|
+
ds = float(speed_m_s) * float(dt)
|
|
113
|
+
proj = np.cos(self.thetas - psi)
|
|
114
|
+
# Push-pull opponent accumulation across the 16 Fan-Shaped Body columns
|
|
115
|
+
pos_drive = np.maximum(0.0, proj)
|
|
116
|
+
neg_drive = np.maximum(0.0, -proj)
|
|
117
|
+
if ds >= 0.0:
|
|
118
|
+
self._cpu4_pos += ds * pos_drive
|
|
119
|
+
self._cpu4_neg += ds * neg_drive
|
|
120
|
+
else:
|
|
121
|
+
self._cpu4_pos += (-ds) * neg_drive
|
|
122
|
+
self._cpu4_neg += (-ds) * pos_drive
|
|
123
|
+
|
|
124
|
+
# Subtract common-mode baseline between paired columns to prevent saturation on long runs
|
|
125
|
+
common = np.minimum(self._cpu4_pos, self._cpu4_neg)
|
|
126
|
+
self._cpu4_pos -= common
|
|
127
|
+
self._cpu4_neg -= common
|
|
128
|
+
|
|
129
|
+
return self.home_vector()
|
|
130
|
+
|
|
131
|
+
def step_with_compass(
|
|
132
|
+
self,
|
|
133
|
+
omega_rad_s: float,
|
|
134
|
+
speed_m_s: float,
|
|
135
|
+
dt: float,
|
|
136
|
+
mag_heading_rad: Optional[float] = None,
|
|
137
|
+
sun_heading_rad: Optional[float] = None,
|
|
138
|
+
mag_confidence: float = 1.0,
|
|
139
|
+
sun_confidence: float = 1.0,
|
|
140
|
+
) -> tuple[float, float, float]:
|
|
141
|
+
"""Update the attached ``RingCompass`` and integrate path in one step.
|
|
142
|
+
|
|
143
|
+
Returns ``(heading_rad, distance_m, home_bearing_rad)``.
|
|
144
|
+
"""
|
|
145
|
+
if self.compass is None:
|
|
146
|
+
raise RuntimeError("FanBodyNavigator has no attached RingCompass; pass compass=... or call update()")
|
|
147
|
+
heading = self.compass.update(
|
|
148
|
+
omega_rad_s=omega_rad_s,
|
|
149
|
+
dt=dt,
|
|
150
|
+
mag_heading_rad=mag_heading_rad,
|
|
151
|
+
sun_heading_rad=sun_heading_rad,
|
|
152
|
+
mag_confidence=mag_confidence,
|
|
153
|
+
sun_confidence=sun_confidence,
|
|
154
|
+
)
|
|
155
|
+
dist_m, home_bearing = self.update(speed_m_s=speed_m_s, dt=dt, heading_rad=heading)
|
|
156
|
+
return heading, dist_m, home_bearing
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def displacement_xy_m(self) -> tuple[float, float]:
|
|
160
|
+
"""Decoded outbound Cartesian displacement ``(x_m, y_m)`` from home origin."""
|
|
161
|
+
net = self.cpu4_columns
|
|
162
|
+
x_m = (2.0 / self.n_columns) * float(np.dot(net, self._cos_thetas))
|
|
163
|
+
y_m = (2.0 / self.n_columns) * float(np.dot(net, self._sin_thetas))
|
|
164
|
+
return x_m, y_m
|
|
165
|
+
|
|
166
|
+
def home_vector(self) -> tuple[float, float]:
|
|
167
|
+
"""Compute ``(distance_m, home_bearing_rad)`` pointing from current position to origin."""
|
|
168
|
+
x_m, y_m = self.displacement_xy_m
|
|
169
|
+
dist_m = float(math.hypot(x_m, y_m))
|
|
170
|
+
if dist_m <= 1e-9:
|
|
171
|
+
return 0.0, 0.0
|
|
172
|
+
home_bearing = float(wrap_angle(math.atan2(-y_m, -x_m)))
|
|
173
|
+
return dist_m, home_bearing
|
|
174
|
+
|
|
175
|
+
def pfl3_columns(
|
|
176
|
+
self, current_heading_rad: Optional[float] = None
|
|
177
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
178
|
+
"""Compute ``(pfl3_left, pfl3_right, pfl2_boost)`` column firing rates.
|
|
179
|
+
|
|
180
|
+
Implements the *Drosophila* +/-67.5 deg (3*pi/8) PFL3 phase shift and 180 deg
|
|
181
|
+
PFL2 anti-goal boost across the Fan-Shaped Body columns.
|
|
182
|
+
"""
|
|
183
|
+
psi = self._resolve_heading(current_heading_rad)
|
|
184
|
+
dist_m, _ = self.home_vector()
|
|
185
|
+
if dist_m <= 1e-9:
|
|
186
|
+
zeros = np.zeros(self.n_columns, dtype=np.float64)
|
|
187
|
+
return zeros, zeros.copy(), zeros.copy()
|
|
188
|
+
|
|
189
|
+
# hDelta 180-deg inverted CPU4 profile normalized to unit vector amplitude
|
|
190
|
+
hdelta_home = -self.cpu4_columns / dist_m
|
|
191
|
+
|
|
192
|
+
phi_shift = 3.0 * math.pi / 8.0 # 67.5 deg biological PFL3 shift
|
|
193
|
+
epg_left = np.cos(self.thetas - (psi - phi_shift))
|
|
194
|
+
epg_right = np.cos(self.thetas - (psi + phi_shift))
|
|
195
|
+
epg_anti = np.cos(self.thetas - (psi + math.pi))
|
|
196
|
+
|
|
197
|
+
pfl3_left = np.square(np.maximum(0.0, 0.5 * (hdelta_home + epg_left)))
|
|
198
|
+
pfl3_right = np.square(np.maximum(0.0, 0.5 * (hdelta_home + epg_right)))
|
|
199
|
+
pfl2_boost = np.square(np.maximum(0.0, 0.5 * (hdelta_home + epg_anti)))
|
|
200
|
+
return pfl3_left, pfl3_right, pfl2_boost
|
|
201
|
+
|
|
202
|
+
def steering_cmd(
|
|
203
|
+
self,
|
|
204
|
+
max_turn_rate: float = 1.0,
|
|
205
|
+
current_heading_rad: Optional[float] = None,
|
|
206
|
+
) -> float:
|
|
207
|
+
"""Compute bounded PFL3/PFL2 homebound yaw-rate command in ``[-max_turn_rate, +max_turn_rate]``."""
|
|
208
|
+
if max_turn_rate <= 0.0:
|
|
209
|
+
raise ValueError("max_turn_rate must be > 0")
|
|
210
|
+
|
|
211
|
+
dist_m, home_bearing = self.home_vector()
|
|
212
|
+
if dist_m <= 1e-6:
|
|
213
|
+
return 0.0
|
|
214
|
+
|
|
215
|
+
psi = self._resolve_heading(current_heading_rad)
|
|
216
|
+
pfl3_left, pfl3_right, pfl2_boost = self.pfl3_columns(psi)
|
|
217
|
+
|
|
218
|
+
# Normalized PFL3 lateralized motor drive in Lateral Accessory Lobe (LAL)
|
|
219
|
+
phi_shift = 3.0 * math.pi / 8.0
|
|
220
|
+
norm_factor = 0.5 * self.n_columns * math.sin(phi_shift)
|
|
221
|
+
raw_asym = float(np.sum(pfl3_right) - np.sum(pfl3_left)) / max(norm_factor, 1e-9)
|
|
222
|
+
|
|
223
|
+
# PFL2 boosts turning when facing away from home (resolves 180-deg anti-goal saddle; max mean = 0.25)
|
|
224
|
+
anti_drive = float(np.mean(pfl2_boost))
|
|
225
|
+
if abs(raw_asym) < 1e-4 and anti_drive > 0.18:
|
|
226
|
+
# Break exact 180-deg symmetry deterministically using shortest signed wrap
|
|
227
|
+
err = float(wrap_angle(home_bearing - psi))
|
|
228
|
+
raw_asym = math.copysign(0.25, err if err != 0.0 else 1.0)
|
|
229
|
+
|
|
230
|
+
boosted = raw_asym * (1.0 + self.pfl2_boost_gain * anti_drive)
|
|
231
|
+
arrival_taper = min(1.0, dist_m / self.arrival_radius_m)
|
|
232
|
+
cmd = max_turn_rate * arrival_taper * math.tanh(self.steering_gain * boosted)
|
|
233
|
+
return float(np.clip(cmd, -max_turn_rate, max_turn_rate))
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Central Complex Ellipsoid Body (E-PG) ring attractor and plastic R-neuron compass.
|
|
2
|
+
|
|
3
|
+
Biophysical inspirations:
|
|
4
|
+
- Seelig & Jayaraman (Nature, 2015): single-bump E-PG landmark + angular velocity dynamics
|
|
5
|
+
- Turner-Evans et al. (eLife, 2017) & Green et al. (Nature, 2017): P-EN angular velocity shifters
|
|
6
|
+
- Fisher et al. (Nature, 2019) & Kim et al. (Nature, 2019): Hebbian/anti-Hebbian R-neuron -> E-PG
|
|
7
|
+
associative plasticity for warped visual/magnetic landmark scenes
|
|
8
|
+
- Lyu, Abbott & Maimon (Nature, 2022): phasor harmonic arithmetic across Central Complex columns
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
import math
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def wrap_angle(angle_rad: float | np.ndarray) -> float | np.ndarray:
|
|
21
|
+
"""Wrap angle(s) in radians cleanly into (-pi, pi] with zero discontinuity."""
|
|
22
|
+
wrapped = (np.asarray(angle_rad) + math.pi) % (2.0 * math.pi) - math.pi
|
|
23
|
+
if np.ndim(angle_rad) == 0:
|
|
24
|
+
# Map -pi to +pi consistently if desired, or keep standard [-pi, pi)
|
|
25
|
+
val = float(wrapped)
|
|
26
|
+
return math.pi if val == -math.pi else val
|
|
27
|
+
return wrapped
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def circular_error_rad(est_rad: float | np.ndarray, truth_rad: float | np.ndarray) -> float | np.ndarray:
|
|
31
|
+
"""Signed shortest circular angular difference (est - truth) in (-pi, pi]."""
|
|
32
|
+
return wrap_angle(np.asarray(est_rad) - np.asarray(truth_rad))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CompassDiagnostics:
|
|
37
|
+
"""Snapshot of internal neural state from a RingCompass update step."""
|
|
38
|
+
|
|
39
|
+
heading_rad: float
|
|
40
|
+
bump_amplitude: float
|
|
41
|
+
estimated_gyro_bias_rad_s: float
|
|
42
|
+
mag_gate: float
|
|
43
|
+
sun_gate: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RingCompass:
|
|
47
|
+
"""Topological N-wedge E-PG ring attractor compass with P-EN angular-velocity shifters.
|
|
48
|
+
|
|
49
|
+
Maintains a localized activity bump across ``n_wedges`` (default 16, matching the
|
|
50
|
+
16 Ellipsoid Body wedges of *Drosophila*; also supports ``n_wedges=32`` or any
|
|
51
|
+
even ``N >= 8``). Recurrent E-PG excitation and global Delta7 inhibition sustain
|
|
52
|
+
a single constant-amplitude bump, while asymmetric Left/Right P-EN projections
|
|
53
|
+
rotate the bump proportionally to ``omega_rad_s * dt``.
|
|
54
|
+
|
|
55
|
+
External cues (``mag_heading_rad``, ``sun_heading_rad``) excite localized von-Mises
|
|
56
|
+
Ring-Neuron (R2 / R4d) receptive fields gated by cooperative dendritic coincidence
|
|
57
|
+
with the active E-PG bump. Cues within the attractor basin (~30 deg) smoothly pull
|
|
58
|
+
the bump and adapt a slow homeostatic P-EN bias trim; transient 60-160 deg
|
|
59
|
+
ferromagnetic ship-hull or motor-cable spikes fall onto Delta7-inhibited wedges
|
|
60
|
+
and are naturally rejected without yanking the heading or corrupting bias state.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
n_wedges: int = 16,
|
|
66
|
+
initial_heading_rad: Optional[float] = None,
|
|
67
|
+
*,
|
|
68
|
+
cue_kappa: float = 4.0,
|
|
69
|
+
basin_sigma: float = 0.14,
|
|
70
|
+
mag_gain: float = 1.6,
|
|
71
|
+
sun_gain: float = 3.2,
|
|
72
|
+
bias_adapt_rate: float = 0.55,
|
|
73
|
+
max_bias_rad_s: float = 0.15,
|
|
74
|
+
inhibition_threshold: float = 0.0,
|
|
75
|
+
reanchor_seconds: Optional[float] = 15.0,
|
|
76
|
+
) -> None:
|
|
77
|
+
if n_wedges < 8 or (n_wedges % 2) != 0:
|
|
78
|
+
raise ValueError(f"n_wedges must be an even integer >= 8, got {n_wedges}")
|
|
79
|
+
if cue_kappa <= 0.0:
|
|
80
|
+
raise ValueError("cue_kappa must be positive")
|
|
81
|
+
if basin_sigma <= 0.0:
|
|
82
|
+
raise ValueError("basin_sigma must be positive")
|
|
83
|
+
|
|
84
|
+
self.n_wedges: int = int(n_wedges)
|
|
85
|
+
self.cue_kappa: float = float(cue_kappa)
|
|
86
|
+
self.basin_sigma: float = float(basin_sigma)
|
|
87
|
+
self.mag_gain: float = float(mag_gain)
|
|
88
|
+
self.sun_gain: float = float(sun_gain)
|
|
89
|
+
self.bias_adapt_rate: float = float(bias_adapt_rate)
|
|
90
|
+
self.max_bias_rad_s: float = float(max_bias_rad_s)
|
|
91
|
+
self.inhibition_threshold: float = float(inhibition_threshold)
|
|
92
|
+
self.reanchor_seconds: Optional[float] = (
|
|
93
|
+
None if reanchor_seconds is None else float(reanchor_seconds)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Preferred wedge angles uniformly tiling [-pi, pi)
|
|
97
|
+
self.thetas: np.ndarray = -math.pi + (2.0 * math.pi * np.arange(self.n_wedges, dtype=np.float64)) / self.n_wedges
|
|
98
|
+
self._cos_thetas: np.ndarray = np.cos(self.thetas)
|
|
99
|
+
self._sin_thetas: np.ndarray = np.sin(self.thetas)
|
|
100
|
+
|
|
101
|
+
self._rates: np.ndarray = np.zeros(self.n_wedges, dtype=np.float64)
|
|
102
|
+
self._initialized: bool = False
|
|
103
|
+
self._pen_bias_rad_s: float = 0.0
|
|
104
|
+
|
|
105
|
+
# Track sustained kinematically coherent off-basin cue (for genuine cold re-orientation)
|
|
106
|
+
self._prev_mag_rad: Optional[float] = None
|
|
107
|
+
self._coherent_offbasin_time: float = 0.0
|
|
108
|
+
self._last_mag_gate: float = 0.0
|
|
109
|
+
self._last_sun_gate: float = 0.0
|
|
110
|
+
|
|
111
|
+
if initial_heading_rad is not None:
|
|
112
|
+
self.reset(initial_heading_rad)
|
|
113
|
+
|
|
114
|
+
def _shape_bump_from_angle(self, angle_rad: float) -> np.ndarray:
|
|
115
|
+
"""Construct the canonical Delta7-inhibited E-PG bump centered at ``angle_rad``."""
|
|
116
|
+
raw = np.maximum(0.0, np.cos(self.thetas - angle_rad) - self.inhibition_threshold)
|
|
117
|
+
peak = float(np.max(raw))
|
|
118
|
+
return raw / peak if peak > 1e-12 else raw
|
|
119
|
+
|
|
120
|
+
def reset(self, heading_rad: float = 0.0, *, reset_bias: bool = True) -> None:
|
|
121
|
+
"""Seed or reset the E-PG compass bump to ``heading_rad``."""
|
|
122
|
+
wrapped = float(wrap_angle(heading_rad))
|
|
123
|
+
self._rates = self._shape_bump_from_angle(wrapped)
|
|
124
|
+
self._initialized = True
|
|
125
|
+
if reset_bias:
|
|
126
|
+
self._pen_bias_rad_s = 0.0
|
|
127
|
+
self._prev_mag_rad = None
|
|
128
|
+
self._coherent_offbasin_time = 0.0
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def initialized(self) -> bool:
|
|
132
|
+
"""Whether the E-PG ring bump has been seeded."""
|
|
133
|
+
return self._initialized
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def wedges(self) -> np.ndarray:
|
|
137
|
+
"""Copy of current N-wedge non-negative E-PG firing rates."""
|
|
138
|
+
return self._rates.copy()
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def heading_rad(self) -> float:
|
|
142
|
+
"""Decoded circular population-vector heading in (-pi, pi]."""
|
|
143
|
+
if not self._initialized:
|
|
144
|
+
return 0.0
|
|
145
|
+
c1 = float(np.dot(self._rates, self._cos_thetas))
|
|
146
|
+
s1 = float(np.dot(self._rates, self._sin_thetas))
|
|
147
|
+
if abs(c1) < 1e-15 and abs(s1) < 1e-15:
|
|
148
|
+
return 0.0
|
|
149
|
+
return float(wrap_angle(math.atan2(s1, c1)))
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def heading_deg(self) -> float:
|
|
153
|
+
"""Decoded heading in degrees within (-180, 180]."""
|
|
154
|
+
return math.degrees(self.heading_rad)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def bump_amplitude(self) -> float:
|
|
158
|
+
"""First-harmonic phasor coherence of the E-PG activity bump in [0, 1]."""
|
|
159
|
+
if not self._initialized:
|
|
160
|
+
return 0.0
|
|
161
|
+
total = float(np.sum(self._rates))
|
|
162
|
+
if total <= 1e-12:
|
|
163
|
+
return 0.0
|
|
164
|
+
c1 = float(np.dot(self._rates, self._cos_thetas))
|
|
165
|
+
s1 = float(np.dot(self._rates, self._sin_thetas))
|
|
166
|
+
return float(math.hypot(c1, s1) / total)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def estimated_gyro_bias_rad_s(self) -> float:
|
|
170
|
+
"""Homeostatic P-EN angular-velocity bias estimate in rad/s."""
|
|
171
|
+
return self._pen_bias_rad_s
|
|
172
|
+
|
|
173
|
+
def diagnostics(self) -> CompassDiagnostics:
|
|
174
|
+
"""Return a structured diagnostic snapshot of the latest compass state."""
|
|
175
|
+
return CompassDiagnostics(
|
|
176
|
+
heading_rad=self.heading_rad,
|
|
177
|
+
bump_amplitude=self.bump_amplitude,
|
|
178
|
+
estimated_gyro_bias_rad_s=self._pen_bias_rad_s,
|
|
179
|
+
mag_gate=self._last_mag_gate,
|
|
180
|
+
sun_gate=self._last_sun_gate,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _von_mises_profile(self, cue_angle_rad: float) -> np.ndarray:
|
|
184
|
+
"""Localized R-neuron afferent excitation profile normalized to match E-PG 1st harmonic."""
|
|
185
|
+
# Raised cosine (degree <= 3) has zero harmonic aliasing on N >= 8 rings while staying localized
|
|
186
|
+
prof = np.power(0.5 * (1.0 + np.cos(self.thetas - cue_angle_rad)), 3)
|
|
187
|
+
# Match 1st-harmonic norm to half-wave rectified E-PG bump (which has 1st harmonic = N/4)
|
|
188
|
+
c1 = abs(float(np.dot(prof, np.cos(self.thetas - cue_angle_rad))))
|
|
189
|
+
target_c1 = 0.25 * self.n_wedges
|
|
190
|
+
return prof * (target_c1 / max(c1, 1e-12))
|
|
191
|
+
|
|
192
|
+
def _basin_coincidence_gate(self, cue_angle_rad: float, pred_heading_rad: float) -> float:
|
|
193
|
+
"""Cooperative dendritic coincidence gate between R-neuron input and E-PG bump."""
|
|
194
|
+
dist = 1.0 - math.cos(cue_angle_rad - pred_heading_rad)
|
|
195
|
+
ratio = dist / self.basin_sigma
|
|
196
|
+
return math.exp(-(ratio * ratio))
|
|
197
|
+
|
|
198
|
+
def update(
|
|
199
|
+
self,
|
|
200
|
+
omega_rad_s: float,
|
|
201
|
+
dt: float,
|
|
202
|
+
mag_heading_rad: Optional[float] = None,
|
|
203
|
+
sun_heading_rad: Optional[float] = None,
|
|
204
|
+
mag_confidence: float = 1.0,
|
|
205
|
+
sun_confidence: float = 1.0,
|
|
206
|
+
) -> float:
|
|
207
|
+
"""Advance the E-PG ring attractor by ``dt`` seconds and return ``heading_rad``.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
omega_rad_s:
|
|
212
|
+
Body yaw angular velocity in rad/s (positive = counter-clockwise).
|
|
213
|
+
dt:
|
|
214
|
+
Timestep in seconds (must be >= 0).
|
|
215
|
+
mag_heading_rad:
|
|
216
|
+
Optional raw/calibrated magnetic heading cue in radians.
|
|
217
|
+
sun_heading_rad:
|
|
218
|
+
Optional sky-polarization / sun / visual azimuth cue in radians.
|
|
219
|
+
mag_confidence:
|
|
220
|
+
Scalar confidence multiplier in [0, 1] for ``mag_heading_rad``.
|
|
221
|
+
sun_confidence:
|
|
222
|
+
Scalar confidence multiplier in [0, 1] for ``sun_heading_rad``.
|
|
223
|
+
"""
|
|
224
|
+
if dt < 0.0:
|
|
225
|
+
raise ValueError(f"dt must be non-negative, got {dt}")
|
|
226
|
+
|
|
227
|
+
mag_conf = float(np.clip(mag_confidence, 0.0, 1.0))
|
|
228
|
+
sun_conf = float(np.clip(sun_confidence, 0.0, 1.0))
|
|
229
|
+
|
|
230
|
+
# Auto-seed bump on the very first step if not yet initialized
|
|
231
|
+
if not self._initialized:
|
|
232
|
+
if sun_heading_rad is not None and sun_conf > 0.0:
|
|
233
|
+
self.reset(float(sun_heading_rad), reset_bias=False)
|
|
234
|
+
elif mag_heading_rad is not None and mag_conf > 0.0:
|
|
235
|
+
self.reset(float(mag_heading_rad), reset_bias=False)
|
|
236
|
+
else:
|
|
237
|
+
self.reset(float(omega_rad_s) * float(dt), reset_bias=False)
|
|
238
|
+
if mag_heading_rad is not None:
|
|
239
|
+
self._prev_mag_rad = float(wrap_angle(mag_heading_rad))
|
|
240
|
+
return self.heading_rad
|
|
241
|
+
|
|
242
|
+
# 1. Delta7 first-harmonic readout + P-EN asymmetric velocity shift
|
|
243
|
+
c1 = (2.0 / self.n_wedges) * float(np.dot(self._rates, self._cos_thetas))
|
|
244
|
+
s1 = (2.0 / self.n_wedges) * float(np.dot(self._rates, self._sin_thetas))
|
|
245
|
+
norm1 = math.hypot(c1, s1)
|
|
246
|
+
if norm1 <= 1e-12:
|
|
247
|
+
c1, s1, norm1 = 1.0, 0.0, 1.0
|
|
248
|
+
|
|
249
|
+
dphi = (float(omega_rad_s) - self._pen_bias_rad_s) * float(dt)
|
|
250
|
+
cos_dphi = math.cos(dphi)
|
|
251
|
+
sin_dphi = math.sin(dphi)
|
|
252
|
+
|
|
253
|
+
c_rot = (c1 * cos_dphi - s1 * sin_dphi) / norm1
|
|
254
|
+
s_rot = (c1 * sin_dphi + s1 * cos_dphi) / norm1
|
|
255
|
+
pred_heading = float(wrap_angle(math.atan2(s_rot, c_rot)))
|
|
256
|
+
|
|
257
|
+
# Reconstruct P-EN shifted recurrent E-PG excitation
|
|
258
|
+
rec_drive = c_rot * self._cos_thetas + s_rot * self._sin_thetas
|
|
259
|
+
u_ring = np.maximum(0.0, rec_drive - self.inhibition_threshold)
|
|
260
|
+
u_peak = float(np.max(u_ring))
|
|
261
|
+
if u_peak > 1e-12:
|
|
262
|
+
u_ring = u_ring / u_peak
|
|
263
|
+
|
|
264
|
+
# 2. Multi-cue R-neuron afferent injection with attractor-basin gating
|
|
265
|
+
self._last_mag_gate = 0.0
|
|
266
|
+
self._last_sun_gate = 0.0
|
|
267
|
+
net_innovation = 0.0
|
|
268
|
+
net_gate_weight = 0.0
|
|
269
|
+
|
|
270
|
+
if sun_heading_rad is not None and sun_conf > 0.0:
|
|
271
|
+
sun_ang = float(wrap_angle(sun_heading_rad))
|
|
272
|
+
sun_gate = self._basin_coincidence_gate(sun_ang, pred_heading)
|
|
273
|
+
self._last_sun_gate = sun_gate
|
|
274
|
+
eff_sun = self.sun_gain * sun_conf * sun_gate * dt
|
|
275
|
+
if eff_sun > 0.0:
|
|
276
|
+
u_ring = u_ring + eff_sun * self._von_mises_profile(sun_ang)
|
|
277
|
+
sun_err = float(wrap_angle(sun_ang - pred_heading))
|
|
278
|
+
net_innovation += (self.sun_gain * sun_conf * sun_gate) * sun_err
|
|
279
|
+
net_gate_weight += self.sun_gain * sun_conf * sun_gate
|
|
280
|
+
|
|
281
|
+
if mag_heading_rad is not None and mag_conf > 0.0:
|
|
282
|
+
mag_ang = float(wrap_angle(mag_heading_rad))
|
|
283
|
+
mag_gate = self._basin_coincidence_gate(mag_ang, pred_heading)
|
|
284
|
+
self._last_mag_gate = mag_gate
|
|
285
|
+
|
|
286
|
+
# Check long-horizon kinematic coherence for genuine cold re-anchoring
|
|
287
|
+
if (
|
|
288
|
+
self.reanchor_seconds is not None
|
|
289
|
+
and mag_gate < 0.05
|
|
290
|
+
and sun_heading_rad is None
|
|
291
|
+
and dt > 0.0
|
|
292
|
+
):
|
|
293
|
+
if self._prev_mag_rad is not None:
|
|
294
|
+
d_mag = float(wrap_angle(mag_ang - self._prev_mag_rad))
|
|
295
|
+
rate_mismatch = abs(d_mag - (float(omega_rad_s) - self._pen_bias_rad_s) * dt) / max(dt, 1e-6)
|
|
296
|
+
if rate_mismatch < 0.25: # < ~14 deg/s discrepancy -> kinematically coherent field
|
|
297
|
+
self._coherent_offbasin_time += dt
|
|
298
|
+
else:
|
|
299
|
+
self._coherent_offbasin_time = 0.0
|
|
300
|
+
if self._coherent_offbasin_time >= self.reanchor_seconds:
|
|
301
|
+
self.reset(mag_ang, reset_bias=False)
|
|
302
|
+
self._prev_mag_rad = mag_ang
|
|
303
|
+
return self.heading_rad
|
|
304
|
+
else:
|
|
305
|
+
self._coherent_offbasin_time = 0.0
|
|
306
|
+
|
|
307
|
+
self._prev_mag_rad = mag_ang
|
|
308
|
+
|
|
309
|
+
eff_mag = self.mag_gain * mag_conf * mag_gate * dt
|
|
310
|
+
if eff_mag > 0.0:
|
|
311
|
+
u_ring = u_ring + eff_mag * self._von_mises_profile(mag_ang)
|
|
312
|
+
mag_err = float(wrap_angle(mag_ang - pred_heading))
|
|
313
|
+
net_innovation += (self.mag_gain * mag_conf * mag_gate) * mag_err
|
|
314
|
+
net_gate_weight += self.mag_gain * mag_conf * mag_gate
|
|
315
|
+
|
|
316
|
+
# 3. Winner-take-all Delta7 divisive/subtractive sharpening back onto canonical bump manifold
|
|
317
|
+
c_new = float(np.dot(u_ring, self._cos_thetas))
|
|
318
|
+
s_new = float(np.dot(u_ring, self._sin_thetas))
|
|
319
|
+
new_heading = float(wrap_angle(math.atan2(s_new, c_new)))
|
|
320
|
+
self._rates = self._shape_bump_from_angle(new_heading)
|
|
321
|
+
|
|
322
|
+
# 4. Slow homeostatic P-EN bias adaptation (only when cues coherently overlap the bump!)
|
|
323
|
+
# If omega_meas = omega_true + bias (bias > 0), pred_heading drifts positive, so
|
|
324
|
+
# cue_err = (cue - pred_heading) < 0; thus bias estimate increases with (-cue_err).
|
|
325
|
+
if dt > 0.0 and net_gate_weight > 1e-6 and self.bias_adapt_rate > 0.0:
|
|
326
|
+
weighted_err = net_innovation / max(self.mag_gain, self.sun_gain, 1.0)
|
|
327
|
+
self._pen_bias_rad_s -= self.bias_adapt_rate * weighted_err * dt
|
|
328
|
+
self._pen_bias_rad_s = float(
|
|
329
|
+
np.clip(self._pen_bias_rad_s, -self.max_bias_rad_s, self.max_bias_rad_s)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return self.heading_rad
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class PlasticLandmarkRing:
|
|
336
|
+
"""Hebbian / anti-Hebbian R-neuron -> E-PG plastic ring mapping for warped magnetic scenes.
|
|
337
|
+
|
|
338
|
+
Models associative synaptic plasticity between ``n_ring_neurons`` circularly tuned
|
|
339
|
+
sensory Ring Neurons (R2/R4d) and the ``n_wedges`` Ellipsoid Body E-PG compass wedges
|
|
340
|
+
(Fisher et al., *Nature* 2019; Kim et al., *Nature* 2019).
|
|
341
|
+
|
|
342
|
+
Around steel docks, harbors, or indoor rooms where soft-iron and hard-iron effects
|
|
343
|
+
nonlinearly warp magnetic azimuth (e.g. ``+25 deg * sin(2*psi)``), the plastic
|
|
344
|
+
``W_r_to_epg`` weight matrix learns the inverse distortion online from either:
|
|
345
|
+
1. A clean intermittent reference cue (``reference_heading_rad``, e.g. sun/sky/visual), or
|
|
346
|
+
2. Self-supervised rotational consistency with IMU yaw rate ``omega_rad_s`` across turns.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
n_wedges: int = 16,
|
|
352
|
+
n_ring_neurons: int = 32,
|
|
353
|
+
*,
|
|
354
|
+
learning_rate: float = 4.0,
|
|
355
|
+
r_kappa: float = 56.0,
|
|
356
|
+
prior_strength: float = 0.25,
|
|
357
|
+
compass: Optional[RingCompass] = None,
|
|
358
|
+
) -> None:
|
|
359
|
+
if n_ring_neurons < 8:
|
|
360
|
+
raise ValueError("n_ring_neurons must be >= 8")
|
|
361
|
+
self.n_wedges: int = int(n_wedges)
|
|
362
|
+
self.n_ring_neurons: int = int(n_ring_neurons)
|
|
363
|
+
self.learning_rate: float = float(learning_rate)
|
|
364
|
+
self.r_kappa: float = float(r_kappa)
|
|
365
|
+
|
|
366
|
+
self.compass: RingCompass = (
|
|
367
|
+
compass if compass is not None else RingCompass(n_wedges=self.n_wedges)
|
|
368
|
+
)
|
|
369
|
+
if self.compass.n_wedges != self.n_wedges:
|
|
370
|
+
raise ValueError("compass.n_wedges must match PlasticLandmarkRing.n_wedges")
|
|
371
|
+
|
|
372
|
+
self.r_thetas: np.ndarray = -math.pi + (
|
|
373
|
+
2.0 * math.pi * np.arange(self.n_ring_neurons, dtype=np.float64)
|
|
374
|
+
) / self.n_ring_neurons
|
|
375
|
+
self.epg_thetas: np.ndarray = self.compass.thetas.copy()
|
|
376
|
+
self._cos_epg: np.ndarray = np.cos(self.epg_thetas)
|
|
377
|
+
self._sin_epg: np.ndarray = np.sin(self.epg_thetas)
|
|
378
|
+
self._prev_raw_ang: Optional[float] = None
|
|
379
|
+
|
|
380
|
+
# Initialize R -> E-PG synaptic matrix with weak topographic identity prior
|
|
381
|
+
diff = self.epg_thetas[:, None] - self.r_thetas[None, :]
|
|
382
|
+
self.weights: np.ndarray = float(prior_strength) * np.cos(diff)
|
|
383
|
+
|
|
384
|
+
def encode_r_neurons(self, raw_angle_rad: float) -> np.ndarray:
|
|
385
|
+
"""Compute L2-normalized R-neuron population vector for ``raw_angle_rad``."""
|
|
386
|
+
act = np.exp(self.r_kappa * (np.cos(self.r_thetas - float(raw_angle_rad)) - 1.0))
|
|
387
|
+
norm = float(np.linalg.norm(act))
|
|
388
|
+
return act / norm if norm > 1e-12 else act
|
|
389
|
+
|
|
390
|
+
def map_raw_heading(self, raw_mag_heading_rad: float) -> tuple[float, float]:
|
|
391
|
+
"""Map a warped raw magnetic angle through ``W_r_to_epg`` -> ``(corrected_rad, confidence)``."""
|
|
392
|
+
r_act = self.encode_r_neurons(raw_mag_heading_rad)
|
|
393
|
+
epg_drive = self.weights @ r_act
|
|
394
|
+
c1 = (2.0 / self.n_wedges) * float(np.dot(epg_drive, self._cos_epg))
|
|
395
|
+
s1 = (2.0 / self.n_wedges) * float(np.dot(epg_drive, self._sin_epg))
|
|
396
|
+
amp = float(math.hypot(c1, s1))
|
|
397
|
+
if amp <= 1e-9:
|
|
398
|
+
return float(wrap_angle(raw_mag_heading_rad)), 0.0
|
|
399
|
+
corrected = float(wrap_angle(math.atan2(s1, c1)))
|
|
400
|
+
conf = float(np.clip(amp, 0.0, 1.0))
|
|
401
|
+
return corrected, conf
|
|
402
|
+
|
|
403
|
+
def update(
|
|
404
|
+
self,
|
|
405
|
+
omega_rad_s: float,
|
|
406
|
+
dt: float,
|
|
407
|
+
raw_mag_heading_rad: float,
|
|
408
|
+
*,
|
|
409
|
+
reference_heading_rad: Optional[float] = None,
|
|
410
|
+
learn: bool = True,
|
|
411
|
+
anchor_gain_scale: float = 0.25,
|
|
412
|
+
) -> float:
|
|
413
|
+
"""Advance compass, decode plastic landmark heading, and apply Hebbian/anti-Hebbian learning.
|
|
414
|
+
|
|
415
|
+
When ``reference_heading_rad`` is None (self-supervised mode), ``anchor_gain_scale``
|
|
416
|
+
keeps magnetic pull gentle so accurate short-term P-EN gyro integration sweeps smoothly
|
|
417
|
+
across soft-iron warps while learning their inverse into ``self.weights``.
|
|
418
|
+
"""
|
|
419
|
+
raw_ang = float(wrap_angle(raw_mag_heading_rad))
|
|
420
|
+
if not self.compass.initialized:
|
|
421
|
+
seed = float(reference_heading_rad) if reference_heading_rad is not None else raw_ang
|
|
422
|
+
self.compass.reset(seed)
|
|
423
|
+
heading = self.compass.heading_rad
|
|
424
|
+
else:
|
|
425
|
+
corr_mag, map_conf = self.map_raw_heading(raw_ang)
|
|
426
|
+
eff_mag_conf = (
|
|
427
|
+
map_conf if reference_heading_rad is None else 0.0
|
|
428
|
+
) * float(np.clip(anchor_gain_scale, 0.0, 1.0))
|
|
429
|
+
|
|
430
|
+
heading = self.compass.update(
|
|
431
|
+
omega_rad_s=omega_rad_s,
|
|
432
|
+
dt=dt,
|
|
433
|
+
mag_heading_rad=corr_mag if eff_mag_conf > 0.0 else None,
|
|
434
|
+
sun_heading_rad=reference_heading_rad,
|
|
435
|
+
mag_confidence=eff_mag_conf,
|
|
436
|
+
sun_confidence=1.0,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if learn and dt > 0.0:
|
|
440
|
+
r_act = self.encode_r_neurons(raw_ang)
|
|
441
|
+
target_epg = np.cos(self.epg_thetas - heading)
|
|
442
|
+
pred_epg = self.weights @ r_act
|
|
443
|
+
# Movement-gated Hebbian / anti-Hebbian update (uniform exposure in R-neuron space)
|
|
444
|
+
if self._prev_raw_ang is None:
|
|
445
|
+
d_raw = abs(float(omega_rad_s)) * dt
|
|
446
|
+
else:
|
|
447
|
+
d_raw = abs(float(wrap_angle(raw_ang - self._prev_raw_ang)))
|
|
448
|
+
self._prev_raw_ang = raw_ang
|
|
449
|
+
step_rate = min(0.95, self.learning_rate * max(d_raw, 0.05 * dt))
|
|
450
|
+
self.weights += step_rate * np.outer(target_epg - pred_epg, r_act)
|
|
451
|
+
np.clip(self.weights, -8.0, 8.0, out=self.weights)
|
|
452
|
+
|
|
453
|
+
return heading
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neckcompass
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NeckCompass: Drosophila Central Complex ring-attractor compass and GPS-denied path integrator with natural ferromagnetic spike rejection
|
|
5
|
+
Author: Amandeep Singh Bains
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/indian-aman/neckcompass
|
|
8
|
+
Project-URL: Repository, https://github.com/indian-aman/neckcompass
|
|
9
|
+
Project-URL: Releases, https://github.com/indian-aman/neckcompass/releases
|
|
10
|
+
Project-URL: Issues, https://github.com/indian-aman/neckcompass/issues
|
|
11
|
+
Project-URL: Documentation, https://github.com/indian-aman/neckcompass#readme
|
|
12
|
+
Keywords: neckcompass,necklink,ring-attractor,central-complex,drosophila,compass,ahrs,magnetometer,imu,path-integration,gps-denied,robotics,usv,auv,esp32
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: C
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
25
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: numpy>=1.24
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
|
+
Requires-Dist: matplotlib>=3.7; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
<div align="center">
|
|
38
|
+
|
|
39
|
+
# NeckCompass (`neckcompass` v1.0.0)
|
|
40
|
+
|
|
41
|
+
**Biophysical *Drosophila* Central Complex ring-attractor compass and GPS-denied path integrator with natural ferromagnetic spike rejection — in Python and zero-heap, zero-float ANSI C99 (`316 B` RAM, `~1 us`/step).**
|
|
42
|
+
|
|
43
|
+
[](https://pypi.org/project/neckcompass/)
|
|
44
|
+
[](https://github.com/indian-aman/neckcompass/releases/tag/v1.0.0)
|
|
45
|
+
[](https://pypi.org/project/neckcompass/)
|
|
46
|
+
[](csrc/)
|
|
47
|
+
[](csrc/tools/neckcompass_selftest.c)
|
|
48
|
+
[](tests/test_neckcompass.py)
|
|
49
|
+
[](LICENSE)
|
|
50
|
+
|
|
51
|
+
[**30-Second Quick Start**](#-30-second-quick-start) ·
|
|
52
|
+
[**Plastic Harbor Calibration**](#2-online-soft-iron--harbor-distortion-learning-plasticlandmarkring) ·
|
|
53
|
+
[**Zero-Float ANSI C99 MCU Core**](#3-zero-heap-zero-float-ansi-c99-core-csrc) ·
|
|
54
|
+
[**Honest Benchmarks**](#-honest-empirical-benchmarks-5-seeds--50-hz) ·
|
|
55
|
+
[**When to Use (and When Not To)**](#-when-to-use-neckcompass--and-when-not-to)
|
|
56
|
+
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<p align="center">
|
|
60
|
+
<img src="https://raw.githubusercontent.com/indian-aman/neckcompass/main/assets/neckcompass_benchmarks.png" alt="NeckCompass v1.0.0 Ring Attractor & Path Integrator Benchmarks" width="100%">
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why NeckCompass?
|
|
66
|
+
|
|
67
|
+
Marine USVs, inspection drones, subsea rovers, and factory AGVs operate around steel ship hulls, sheet-pile docks, high-current motor leads, and buried power cables that inject intermittent **`70°–150°` ferromagnetic heading spikes** lasting `0.5–2.0 s`. Standard linear-Gaussian estimators break down in this regime:
|
|
68
|
+
|
|
69
|
+
- **Gyro Dead-Reckoning** ignores magnetic anomalies completely, but MEMS gyro bias (`~0.8–1.5°/s`) drifts unboundedly (`50°–100°+` within minutes).
|
|
70
|
+
- **2-State Circular Extended Kalman Filters (`[heading, gyro_bias]`)** are minimum-variance optimal under textbook Gaussian noise, **but** a `120°` ferromagnetic anomaly feeds through the Kalman cross-covariance straight into the **gyro bias state**, causing both massive heading yanks (`45°+` P95) and post-spike bias hangovers.
|
|
71
|
+
|
|
72
|
+
**NeckCompass** ports the neural navigation circuit of the fruit-fly (*Drosophila melanogaster*) **Central Complex** into a deterministic software and microcontroller library — **Product #3** in the **[NeckLink](https://github.com/indian-aman/necklink)** fruit-fly brain engineering family:
|
|
73
|
+
|
|
74
|
+
1. **Ellipsoid Body (`E-PG`) Ring Attractor + `Delta7` Inhibition + `P-EN` Shifters (`RingCompass`)**: Maintains a single topological activity bump across `N=16` (or `N=32`) circular wedges with **zero `-π ↔ +π` wrap-around discontinuity** (`< 10⁻¹⁵ rad` harmonic aliasing on even-`N` rings). Asymmetric Left/Right `P-EN` projections integrate IMU yaw rate (`omega_rad_s * dt`), while localized `R-neuron` sensory cues (`mag_heading_rad`, `sun_heading_rad`) undergo cooperative dendritic coincidence gating against the active `E-PG` bump — smoothly anchoring gyro drift inside the attractor basin while naturally shunting **`70°–150°` ferromagnetic spikes** (`10.2×` lower RMSE than 2-State EKF).
|
|
75
|
+
2. **Associative `R-Neuron → E-PG` Synaptic Plasticity (`PlasticLandmarkRing`)**: Movement-gated Hebbian / anti-Hebbian ring weight matrix that learns warped local soft-iron and hard-iron magnetic distortion tables online around steel harbors or indoor rooms — with or without an external sun/visual reference.
|
|
76
|
+
3. **Fan-Shaped Body `CPU4` Path Integrator + `PFL3`/`PFL2` Steering (`FanBodyNavigator`)**: `N=16` push-pull opponent memory columns integrate translational speed along the live `E-PG` compass bump with common-mode cancellation, computing exact `(distance_m, home_bearing_rad)` and `±67.5°` phase-shifted `PFL3` + `180°` `PFL2` motor yaw commands to steer straight home during complete **GPS blackouts** (`0.84 m` miss after a `1,125 m` spiky mission).
|
|
77
|
+
4. **Zero-Heap, Zero-Float ANSI C99 Core (`csrc/`)**: Pure `Q15` / `int32_t` 16-wedge ring attractor + `CPU4`/`PFL3` navigator using **`316 bytes` of struct RAM** (`576 bytes` total including `260 B` Flash LUTs), **zero `malloc`**, **zero floating-point instructions**, and **`~1.0 us` per update** — ready for `$4` `ESP32`, `STM32`, `RP2040`, and flight controllers.
|
|
78
|
+
|
|
79
|
+
```mermaid
|
|
80
|
+
flowchart LR
|
|
81
|
+
IMU["IMU Gyro Yaw Rate (omega_rad_s)"] --> PEN["P-EN Left/Right Velocity Shifters"]
|
|
82
|
+
PEN --> EPG["16-Wedge E-PG Ring Attractor + Delta7 Inhibition"]
|
|
83
|
+
MAG["Spiky Magnetometer / Sun Azimuth"] --> RN["R2 / R4d Ring Neurons (Plastic W + Basin Gate)"]
|
|
84
|
+
RN -->|"Coherent cues anchor drift; 70-150 deg spikes shunted"| EPG
|
|
85
|
+
EPG --> CPU4["16 Opponent CPU4 Memory Columns (+ Speed)"]
|
|
86
|
+
CPU4 --> PFL3["PFL3 (+/-67.5 deg) + PFL2 (180 deg) Steering"]
|
|
87
|
+
PFL3 --> HOME["GPS-Denied Homebound Yaw Command"]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ⚡ 30-Second Quick Start
|
|
93
|
+
|
|
94
|
+
### 1. Python (`pip install neckcompass`)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install neckcompass
|
|
98
|
+
# Or directly from source:
|
|
99
|
+
pip install git+https://github.com/indian-aman/neckcompass.git
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Fuse a biased IMU gyro with a spiky magnetometer and steer home through a complete GPS blackout in 14 lines:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import math
|
|
106
|
+
from neckcompass import RingCompass, FanBodyNavigator
|
|
107
|
+
|
|
108
|
+
compass = RingCompass(n_wedges=16, initial_heading_rad=0.0)
|
|
109
|
+
nav = FanBodyNavigator(n_columns=16, compass=compass)
|
|
110
|
+
|
|
111
|
+
# Outbound step @ 50 Hz (dt = 0.02 s): 125-deg hull spike is rejected automatically!
|
|
112
|
+
heading_rad, dist_m, home_bearing_rad = nav.step_with_compass(
|
|
113
|
+
omega_rad_s=0.15,
|
|
114
|
+
speed_m_s=1.5,
|
|
115
|
+
dt=0.02,
|
|
116
|
+
mag_heading_rad=math.radians(125.0), # Transient ferromagnetic spike -> gated out
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# During GPS blackout, generate bounded [-0.8, +0.8] rad/s yaw command back to (0, 0):
|
|
120
|
+
yaw_rate_cmd = nav.steering_cmd(max_turn_rate=0.8)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Run the built-in CLI demo or head-to-head quick benchmark from your terminal:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
neckcompass demo
|
|
127
|
+
neckcompass benchmark-quick
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### 2. Online Soft-Iron / Harbor Distortion Learning (`PlasticLandmarkRing`)
|
|
133
|
+
|
|
134
|
+
When operating repeatedly inside a steel marina slip or indoor robot bay with static nonlinear magnetic warp (`+20° sin(ψ) + 16° sin(2ψ)`), `PlasticLandmarkRing` learns the `R-neuron → E-PG` unwarping matrix online:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from neckcompass import PlasticLandmarkRing
|
|
138
|
+
|
|
139
|
+
plastic = PlasticLandmarkRing(n_wedges=16, n_ring_neurons=32)
|
|
140
|
+
|
|
141
|
+
# Self-supervised during normal turns (or pass reference_heading_rad=sun_rad when sky is visible)
|
|
142
|
+
corrected_heading = plastic.update(
|
|
143
|
+
omega_rad_s=imu_gz,
|
|
144
|
+
dt=0.02,
|
|
145
|
+
raw_mag_heading_rad=raw_mag_rad,
|
|
146
|
+
learn=True,
|
|
147
|
+
)
|
|
148
|
+
unwarped_mag_rad, confidence = plastic.map_raw_heading(raw_mag_rad)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### 3. Zero-Heap, Zero-Float ANSI C99 Core (`csrc/`)
|
|
154
|
+
|
|
155
|
+
Drop [`csrc/neckcompass.h`](csrc/neckcompass.h) and [`csrc/neckcompass.c`](csrc/neckcompass.c) into any `ESP32`, `STM32`, `RP2040`, ArduPilot, or bare-metal firmware project (`-std=c99 -Wall -Wextra -pedantic -Werror`, zero FPU, zero `malloc`):
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
make -C csrc clean all test
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```c
|
|
162
|
+
#include "neckcompass.h"
|
|
163
|
+
|
|
164
|
+
nc_compass_t comp;
|
|
165
|
+
nc_navigator_t nav;
|
|
166
|
+
|
|
167
|
+
void autopilot_init(void) {
|
|
168
|
+
nc_compass_init(&comp);
|
|
169
|
+
nc_compass_reset(&comp, nc_cdeg_to_bam(0));
|
|
170
|
+
nc_navigator_init(&nav, 1500); /* 1.5 m smooth arrival taper */
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* 50 Hz deterministic fixed-point loop (~1.0 us on desktop, < 8 us on ESP32) */
|
|
174
|
+
int32_t autopilot_step_50hz(int32_t gyro_cdeg_s, int32_t mag_cdeg, int32_t speed_mm_s) {
|
|
175
|
+
nc_bam_t heading = nc_compass_update(
|
|
176
|
+
&comp,
|
|
177
|
+
nc_cdeg_to_bam(gyro_cdeg_s),
|
|
178
|
+
20u, /* dt = 20 ms */
|
|
179
|
+
nc_cdeg_to_bam(mag_cdeg), 1u, NC_Q15_ONE,
|
|
180
|
+
0, 0u, 0 /* optional sun/sky sensor */
|
|
181
|
+
);
|
|
182
|
+
nc_navigator_update(&nav, speed_mm_s, 20u, heading);
|
|
183
|
+
return nc_navigator_steering_cmd(&nav, heading, nc_cdeg_to_bam(4500));
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 📊 Honest Empirical Benchmarks (5 Seeds @ `50 Hz`)
|
|
190
|
+
|
|
191
|
+
Reproduce all numbers and regenerate [`assets/neckcompass_benchmarks.png`](assets/neckcompass_benchmarks.png) + [`results/benchmark_summary.json`](results/benchmark_summary.json) locally:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
python scripts/benchmark_neckcompass.py
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
All four estimators receive the **exact same** `50 Hz` sensor streams across 5 seeds (`101–105`): realistic MEMS gyro bias (`~0.9°/s` initial offset + `0.08°/s/√s` random walk + `1.5°/s` white noise) and `3.0°` Gaussian magnetometer noise. The **2-State Circular EKF** is given the exact true clean discrete-time process and measurement noise covariances `(Q, R)` with zero handicap.
|
|
198
|
+
|
|
199
|
+
### 1. Heading Accuracy: Clean Gaussian Noise vs. `70°–150°` Ferromagnetic Spikes
|
|
200
|
+
|
|
201
|
+
Mean ± std across 5 seeds (`180 s` trajectories @ `50 Hz`):
|
|
202
|
+
|
|
203
|
+
| Estimator | **Scenario A: Clean Gaussian RMSE** | **Scenario A: P95** | **Scenario B: Spiky Mag RMSE** | **Scenario B: Spiky Mag P95** | **Scenario B: Max Error** |
|
|
204
|
+
| :--- | :---: | :---: | :---: | :---: | :---: |
|
|
205
|
+
| **`NeckCompass` (`E-PG` 16w)** | `0.43° ± 0.02°` | `0.86° ± 0.03°` | **`1.70° ± 0.18°`** | **`4.27° ± 0.37°`** | **`10.55° ± 1.88°`** |
|
|
206
|
+
| **`2-State Circular EKF`** | **`0.39° ± 0.02°`** *(wins)* | **`0.77° ± 0.03°`** | `17.42° ± 0.93°` | `45.28° ± 2.63°` | `82.53° ± 7.50°` |
|
|
207
|
+
| **`Complementary IMU+Mag`** | `3.64° ± 1.54°` | `5.34° ± 1.80°` | `11.80° ± 0.54°` | `27.77° ± 1.30°` | `47.45° ± 4.87°` |
|
|
208
|
+
| **`Gyro Dead-Reckoning`** | `90.22° ± 32.41°` | `141.70° ± 46.28°` | `84.80° ± 30.74°` | `139.91° ± 53.31°` | `145.98° ± 55.90°` |
|
|
209
|
+
|
|
210
|
+
### 2. Scenario C: `600 s` Outbound Survey + Blind GPS-Blackout Return (`~1,125 m` Total Path)
|
|
211
|
+
|
|
212
|
+
A USV / rover executes a `10-minute` (`900 m`) multi-leg survey in complete GPS blackout (`2%` wheel/water-speed noise + biased MEMS gyro + intermittent `70°–150°` ferromagnetic spikes), then autonomously steers back to `(0, 0)`:
|
|
213
|
+
|
|
214
|
+
| Navigation Stack | **Return-to-Home Miss (`m`)** | **Miss `%` of `1.12 km` Path** | **RAM / State Footprint** | **Step Latency** |
|
|
215
|
+
| :--- | :---: | :---: | :---: | :---: |
|
|
216
|
+
| **`NeckCompass` (C99 `Q15` Core)** | **`0.32 m`** *(158 m selftest)* | **`0.20%`** | **`316 B` RAM (`576 B` w/ LUT)** | **`~1.04 us / step`** |
|
|
217
|
+
| **`NeckCompass` (Python `E-PG + CPU4`)** | **`0.84 ± 0.23 m`** | **`0.07% ± 0.02%`** | `3,143 B` | `171.26 us / step` |
|
|
218
|
+
| **`Complementary IMU+Mag + DR`** | `13.31 ± 3.08 m` | `1.19% ± 0.28%` | `~256 B` | `~6.5 us / step` |
|
|
219
|
+
| **`2-State Circular EKF + DR`** | `14.29 ± 6.95 m` | `1.28% ± 0.63%` | `5,792 B` *(Py)* | `13.58 us / step` *(Py)* |
|
|
220
|
+
| **`Gyro Dead-Reckoning`** | `256.74 ± 52.38 m` | `24.16% ± 5.69%` | `~128 B` | `~2.0 us / step` |
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 🎯 When to Use NeckCompass — and When Not To
|
|
225
|
+
|
|
226
|
+
> [!TIP]
|
|
227
|
+
> **Reach for NeckCompass when:**
|
|
228
|
+
> - **Operating near steel, motors, docks, ship hulls, or power cables**: Intermittent `60°–160°` ferromagnetic spikes yank standard complementary filters and corrupt EKF gyro-bias states; `RingCompass` shunts off-basin anomalies naturally (`1.70°` vs `17.42°` RMSE).
|
|
229
|
+
> - **Surviving GPS blackouts, jamming, or subsea/indoor dropouts**: `FanBodyNavigator` (`CPU4` + `PFL3`/`PFL2`) maintains a bounded, common-mode-cancelled home vector and smooth return controller over `10+ minute` (`> 1 km`) dead-reckoning missions.
|
|
230
|
+
> - **Deploying on tiny FPU-less or ultra-low-power microcontrollers (`$1–$4` MCUs)**: `csrc/neckcompass.c` uses `316 bytes` of RAM, `0` heap allocations, `0` floats, and `~1 us` per step.
|
|
231
|
+
> - **Learning warped harbor or indoor magnetic scenes online**: `PlasticLandmarkRing` unwarps repeatable soft-iron / hard-iron distortions continuously without offline ellipsoid-fitting batch scripts.
|
|
232
|
+
|
|
233
|
+
> [!NOTE]
|
|
234
|
+
> **Stick with a classical EKF / Factor Graph when:**
|
|
235
|
+
> - **Sensors are clean, pre-calibrated, and strictly Gaussian (e.g. open-air UAV far from iron/high-current leads)**: As shown in Scenario A, a properly tuned 2-State Circular EKF achieves slightly lower steady-state RMSE (`0.39°` vs `NeckCompass` `0.43°`) because linear Kalman filtering is minimum-variance optimal under pure Gaussian noise.
|
|
236
|
+
> - **Pure Python ultra-high-rate (`> 10 kHz`) loops without C bindings**: Scalar 2-State Python EKF runs in `~14 us/step` vs `~171 us/step` for 16-wedge NumPy vector ops (`~5.8 kHz`). For `> 5 kHz` embedded or C/C++ loops, compile [`csrc/neckcompass.c`](csrc/neckcompass.c) (`~1.0 us/step`).
|
|
237
|
+
> - **Full 6-DOF visual-inertial SLAM (`SE(3)` pose graphs + loop closure)**: `NeckCompass` is a lightweight 2D/yaw heading & home-vector module (`SO(2)` + `R²`), not a replacement for full 3D LiDAR/camera SLAM when rich 3D visual features and GPU/CPU compute are available.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 🗂️ Repository Layout
|
|
242
|
+
|
|
243
|
+
| Path | Contents |
|
|
244
|
+
| :--- | :--- |
|
|
245
|
+
| [`neckcompass/ring_attractor.py`](neckcompass/ring_attractor.py) | `RingCompass` (`N=16`/`32` `E-PG` ring attractor + `P-EN` shifters + multi-cue basin gating) & `PlasticLandmarkRing` (`R-neuron → E-PG` Hebbian/anti-Hebbian distortion learner) |
|
|
246
|
+
| [`neckcompass/path_integrator.py`](neckcompass/path_integrator.py) | `FanBodyNavigator` (`N=16` opponent `CPU4` vector memory + `PFL3`/`PFL2` GPS-denied homebound steering) |
|
|
247
|
+
| [`neckcompass/cli.py`](neckcompass/cli.py) | `neckcompass` CLI (`neckcompass demo`, `neckcompass benchmark-quick`) |
|
|
248
|
+
| [`csrc/`](csrc/) | Zero-heap, zero-float ANSI C99 fixed-point `Q15` core (`neckcompass.h`, `neckcompass.c`, `Makefile`, and `6/6` `neckcompass_selftest.c`) |
|
|
249
|
+
| [`scripts/benchmark_neckcompass.py`](scripts/benchmark_neckcompass.py) | Reproducible 5-seed empirical benchmark & 3-panel hero chart generator |
|
|
250
|
+
| [`results/benchmark_summary.json`](results/benchmark_summary.json) | Exact 5-seed benchmark metrics and C99/Python latency & memory measurements |
|
|
251
|
+
| [`tests/test_neckcompass.py`](tests/test_neckcompass.py) | `19`-test `pytest` suite covering wrap-around continuity, spike rejection, soft-iron plasticity, GPS-denied return, and CLI |
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 📚 Scientific Foundations
|
|
256
|
+
|
|
257
|
+
1. **Seelig, J. D. & Jayaraman, V.** (2015). Neural dynamics for landmark orientation and angular path integration. *Nature*, 521, 186–191.
|
|
258
|
+
2. **Stone, T. et al.** (2017). An anatomically constrained model for path integration in the bee brain. *Current Biology*, 27(20), 3069–3085.
|
|
259
|
+
3. **Turner-Evans, D. et al.** (2017). Angular velocity integration in a fly heading circuit. *eLife*, 6, e23496.
|
|
260
|
+
4. **Fisher, Y. E., Lu, J., D'Alessandro, I. & Wilson, R. I.** (2019). Sensorimotor experience remaps visual input to a heading network. *Nature*, 576, 121–125.
|
|
261
|
+
5. **Kim, S. S., Hermundstad, A. M., Romani, S., Abbott, L. F. & Jayaraman, V.** (2019). Generation of stable heading representations in diverse visual scenes. *Nature*, 576, 126–131.
|
|
262
|
+
6. **Lyu, C., Abbott, L. F. & Maimon, G.** (2022). Building an allocentric travelling direction signal via vector computation. *Nature*, 601, 92–97.
|
|
263
|
+
7. **Westeinde, E. A. et al.** (2024). Transforming a head direction signal into a goal-oriented steering command. *Nature*, 626, 819–826.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
Released under the [MIT License](LICENSE). Copyright (c) 2026 Amandeep Singh Bains.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
neckcompass/__init__.py,sha256=5yZy-r_Hb_UHwcQiZlKUMWsT2Z5kS3RAM4zlgOqqwQE,481
|
|
2
|
+
neckcompass/__main__.py,sha256=kBwyrlDlO5l5tZ2ubsXpCgOgZ09awszyXUyTo7JzieQ,127
|
|
3
|
+
neckcompass/cli.py,sha256=243Psgro8D0j15JFsn8uFx2XrsDiVMIJAH2R0EOmGMA,11767
|
|
4
|
+
neckcompass/path_integrator.py,sha256=y5ynWIgHDBicqj2urfh5OulJlEgcAMT7zOpulxE2_Ck,9946
|
|
5
|
+
neckcompass/ring_attractor.py,sha256=TPfg3T-5Flb9oYIc8UG-MRHGxGLQEuJRE62IeZ_zawo,20396
|
|
6
|
+
neckcompass-1.0.0.dist-info/licenses/LICENSE,sha256=NSiGDGV_78IuijtD5CKRPFs9pxFunuXeu5leb0Y7_TE,1077
|
|
7
|
+
neckcompass-1.0.0.dist-info/METADATA,sha256=i1asAqvkdi1h0Ik6sjjuMrYKEXkWzdPaRu5nsASi-5A,17637
|
|
8
|
+
neckcompass-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
neckcompass-1.0.0.dist-info/entry_points.txt,sha256=MLxrRlqc7EVHZmmrUPzTRnYHonOVz2amY91M7_3kuG0,53
|
|
10
|
+
neckcompass-1.0.0.dist-info/top_level.txt,sha256=hEcfbJr-svkIC2be1Pb3u2x-SX3cb9ZxNWtsas6F_vs,12
|
|
11
|
+
neckcompass-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amandeep Singh Bains
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
neckcompass
|