Skip to content

Repository files navigation

fly-reflex

CI Python License

1. 一句话定位

确定性、微秒级的反射安全层:给任何带动力的机器加一层不经过 AI、不经过任何高层决策的保护—— update() 管「停不停」,scale() 管「给多少」,实测每帧 1.3 µs。

2. 为什么存在

带动力的机器迟早会遇到「已经不对了,但大脑还在想」的那半秒。这半秒里你需要的不是更聪明的判断, 而是一条固定的、快到不用思考的通路。

果蝇身上就有这么一条:视小叶里的 LC4 / LPLC2 神经元群体检测到有东西在迫近,汇聚到 DNp01 巨纤维, 阈值一到直接驱动逃逸肌肉——绕过中央复合体那套慢决策。整条通路固定、几十到几百个神经元、亚十毫秒。

真实工程里这层保护通常长成这样:散在控制循环各处的 if、跟具体机器绑死、写完就没人敢动、 改一个阈值要翻三个文件、出了事也说不清当时是哪条判定触发的。本库把它收成一个对象:

  • 规则是数据:Rule / TaperRule 是 frozen dataclass,能 JSON 往返,能塞日志、塞仪表盘、塞经验库
  • 判定有证据:每个 Verdict 带 reason / rule_id / value / threshold / t,事后能逐条复核
  • 时间由你传:库内从不读时钟,同一份数据回放两遍结果逐帧完全一致
  • 只停不够,还要渐弱:真实系统应该在接近极限时就减小输出,而不是撞线才急停
  • 三道防误触发:连续 N 帧、传感器饱和忽略、上电静默期——这三样是真机上最常见的误停来源

本库不含任何特定机器的专有代码。它不知道你在做外骨骼、机械臂还是电动滑板。

3. 安装

# 纯标准库,零依赖
uv add "fly-reflex @ git+https://github.com/OwenZhao9/fly-reflex@v0.1.0"

# 想用脉冲后端(唯一会引入 numpy 的地方)
uv add "fly-reflex[spiking] @ git+https://github.com/OwenZhao9/fly-reflex@v0.1.0"

或者 pip install "fly-reflex @ git+https://github.com/OwenZhao9/fly-reflex@v0.1.0"。 Python >= 3.11。

从源码开发:

git clone https://github.com/OwenZhao9/fly-reflex && cd fly-reflex
uv sync --all-extras
uv run pytest && uv run ruff check .

4. 60 秒上手

from fly_reflex import Reflex, Rule, TaperRule, Action

reflex = Reflex.rules(
    # 腰部加速度连续 3 帧超过 2.5 g -> 锁存急停;|值| > 3000 当作传感器饱和,忽略该帧
    Rule(id="acc", signal="acc_mag", op=">", threshold=2.5, consecutive=3, ignore_above=3000.0),
    # 电机过热 -> 软停,条件消失自动恢复
    Rule(id="temp", signal="motor_c", op=">", threshold=70.0, action=Action.SOFT_STOP),
    # 关节速度 150 °/s 开始渐弱,260 °/s 输出归零
    tapers=[TaperRule(id="speed", signal="joint_dps", soft=150.0, hard=260.0)],
)
reflex.arm(t=0.0)  # 武装,并开始算 warmup

while True:  # 你的实时环,时间戳由你给
    t, s = read_sensors()  # s 是 {"ax":..., "ay":..., "az":..., "joint_dps":..., "motor_c":...}
    v = reflex.update(t, s)  # 停不停
    k = reflex.scale(t, s)  # 给多少(0.0 ~ 1.0)
    if v.action is Action.HARD_STOP:
        emergency_stop(v.reason)  # 已锁存,只有 reflex.reset() 能解除
    elif v.action is Action.SOFT_STOP:
        send_torque(0.0)
    else:
        send_torque(desired_torque * k)

开箱即用的预设:

reflex = Reflex.from_preset("wearable")  # 穿在人身上
reflex = Reflex.from_preset("bench")  # 设备放桌上自检

