# 实测发现

以下数字全部来自本机（Apple M3 Max / 36 GB / macOS 26.5.2）实际运行，不是估算。
复现命令写在每节末尾。

---

## 1. 数据集与导入

| 项目 | 数值 |
|---|---|
| 神经元（保留节点） | **166,700** |
| 连接（有向边） | **25,582,938** |
| 原始突触接触数 | 311,833,243（保留节点间 124,177,617） |
| 抑制性神经元占比 | 30.8% |
| 递质未知神经元 | 178 |
| 行和归一化尺度 | 690.66 |
| 导入耗时（冷启动，含建 CSR + 落缓存） | **10 秒** |
| 缓存体积 | 约 205 MB（`.npz`，未压缩） |

神经元数与连接数与 MaleCNS v1.0 官方公布值**完全一致**，可作为导入正确性的外部校验。

```bash
python -m flydrone.cli probe
```

## 2. 下载：单流 350 KB/s vs 16 线程 2.45 MB/s

官方 GCS 单连接实测 **346 KB/s**，1002.5 MB 的边表按这个速度要 40 分钟以上。
改成 HTTP Range 16 线程分块后稳定在 **2.35–2.45 MB/s，约 7 倍**，整体 12 分钟内完成。

**踩到的坑（已修复）**：最初用 curl 下的半截文件被当成断点续传起点，
`neurotransmitters.feather` 尺寸正确（43,282,834 字节）但 **sha256 不匹配**。
现在的下载器对每个文件做完整 sha256 比对（与 DOOMFLY 的 `source.lock.json` 一致），
不一致就整份重下，不再信任任何未经校验的既有文件。

```bash
python scripts/fetch_malecns.py --dir connectome_data/malecns_v1 --workers 16
```

## 3. 算力：事件驱动带来 15.2 倍加速

全图 2560 万连接，单步神经积分的实测成本：

| 传播方式 | 每步 | 每个 brain tick (50 步) | 1 秒仿真需墙钟 | 实时倍率 |
|---|---|---|---|---|
| `matvec`（读全部权重） | 25.94 ms | 1296.8 ms | 25.9 s | **0.039x** |
| **`events`（只遍历活跃神经元的出边）** | **1.71 ms** | **85.7 ms** | **1.7 s** | **0.583x** |

事件驱动快 **15.2 倍**，这是"整套能不能塞进一台笔记本"的胜负手。
原因很直接：发放率只有百分之几，`matvec` 却要把 205 MB 的权重全读一遍（受内存带宽限制），
而事件驱动的工作量与活跃神经元数成正比，用 `bincount` 做散射比 `np.add.at` 快一个量级。

两条通路在 `tests/test_smoke.py::test_event_and_matvec_propagation_agree` 里逐位对齐，
保证加速没有改变动力学。

**加上感觉前端与机体后的端到端倍率是 1.08x**（略快于实时），因为一个 brain tick
同时覆盖 50 ms 生物时间，而前端开销相对小。

```bash
python -m flydrone.cli bench --steps 100
```

## 4. 注解词表：想当然的列名全军覆没

真实 `superclass` 取值是 `ol_intrinsic`(89,403) / `cb_intrinsic`(32,164) /
`vnc_intrinsic`(13,161) / `visual_projection`(9,201) / `vnc_sensory`(6,370) /
`ol_sensory`(6,098) / `cb_sensory`(4,868) / `descending_neuron`(**1,314**) /
`vnc_motor`(708) …… 我最初猜的 `visual` / `descending` / `motor` 命中数**全部为 0**。

可靠命中的 `cell_type`：R1-R6(3,377)、R7*(1,389)、R8*(1,418)、T4(6,893)、T5(6,806)、
LPLC(417)、HS(12)、VS(34)、DNp*(320)、DNg*(860)、DNd*(26)。

**一个必须如实说明的缺陷：`cell_type` 里没有任何 haltere / campaniform / chordotonal 标注。**
所以"陀螺 → 平衡棒"这条通路在数据层面无法定名，只能退到 `vnc_sensory` 的 8 个神经元。
报告里显示为"机械感觉(平衡棒无法定名)"，不假装它是平衡棒。

```bash
python scripts/inspect_annotations.py
```

## 5. S1 核心结果：下行神经元的**脉冲里没有视觉信息**，膜电位里有

