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.
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ """Allow `python -m neckcompass` execution."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
+ [![PyPI](https://img.shields.io/pypi/v/neckcompass?style=flat-square&logo=pypi&logoColor=white&label=PyPI%20neckcompass&color=3775A9)](https://pypi.org/project/neckcompass/)
44
+ [![Release](https://img.shields.io/badge/release-v1.0.0-0EA5E9?style=flat-square)](https://github.com/indian-aman/neckcompass/releases/tag/v1.0.0)
45
+ [![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://pypi.org/project/neckcompass/)
46
+ [![ANSI C99](https://img.shields.io/badge/C99-zero--heap%20%7C%20zero--float%20%7C%20316%20B%20RAM-00599C?style=flat-square&logo=c&logoColor=white)](csrc/)
47
+ [![C99 Self-Tests](https://img.shields.io/badge/C99%20Q15%20tests-6%2F6%20passed-10B981?style=flat-square)](csrc/tools/neckcompass_selftest.c)
48
+ [![Pytest](https://img.shields.io/badge/pytest-19%2F19%20passed-10B981?style=flat-square)](tests/test_neckcompass.py)
49
+ [![License: MIT](https://img.shields.io/badge/license-MIT-F59E0B?style=flat-square)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ neckcompass = neckcompass.cli:main
@@ -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