跑起来看看:

uv run python examples/quickstart.py        # 上面这段的可执行版
uv run python examples/compare_backends.py  # 规则后端 vs 脉冲后端的对照数据
uv run python examples/bench.py             # 本机 update() 实测耗时

5. API 参考

公开符号一共 6 个:Action、Verdict、Rule、TaperRule、Reflex、FlyReflexError。 单位一律写进字段名(acc_g / gyro_dps / tilt_deg / dt_s …),库本身不做任何单位换算—— 你传进来什么单位,阈值就按什么单位写。

Action

class Action(str, Enum):
    OK = "ok"  # 无事
    SOFT_STOP = "soft"  # 输出归零,条件消失可自动恢复
    HARD_STOP = "hard"  # 锁存,必须显式 reset() 才解除

Verdict(frozen dataclass)

一帧的判定结果 + 证据。

字段 类型 含义
action Action 结论
reason str 人能读懂的原因,例:加速度 3.2 g > 2.5 g(撞击/摔倒)
rule_id str | None 是哪条规则给出的结论;OK 时为 None
value float | None 当时的信号值(单位随 signal)
threshold float | None 当时比的阈值
t float 判定发生的时刻(秒),你传进来的那个 t

to_dict() -> dict / Verdict.from_dict(d) -> Verdict:JSON 往返,json.dumps 直接能吃。

Rule(frozen dataclass)——管「停不停」

Rule(id: str,
     signal: str,                                # sensors 的 key,或派生量名
     op: Literal[">", "<", "abs>", "abs<"],
     threshold: float,                           # 单位随 signal
     action: Action = Action.HARD_STOP,
     consecutive: int = 1,                       # 连续 N 帧满足才触发(防单帧毛刺)
     ignore_above: float | None = None,          # |值| 超过此值视为传感器饱和,忽略该帧
     warmup_s: float = 0.0,                      # arm() 之后多久内不判定该规则(秒)
     message: str = "{id}: {signal}={value:.2f} {op} {threshold}")
  • 比较一律是严格不等号:op=">"、threshold=2.0、value=2.0 → 不触发。
  • ignore_above 也是严格的:|value| > ignore_above 才算饱和;正好相等按正常值判定。
  • message 可用的占位符:{id} {signal} {op} {value} {threshold}。 模板在构造期就试跑一遍,写错立刻 ValueError,不会拖到运行期。
  • 同样有 to_dict() / from_dict()。

TaperRule(frozen dataclass)——管「给多少」

TaperRule(id: str,
          signal: str,
          soft: float,                     # 超过此值开始线性渐弱
          hard: float,                     # 到达此值输出为 0(必须 > soft)
          use_abs: bool = True,
          ignore_above: float | None = None)

三段式,边界含在「安全」那一侧:v <= soft → 1.0;soft < v < hard → 线性;v >= hard → 0.0。 use_abs=False 时是有符号的,可以写 soft=-50, hard=-10 这种单边渐弱。 同样有 to_dict() / from_dict()。

Reflex

@classmethod
def rules(cls, *rules: Rule,
          tapers: Sequence[TaperRule] = (),
          derived: Mapping[str, Callable[[Mapping[str, float]], float]] | None = None) -> Reflex

用若干条规则拼一个反射层。derived 里的函数必须是无状态纯函数(只看传进来的这一帧)。

@classmethod
def from_preset(cls, name: str) -> Reflex        # "wearable" | "bench"
@classmethod
def spiking(cls, *, n_neurons: int = 256, threshold: float = 1.0,
            signals=("acc_mag","gyro_mag","tilt_deg","joint_dps"),
            ranges=None, tau_m_s=0.02, tau_adapt_s=0.25, expansion_gain=2.0,
            refractory_s=0.05, max_dt_s=0.05, ignore_above=3000.0, warmup_s=0.0,
            taper_soft=0.35, taper_hard=0.85, action=Action.HARD_STOP,
            tapers=(), derived=None, seed: int = 0) -> Reflex