这是整个项目最重要的一次测量。做法：同一个网络看四个不同朝向的场景，
比较读出层的跨朝向差异（信号）与同朝向不同初始条件造成的差异（噪声底）。

| 全局驱动 | 输入增益 | 读出源 | 群体Hz | 读出信号幅值 | 跨朝向差异 | 噪声底 | 信噪比 |
|---|---|---|---|---|---|---|---|
| 0.0 | 6.0 | **spikes** | 2.48 | 0.0000 | **0.00000** | 0.00000 | **0.00** |
| 0.0 | 6.0 | **membrane** | 2.48 | 0.0157 | 0.00786 | 0.00117 | **6.74** |
| 0.0 | 12.0 | membrane | 4.53 | 0.0227 | 0.00232 | 0.00116 | 1.99 |
| 0.0 | 25.0 | membrane | 9.89 | 0.0251 | 0.00390 | 0.00116 | 3.36 |
| 0.4 | 12.0 | spikes | 86.69 | 135.49 | 0.42969 | 0.31250 | 1.38 |
| 0.4 | 12.0 | membrane | 86.69 | 0.4880 | 0.00120 | 0.00182 | 0.66 |
| 1.0 | — | spikes | 236.68 | 348.75 | 0.0781 | 0.2083 | 0.38 |
| 2.0 | — | spikes | 482.26 | 489.27 | 0.00000 | 0.00000 | **0.00** |

三条结论：

1. **不加全局驱动时，感光细胞即使烧到 486 Hz，下行神经元的发放率仍然是 0.00 Hz。**
   权重按行和归一化后，只有 2.5% 神经元被点亮时下游驱动力约 0.025，远低于阈值，信号传两跳就衰减干净。
2. **加全局驱动会立刻自激**：0.5 就到 108 Hz 群体活动、读出 183 Hz；到 1.0–2.0 时全网络饱和
   （读出 349–489 Hz），**信息反而被压平**，信噪比掉到 0.1–0.5。整个可用窗口窄得几乎不存在。
3. **换读膜电位就绕开了这个困境**：同样的零驱动态下，48 个下行神经元的平均膜电位携带
   跨朝向差异 0.00786，噪声底 0.00117，**信噪比 6.74**。

这条不是取巧：昆虫的许多下行神经元本身就是**非脉冲（non-spiking）、传递渐变信号**的，
膜电位/突触电流就是它们的输出。所以"读膜电位"既符合生物学，也顺应了这个简化网络的实际性质。
本工程因此把默认读出源设为 `membrane`。

```bash
python scripts/probe_response.py
```

## 6. 修掉的一个真 bug：姿态环是正反馈

几何姿态控制里 `e_R = ½(R_desᵀR − RᵀR_des)∨` 表示"**当前相对期望**"的误差，
因此力矩必须是 **负** 反馈。我最初写成 `τ = I(kp·e_R + kd·e_ω)`，
结果姿态误差单调增大——机体在 0.2 秒内倾到 41.8° 后翻过去直接坠地。

修正为 `τ = I(−kp·e_R − kd·e_ω) + ω×Iω`，并把姿态环带宽提到位置环的 6.5 倍
（ω_n：15.8 vs 2.45 rad/s，阻尼比 0.89），同时给期望倾角加 34° 物理限幅。
修复后：起飞 → 30° 倾角加速 → 3.6 秒收敛到 (2, 0, 1.2) m，悬停推力 274.5 mN 正好等于重力补偿。

```bash
python scripts/debug_plant.py
```

## 7. 闭环端到端（真实连接组）

| 策略 | 均方跟踪误差 | 终点误差 | 坠机 | 实时倍率 |
|---|---|---|---|---|
| `reference`（比例导引） | 0.317 m | **0.0055 m** | 否 | 1.08x |

内环 + 机体 + 真实连接组全链路的延迟与精度都在可用范围内。

## 8. 通用读出层训不动（负面结果，且被外部独立复现）

用 ES 训练"连接组 20 Hz → 196 参数线性读出 → 四旋翼 setpoint"，10 秒仿真、12 代、
每代 8 个体（`--mode fly`）。跟踪误差 = 位置到目标的平均距离，越小越好：