脉冲后端,接口与规则后端完全一致。需要 fly-reflex[spiking]。见 §6。

def arm(self, t: float) -> None

武装并重置 warmup 计时。不解除锁存(只有 reset() 能解除),会清空连续帧计数。 没调用 arm() 就直接 update() 也能用:第一帧被当作 arm 时刻,并记一条 logging.warning—— 宁可 warmup 从这里起算,也不让机器处在完全没保护的状态。

def update(self, t: float, sensors: Mapping[str, float]) -> Verdict

判定这一帧。纯计算、无 IO、不阻塞,可以在实时环里调用。 同一帧多条规则命中时取最严重的(HARD_STOP > SOFT_STOP),同严重度取先声明的那条。 已锁存时直接返回当初那条判定(含当初的 t),不再评估规则。

def reset(self) -> None

解除锁存,清空连续帧计数和脉冲网络状态。累计统计(stats())不清零。

@property
def latched(self) -> Verdict | None
def scale(self, t: float, sensors: Mapping[str, float]) -> float
def scale_detail(self, t: float, sensors: Mapping[str, float]) -> dict[str, float]

返回 0.0 ~ 1.0 的输出缩放系数 = 所有 TaperRule 的最小值;scale_detail 给出每条各自的系数(界面用)。 与 update() 相互独立:SOFT_STOP 不影响 scale(),scale() 也不推进 update() 的连续帧计数。 锁存时恒返回 0.0,没有任何 taper 时恒返回 1.0。这两个方法不改任何状态,调多少次都一样; t 只为接口对称保留,当前不参与判定。

def stats(self) -> dict

累计统计,JSON 可直接序列化。形如:

{
    "acc": 1,
    "gyro": 0,  # 每条规则的触发帧数(契约要求的形状)
    "frames": 2891,
    "armed": True,
    "armed_t": 0.0,
    "latched": True,
    "latched_rule": "acc",
    "soft_stops": 0,
    "hard_stops": 1,
    "backend": "rules",
    "triggers": {...},
    "ignored": {...},  # ignored = 因饱和被忽略的帧数
    "skipped": {...},  # skipped = 信号取不到、规则没法判定的帧数
    "tapers": ["speed"],
}

skipped 是发现「传感器 key 拼错了 / 忘了传」的地方——库不会为此报错,但会替你记账。 因此规则 id 不能叫 frames、latched、triggers 这些保留名,构造期会直接 ValueError。

FlyReflexError

本库的构造期异常基类,同时继承 ValueError,所以 except ValueError 和 except FlyReflexError 都接得住。运行期(update / scale)永远不会抛它。

内置派生量

acc_mag(由 ax,ay,az 求模)、gyro_mag(由 gx,gy,gz 求模)。取值顺序: sensors 里的同名 key 优先 → 你传的 derived → 内置。也就是说你自己算好了就直接传值, 没传才会去算。缺轴按 0 计。

6. 后端与配置

规则后端(默认,上机该用的)

纯标准库,每帧 1.3 µs,触发点精确等于你写的阈值。

内置预设(阈值来自契约,不含任何特定机器的专有参数):

预设 acc_mag gyro_mag tilt_deg joint_dps 公共项
wearable > 2.5 g > 300 °/s abs> 45° abs> 450 °/s 全部 HARD_STOP、连续 3 帧、ignore_above=3000
bench > 3.0 g > 400 °/s (不判定) abs> 2500 °/s 同上

预设需要你提供的 sensors key:ax/ay/az、gx/gy/gz(用来算 acc_mag / gyro_mag)、 tilt_deg、joint_dps。没传的 key 对应的规则会被静默跳过(并计入 stats()["skipped"])—— 上线前建议看一眼这个计数。

预设只含停机规则,不含渐弱 taper:渐弱阈值跟具体机器强相关,放进通用库就是瞎猜。

左右两侧分开判定(预设里的 joint_dps 是单路,想要每侧独立的饱和忽略就自己写):

Reflex.rules(
    Rule(
        id="joint_l", signal="ldps", op="abs>", threshold=450.0, consecutive=3, ignore_above=3000.0
    ),
    Rule(
        id="joint_r", signal="rdps", op="abs>", threshold=450.0, consecutive=3, ignore_above=3000.0
    ),
)

脉冲后端(已实现,但请先读完这一段)

reflex = Reflex.spiking(n_neurons=256, threshold=1.0, seed=0)  # 需要 fly-reflex[spiking]

纯 numpy 的小型 LIF 网络,默认 225 个神经元(感觉层 192 + 汇聚层 32 + DNp01 1), 输入是调用方实际拥有的信号(IMU、关节角速度),保留果蝇迫近→逃逸通路的结构特征: 群体编码 → 汇聚阈值 → 绕过高层直接触发。

结构受果蝇 LC4 / LPLC2 → DNp01 通路启发,不是连接组的复刻。 本库没有加载 MaleCNS / FlyWire 连接组,也没有用它们的任何权重——我们没有视觉输入,硬套就是造假。 import 时不下载任何数据集,运行期不打任何网络请求。

设计细节、增益标定方法和已知局限:docs/spiking-backend.md。 与规则后端的对照数据:docs/backend-comparison.md(见 §8)。

线程与实时性

⚠️ 所有实例都不是线程安全的。 一个实例只在一个线程里用。实现里不加锁——加锁会拖慢热路径, 而热路径正是这个库存在的理由。要多线程就一个线程一个实例,或者自己在外面同步。

  • ✅ 可以在实时环里调用:update() / scale() / scale_detail() / latched ——纯计算、无 IO、不阻塞、不读时钟。
  • ⚠️ stats() 会建几个小 dict,别放在最内层循环里每帧调。
  • ❌ 有状态的派生量由调用方负责:derived= 里的函数必须是无状态纯函数(只看当前这一帧)。 需要时间窗的量(例如「最近 1 秒的平均功率」)请你自己算好,以普通 key 放进 sensors 传进来。 库内部不维护任何滑动窗口——因为窗口状态会让「回放两遍结果一致」这件事变得不可能保证。

错误模型

  • 构造期参数错误 → ValueError(具体是 FlyReflexError,它就是 ValueError 的子类)。
  • 运行期永不抛异常到控制环:信号缺失、值不是实数、derived 函数自己炸了、message 模板格式化失败——一律降级为「这条规则本帧跳过」,记一条 logging.warning(同一原因只记一次), 并在 stats() 里计数。
  • 库代码不 print,只用 logging(logger 名 fly_reflex)。有测试守着。

7. 边界:不做什么

  • 不碰硬件:不 import pyserial 之类,不打开串口,不访问任何设备。有测试守着。
  • 不打网络:不下载数据集、不上报、不做任何 HTTP。有测试守着。
  • 不读时钟:时间一律由调用方传入。库里连 import time 都没有,有测试守着。
  • 不维护滑动窗口 / 不做任何时间积分类派生量(脉冲后端的膜电位除外,那是神经元模型本身)。
  • 不做传感器融合、不做姿态解算:tilt_deg 要你自己给,库不从四元数或加计里解算姿态。
  • 不做单位换算:传 g 就按 g 比,传 m/s² 就按 m/s² 比。
  • 不加锁、不起线程、不开进程。
  • 不管通信丢失 / 会话超时 / 数据流频率:这些需要时间窗或 IO 状态,是调用方的事。 真要在本库里表达,把「距上次收到数据的秒数」算好当普通 key 传进来即可。
  • 不替你决定拿 Verdict 做什么:库只给结论和证据,断电、刹车、归零由你实现。
  • 不是功能安全认证件:没有做 IEC 61508 / ISO 13849 这类认证,别把它当唯一的安全措施。 真正的安全系统需要硬件层面的冗余。

8. 验证与实测数据