| 代 | best | median | tracking |
|---|---|---|---|
| 0 | −2.572 | −27.532 | 2.410 |
| 4 | −2.602 | −2.639 | 2.451 |
| 8 | −2.536 | −2.599 | 2.373 |
| 11 | −2.537 | −2.581 | **2.369** |

12 代下来 tracking 从 **2.400 → 2.369（改善 1.3%）**，曲线基本是平的。
（median 从 −27.5 收敛到 −2.58 只是种群向最优个体收敛，不是任务变好。）
本机每次 rollout 约 9.8 s 墙钟，全程 389.9 s。

`alextitonis/fly.ai` 用另一套参数独立撞到同一堵墙，并给出了机制解释：
按 Shiu et al. (*Nature* 2024) 的 tonic/decay，静息膜电位恰好落在发放阈值上
（0.18/(1−0.82) ≈ 1.0），全网络约 4 Hz 自激，**光感受器信号在第一级中继就被抹平**；
而真实层板神经元是渐变信号，离散 LIF 复现不了。他们试过的所有设置里逼近刺激都到不了逼近检测神经元。

**结论**：问题不是在"果蝇不行"，而是**接口选错了**。这直接导出第 9 节的路线修正。

```bash
python -m flydrone.cli train --mode fly --generations 12 --population 8 --seconds 5
```

## 9. 具名回路提取：两个真 bug，以及 55% → 3.1%

### bug A：双向 k 跳在整条中枢链上会吃掉 97.6% 的神经元

MaleCNS 是"脑 + 腹神经索"的整条中枢，平均度约 153。从 14,312 个种子做 2 跳**双向**扩展：

| 跳 | 新增 | 累计 |
|---|---|---|
| 1 | 69,061 | 83,373 |
| 2 | 79,353 | **162,726（97.6%）** |

这样拿到的"子图"既不小也不具名，1 kHz 无从谈起。

### bug B：用带符号权重矩阵做可达性判断，连接会被"抵消掉"

这是更隐蔽的一个。`w[post, pre]` 带符号。若某神经元同时收到兴奋与抑制输入，
`w @ indicator` 的**点积会相互抵消到 0**，`> 0` 判据就把这条真实连接判成"不通"。

实测后果：DNp01（巨纤维）**有 498 个抑制性突触前伙伴**（直接查边表可得），
但用带符号矩阵做逆行 BFS 时这 498 个全部消失，于是得出
"抑制性神经元在核心内 = 0"这个**假结论**。改成在**结构矩阵（全 1）**上跑 BFS 后恢复正常。

### 修法：有向 core-hops + 保护前馈抑制

    核心 = (顺行 N 跳可达自感觉锚点) ∩ (逆行 N 跳可达自运动锚点)
    截断 = 按 dist_in + dist_out 升序、总度数次级，但锚点与"直接投射到输出的抑制性神经元"全保留

锚点用真实命名（前缀匹配，因为 MaleCNS 亚型名形如 `DNg02_b`）：

| | 细胞类型 | 命中 |
|---|---|---|
| 输入 | LPLC2 / LC4 | 185 / 202 |
| 输出 | DNp01 / DNp03 | 2 / 2 |

| 阶段 | 神经元 | 连接 | 占比 |
|---|---|---|---|
| 原始核心（2 跳交集） | 91,948 | — | 55.2% |
| 保护前馈抑制后截断 | **5,145** | **687,542** | 3.1% / 2.7% |

**子图内 4/4 个输出锚点仍可从输入到达**（这是回路"通不通"的硬判据）。
抑制性连接占 **44.0%**（全图 38.0%），其中 754 个直接投射到运动锚点的抑制性神经元被保留
——对应 DesktopFly 自述的"约 1,200 个前馈抑制突触"。

一个暴露出来的系统性偏差：只按"离最短通路最近"排序会**必然丢掉抑制性中间神经元**，
因为它们不在 input→output 的最短兴奋链上。第一版提取因此拿到 **0 个抑制性神经元**，
而"慢速被容忍"恰恰依赖前馈抑制。

### 吞吐

| dt | 频率 | 1 秒仿真墙钟 | 实时倍率 |
|---|---|---|---|
| 0.5 ms | 2000 Hz | 0.092 s | **10.9x** |
| 1.0 ms | 1000 Hz | 0.046 s | **21.8x** |
| 2.0 ms | 500 Hz | 0.023 s | 43.5x |

对比 DesktopFly 用 668 神经元 / 18,968 连接跑 1 kHz —— 我们大了 8 倍，
但因为只有全图的 2.7% 连接，1 kHz 反而有 21.8 倍余量。

```bash
python scripts/extract_circuit.py --preset escape --hops 2 --max-neurons 4000
```

## 10. 青蛙实验：慢速被容忍，快速才触发

把半径 5 cm 的物体从正前方 60 cm 处以速度 v 逼近，按真实几何算角尺寸 θ=2·atan(R/d)
与角速度 dθ/dt，分别送进 LPLC2（尺寸）与 LC4（速度）。判据照搬 DesktopFly：
**巨纤维真发放才算逃逸**，且要看是否赶在物体触身之前（`escape.threshold_hz = 20`，取自 FlyDrones）。

### 速度扫描（每点 5 次，300 ms 试验窗）

| 逼近速度 m/s | 真实回路 | 符号打乱 | 线性对照 |
|---|---|---|---|
| 0.05 | 0%（静默） | 0% | 0% |
| 0.10 | 0%（静默） | 0% | 0% |
| 0.20 | 0%（静默） | 0% | 0% |
| 0.35 | **100%** / 首发放 241 ms | 0% | 0% |
| 0.60 | **100%** / 110 ms | 0% | 100% / 243 ms |
| 1.00 | **100%** / 53 ms | 0% | 100% / 84 ms |
| 1.80 | **100%** / 31 ms | 100% / 206 ms | 100% / 36 ms |
| 3.00 | **100%** / 21 ms | 100% / 119 ms | 100% / 23 ms |
| 5.00 | **100%** / 16 ms | 100% / 79 ms | 100% / 18 ms |

**慢速被容忍是涌现的，不是我把阈值调出来的**：0.2 m/s 以下巨纤维根本不发放（0 Hz），
0.35 m/s 起才越过 20 Hz 判据，且首发放潜伏期随速度单调下降（241 → 16 ms）。

两条对照都变差：
- **符号打乱**（拓扑与突触数不变，只重排兴奋/抑制标签）灵敏度掉了 5 倍以上——
  0.35–1.0 m/s 完全无响应，1.8 m/s 才醒过来，且慢一倍（206 ms vs 31 ms）。
  → **E/I 结构是机制来源，不是装饰。**
- **线性对照**（同一对输入线性相加，无中间回路）在 0.35 m/s 无响应，要到 0.6 m/s 才触发，
  且同速度下更慢（0.6 m/s：243 ms vs 110 ms）。→ **回路比线性滤波多做了事。**

### 通道消融：两路在巨纤维相加（复现 Janelia Card 实验室 2017）

| 条件 | 只给角尺寸 | 只给角速度 | 两路都给 |
|---|---|---|---|
| **真实回路** | **100%** / 114 ms | **100%** / 160 ms | **100% / 54 ms** |
| 符号打乱 | 0% | 0% | **0%** |
| 线性对照 | 0% | 0% | 100% / 84 ms |

真实回路里**单给任一路都能触发**，但两路一起给时 **54 ms** 就触发
——比单给尺寸快 2.1 倍、比单给速度快 3.0 倍。这就是 Card 实验室描述的
"角速度通道与角尺寸通道**在巨纤维里相加**"的协同效应。

反面：线性对照**只有两路都给才有反应**（因为它就是把两路加起来），且更慢（84 ms）；
符号打乱则**三个条件全部失效**。

### 一个被我自己修掉的标定错误

第一版把角速度归一化参考设为 6 rad/s，结果 LC4 几乎拿不到输入，
通道消融里 `speed_only` 恒为 0——**看起来像"角速度通道没用"，
其实是我把它饿死了**。改成 1.0 rad/s（≈57°/s，一次普通逼近就能到的量级）后
`speed_only` 变成 100% / 160 ms，协同效应才显出来。
标定错误的教训写在 `flydrone/reflex.py` 的 `theta_dot_ref_rps` 注释里。

```bash
python scripts/frog_escape.py --gain 1.2 --seeds 5
```

## 11. 关于"塞进本机"的最终答案

| 环节 | 规模 | 本机实测 |
|---|---|---|
| 具名逃逸回路 | 5,145 神经元 / 687,542 连接 | 1 kHz → 21.8x 实时 |
| 青蛙实验（9 速度 × 3 条件 × 5 次 + 消融） | 135 次 300 ms 试验 | **20.6 s** |
| 全图单步（事件驱动） | 166,700 / 25.6M | 1.71 ms |
| 全闭环（连接组 20 Hz + 姿态 500 Hz） | 全图 + 6-DOF | 1.08x 实时 |

**结论：塞得进，而且余量很大。** 但前提是**别用全图做那个反射**——
用 2.7% 连接的具名子回路，才换得起 1 kHz 的步长。

## 12. 搬到浏览器：同一个内核，零安装，21× 余量

参照 `SpikeCalls/FlyDrones` 的做法（Python 导出连接组数组 → JS 用同一套动力学步进），
把整个反射搬进了浏览器，页面在 `docs/index.html`。

### 导出与移植

| 文件 | 内容 | 大小 |
|---|---|---|
| `docs/live/brain.bin` | 全连接组子图的 CSC（`indptr` / `indices` / `values`），魔数 `FBDB`，小端 | 5.52 MB |
| `docs/live/brain.json` | 分组、LIF 参数、反射参数（含招募阈值），外加 Python 参考结果 | 61 KB |
| `docs/live/engine.js` | `neural.py` + `reflex.py` 的逐行移植 | 14.8 KB |

导出用 `tools/export_web_brain.py`。指针是**按突触前分组**的，因为事件驱动需要的正是
"某个突触前神经元的全部出边"——这也正是 Python 端快 15.2 倍的同一条理由。
累加用 `Float64Array`、存储用 `Float32Array`，对齐 Python 的 `np.bincount` → `astype(float32)`。

### 一致性：毫秒级逐点对齐

```bash
node tools/check_web_engine.mjs
```

在确定性条件下（`background_std = 0`，噪声只影响随机流，不影响结论）重跑 Python 的参考实验：

- 9 个速度点：`0.05 / 0.1 / 0.2` 静默，`0.35 / 0.6 / 1 / 1.8 / 3 / 5` 分别在
  **243 / 110 / 54 / 31 / 21 / 16 ms** 触发 —— **两侧完全一致**
- 3 个消融条件：`size_only 117` / `speed_only 160` / `both 54` ms —— **完全一致**

页面上的两张表就是现场用 JS 重算的，**留了一列专门写"一致 / 不一致"**：
一旦哪天改坏了内核，这一列会自己变红，而不是让数字悄悄漂移。
浏览器里实测四个条件也对上了 Python：117 / 160 / 54 ms，0.15 m/s 静默。

### 性能

```bash
node tools/bench_web_engine.mjs
```

| 速度 | 单步 | 实时倍率 |
|---|---|---|
| 0.2 m/s | 0.024 ms | 41.0× |
| 1 m/s | 0.027 ms | 37.6× |
| 3 m/s | 0.043 ms | 23.1× |
| 5 m/s | 0.047 ms | 21.3× |

最坏单步 0.047 ms：60 fps 的 16.7 ms 预算能装 **355 步**，而 1 kHz 神经实时只要 **17 步**
——**余量 20.9×**。速度越高越慢，因为事件驱动的成本正比于发放数，这是符合预期的。

### 移植时踩到的两个坑

1. **魔数字节序。** `brain.bin` 的头 4 字节是 `"FBDB"`，按小端读成 `uint32` 是 `0x42444246`。
   第一版在 JS 里写成 `0x42444642`，直接报"魔数不匹配"。这类错误的好处是响——
   坏处是如果校验只偷看"能不能跑"，就会变成静默的错位读取。
2. **画出来的量和判据用的量不是同一个量。** 曲线一开始画的是巨纤维**瞬时**发放率，
   而判据（20 Hz）用的是 **3 步滑动窗平滑值**。DNp01 只有 2 个细胞，
   瞬时值非 0 即 500 Hz，画出来是一把梳子，跟那条阈值线说的根本不是一回事。
   改成画平滑值之后曲线才有意义。**凡是要给结论配一张图的，先确认图和结论用的是同一个数。**

还有一个交互上的真 bug：早期版本只在"逼近中"才响应通道开关，
于是在定格期间拨开关，**屏幕上留的是旧配置的结论，而勾选框已经是新配置**——
页面在骗人。改成任何条件变化都立刻重跑。