uv run pytest:121 个测试(不装 numpy 时 96 通过 / 3 跳过),ruff check + ruff format --check 干净。 CI 在 Python 3.11 / 3.12 / 3.13 上跑,并且额外跑一遍不装 numpy 的纯标准库环境。

覆盖点:每条 op 的边界(严格不等号)· consecutive 计数与中断重置 · ignore_above 饱和忽略 (含「饱和帧打断连续计数」)· warmup_s(含 arm() 重置与跨热身边界不累计)· 锁存与 reset() · arm() 不解锁存 · 同严重度/跨严重度的优先级 · 派生量优先级与用户覆盖 · 缺 key / 非实数 / derived 抛异常都不打断控制环 · stats() 形状与 JSON 可序列化 · 构造期校验(重复 id、保留名、 类型、NaN/Inf、坏模板)· scale() 三段与多条取最小 · 有符号 taper · 锁存时 scale() 归零 · scale() 不改状态 · 确定性(两个实例 / 同实例 reset 后回放,逐帧一致)· 预设阈值逐条比对契约 · 触发时刻精确等于「穿过阈值后第 3 帧」· 脉冲后端全套 + 与规则后端对照 · benchmark · 工程纪律(库内无时钟、无 print、无锁、无硬件/网络 import、import fly_reflex 不拉 numpy)。

实测耗时(uv run python examples/bench.py)

Apple M4 / macOS 26.2 / CPython 3.11.15,单线程:

场景 mean p50 p99
update() · wearable 预设(4 条规则) 1.25 µs 1.25 µs 1.67 µs
scale() · 2 条 taper 0.32 µs 0.33 µs 0.38 µs
update() · 脉冲后端(225 个神经元) 11.5 µs 11.3 µs 19 µs

契约要求 update() 在 M 系列上 ≤ 50 µs:规则后端 1.25 µs,约为预算的 1/40; 脉冲后端 11.5 µs 也在预算内。240 Hz 的控制环里,规则后端占用 0.03% 的周期。 p99 偶尔会被 GC / 调度打到 40~100 µs,那是解释器的抖动,不是本库的算法行为。 tests/test_benchmark.py 里的断言阈值放宽到 250 µs,因为 CI runner 比 M 系列慢得多—— 它只用来兜住「某次改动把热路径写崩了」,不是性能声明。

规则后端 vs 脉冲后端(examples/compare_backends.py)

输入是合成回放(3360 帧 @ 240 Hz:站立 → 走路 → 1 帧 700 °/s 毛刺 → 5 帧 ±3276.7 编码器饱和 → 60 ms 内加速度冲到 4.5 g 的撞击):

场景 后端 触发 t (s) 相对事件延迟 事件前误触发 误触发率
A. 撞击/摔倒(事件 t=12.0) rules 12.0417 +41.7 ms 0 0.0000%
A. 撞击/摔倒 spiking 12.0333 +33.3 ms 0 0.0000%
B. 慢速倾倒(穿阈 t=2.4292) rules 2.4375 +8.3 ms 0 —
B. 慢速倾倒 spiking 2.1375 −291.7 ms 1 —

怎么读:

  • 快事件脉冲后端早约 8 ms——迫近通道对「快速增长」敏感,不必等绝对值越线。
  • 慢事件脉冲后端在阈值前 292 ms 就发放(倾角 38° 时)。算不算误触发取决于你的定义, 但事实是:脉冲后端没有可审计的确定阈值。要「触发点精确等于你写的阈值」就用规则后端。
  • 两者在含毛刺和编码器饱和的静息/走路段都是 0 次误触发。

完整表格与多 seed 结果(seed = 0/1/7/42/123 下延迟 29~33 ms、误触发全 0):docs/backend-comparison.md。

数据来源声明:以上全部是合成输入(examples/synthetic.py,可播种、可复现)。 本仓库没有真机录制数据,README 里也不会假装有。

9. 出处与致谢

这条通路和它的工程化,站在下面这些工作的肩膀上:

论文

  • arXiv:2602.17997 —— 全脑连接组图模型驱动果蝇全身运动控制。 本库借的是「一条固定小电路直接驱动行为」这个形状,不是它的连接组数据。
  • arXiv:2508.16792 —— 果蝇连接组在 Loihi 2 神经形态芯片上的仿真。 本库的脉冲后端只是 numpy,没有做神经形态硬件部署。

实现参考

再说一遍红线:本库的脉冲后端结构受 LC4 / LPLC2 → DNp01 通路启发,不是连接组的复刻; 没有加载 MaleCNS / FlyWire 连接组;import 时不下载任何数据集。

10. 许可

MIT,见 LICENSE。


附:待确认(契约留白之处,一律按最保守的解释实现)

契约没说死的地方,这里列出我当时怎么选、为什么这么选。有异议的请提 issue,改动只会走 v0.1.x 补丁号,公开 API 的形状不变。

  1. 饱和帧(ignore_above)会打断 consecutive 计数。 「忽略该帧」既可以理解为「这帧不算数,计数保持」,也可以理解为「这帧不可信,连续中断」。 选了后者——三道机制的目的都是防误触发,而且这与下游现有实现的语义一致。 代价:真实危险事件里如果夹着饱和尖峰,可能凑不满连续 N 帧。
  2. 没 arm() 就 update() → 自动把第一帧当 arm 时刻,并记一条 warning。 另一种做法是「没武装就永远不触发」,但那会让忘记 arm() 的机器完全没有保护,比误触发危险得多。
  3. 锁存期间 update() 直接返回当初那条 Verdict(含当初的 t),不重新评估规则、 不推进连续帧计数。逐帧记录时会看到重复的 t,那是特意的——它标识的是哪次事件锁的。
  4. arm() 不解除锁存,只有 reset() 能解除。
  5. taper 没有 warmup 概念(TaperRule 里没有这个字段)。需要「热身期不出力」请调用方自己按 t 判断。
  6. 信号取不到时:规则本帧跳过(计入 stats()["skipped"]),taper 系数取 1.0(不压输出)。 两者都不会让机器停下来,也都不抛异常——但会在统计里留痕。
  7. 饱和帧的 taper 系数取 1.0(这条 taper 本帧不出力),与「忽略该帧」一致。
  8. scale() / scale_detail() 的 t 参数当前不参与任何判定,保留它只为接口对称, 以及给未来可能的时变 taper 留位置。
  9. stats() 把每条规则的触发次数放在顶层(契约里的 {rule_id: 触发次数, frames: n, ...} 就是这个形状),因此规则 id 不能用保留名,构造期直接报错。触发次数统计的是命中帧数: HARD_STOP 因为锁存只会记 1 次,SOFT_STOP 会每帧记一次。
  10. 预设里的 joint_dps 是单路信号。左右腿分开判定(各自独立的饱和忽略与连续计数)需要自己写 两条规则,§6 给了例子。
  11. tilt_deg 不是内置派生量:契约只规定了 acc_mag 和 gyro_mag 两个内置派生量, 所以 tilt_deg 需要调用方自己给(例如 max(abs(pitch), abs(roll))),或者通过 derived= 传一个纯函数进来。
  12. Rule / TaperRule 也实现了 to_dict() / from_dict():契约第 1 节只在 Verdict 上标了, 但第 0.2 节要求所有公开数据结构都能 JSON 往返,按后者执行。
  13. FlyReflexError 同时继承 ValueError:契约 0.4 既要求构造期抛 ValueError, 又要求每个库有自己的 <Lib>Error 基类,让它同时是两者是唯一不用新增第二个符号的办法。
  14. 脉冲后端的 scale() 读出是无状态的(只看当前帧的群体驱动),这样 「scale() 与 update() 相互独立」这条对两个后端都成立。

About

确定性、微秒级的反射安全层:给任何带动力的机器加一层不经过 AI 的保护。灵感来自果蝇 LC4/LPLC2 → DNp01 迫近-逃逸通路。

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages