本节摘要:状态板是整架飞行器的单一事实源。本节定义它的三块仪表——状态(我在哪)、目标(我去哪)、行为(我在做什么);讲清状态的表示层次(位姿、资源、信念)与目标栈的压弹机制;给出行为仲裁的优先级规则,并解释为什么"两个舱段各写一份状态"是全册要反复围剿的头号病灶。
模拟舱训练里,教员扮演的塔台翻来覆去只问同样的问题:你现在在哪?你要去哪?你正在做什么?答不上来就复训。智能体的驾驶舱也一样——上一节空转通过的主回路,每一拍都在读写这三样数据,而它们必须写在同一块状态板上。本节的任务就是设计这块板:字段怎么定、目标怎么管、两段舱体同时想动舵听谁的。这三件事定不好,后面感知舱、规划舱、行动舱的算法写得再漂亮,也会在接缝上互相打架。
状态板的三块仪表对应三个永恒问题。状态回答"我现在在哪":不只是物理位姿,还包括资源余量(电量、时间预算)与对环境的信念估计;目标回答"我要去哪":可以是单一目标,也可以是一叠按优先级压放的目标栈;行为回答"我正在做什么":当前生效的动作模式名,外加它的进度与来源(规划层指派还是反应层接管)。
一条铁律管住三块仪表:单一事实源(Single Source of Truth)。全机任何舱段想读写状态、目标、行为,都必须经过状态板的接口,禁止私下持有副本。理由在第 1.1 节的误诊里已经预演过:两份状态必然漂移,漂移的故障表现是"时好时坏"——比稳定出错可怕得多,因为它不可复现。这条铁律在工程上还有一个等价说法:状态板是全机唯一的共享可变对象,其余一切数据要么不可变,要么舱段私有。
状态字段不是越多越好,每多一个字段就多一份维护成本和一处不一致的机会。最小字段集按用途倒推:规划舱要用什么,状态板就得有什么。
| 字段组 | 代表字段 | 谁读 | 谁写 |
|---|---|---|---|
| 位姿 | 位置、朝向、速度 | 规划、行动 | 感知(定位)、行动(航位推算) |
| 资源 | 电量、时间预算、载荷余量 | 规划 | 感知(计量)、行动(消耗) |
| 信念 | 障碍图、目标物置信度 | 规划 | 感知(状态估计) |
| 任务 | 目标栈、当前航线、行为名 | 全舱段 | 规划(航线)、行动(回执) |
这张表的关键列是"谁写":每个字段至多一个主要写入者。位姿由感知舱写、行动舱只做航位推算备份,是典型分工;要是行动舱也直接写位姿,字段就有了两个真相,漂移只是时间问题。写不下去了怎么办——比如感知舱定位丢失——字段的降级策略也要预先声明:标记为"过期"而不是删掉,让规划舱看到灰色的仪表而不是黑洞。
部分可观测的空域(第 1.1 节的四属性)还要求状态板存信念状态而非裸读数:障碍物的位置带着置信度、消失在视野里的目标保留记忆估计。信念的更新算法是第 2 章的主题,这里只定契约:信念字段必须带时间戳与置信度,规划舱有权按需查询"这份情报几岁了"。
import threading from dataclasses import dataclass, field from time import monotonic @dataclass class Reading: value: object stamp: float # 单调时钟时间戳 conf: float = 1.0 # 置信度 def age(self): return monotonic() - self.stamp class FlightBoard: """单一事实源:所有跨舱段读写走这扇门。""" def __init__(self, stale_after=2.0): self._lock = threading.RLock() self._data = {} # 字段名 -> Reading self._stale_after = stale_after # 超龄即视为过期 def write(self, key, value, conf=1.0, writer=""): with self._lock: old = self._data.get(key) # 写入者校验:位姿只许感知舱与行动舱推算写 if old and old.writer not in (None, writer) and key in LOCKED: raise PermissionError(f"{writer} 无权写 {key}") self._data[key] = Reading(value, monotonic(), conf, writer) def read(self, key, max_age=None): with self._lock: r = self._data.get(key) if r is None: return None # 黑洞:明确告知没有 if max_age and r.age() > max_age or r.age() > self._stale_after: r.stale = True # 过期:降级为灰色仪表 return r
实现里埋了近防呆:LOCKED 字段的写入者白名单在接口层挡住越权直写,时间戳与置信度强制随身——这两样是第 2 章状态估计和第 3 章不确定性规划的口粮。把契约做进数据结构,比写在文档里指望自觉靠谱得多。
目标比状态难表示,因为它带着"程度":有的目标满足即止(货送到就行),有的目标越优越好(路径越短越省电)。工程上把前者建模为满足型目标(谓词为真即达成),后者建模为效用型目标(评价函数打分取优)。多数真实任务是混合体:送货是满足型,途中的路径选择是效用型。
多目标并存时用目标栈管理:新目标压栈、完成弹栈、被高优先级目标抢占时挂起而非丢弃。塔台插话"先避让"就是一次压栈——避让目标压过送货目标,避让完成、栈顶弹出,送货目标原地恢复。目标栈让"打断与恢复"从特例变成常规操作,这是飞行器应对动态空域的核心机制之一。
from enum import IntEnum class Priority(IntEnum): SAFETY = 0 # 数值最小 = 优先级最高 MISSION = 5 OPPORTUNITY = 9 # 顺路捡货这类机会目标 class GoalStack: def __init__(self): self._stack = [] def push(self, goal): self._stack.append(goal) self._stack.sort(key=lambda g: g.priority) # 栈顶最小优先 def preempt(self, goal): """高优先级目标入栈,返回被挂起的目标清单供日志记录。""" suspended = [g for g in self._stack if g.priority > goal.priority] self.push(goal) return suspended def top(self): return self._stack[0] if self._stack else None def complete_top(self, board): done = self._stack.pop(0) board.write("behavior", f"resume:{done.name}", writer="planner")
仲裁规则随之定型:规划舱永远只服务栈顶目标;同优先级的目标按到达顺序公平排队;安全目标永不让位。栈深要设上限——目标栈长得离谱通常说明任务分解出了问题,该回第 3 章重拆任务,而不是继续压栈。
最后一格仪表是行为。反应式流派(1.2 节)把行为做成了主角:多条行为同时活跃,谁赢?经典答案是优先级仲裁——避障行为永远压过巡航行为,这与目标栈的 SAFETY 优先互为表里:目标栈管"想做什么",行为仲裁管"此刻允许做什么",两道闸都过,舵面才动。
仲裁的工程要点在于行为间接口统一:每个行为对外只报"我建议的动作 + 我的紧迫度",仲裁器按(优先级,紧迫度)排序取头名。把仲裁集中在一个点,出问题时才有唯一的排查位置——分布式仲裁(行为互相感知互相让路)听起来民主,实际上是没人负责。

状态板的联调围绕一致性断言展开:循环里周期性检查"位姿与最近回执是否自洽""目标栈顶与行为名是否对应""每个字段的写入者是否在白名单里"。断言失败即报警,宁可误报也不放过双写。
三类高频病对症下药:状态漂移(同一数据两处真相)——用写入者白名单根治,出事后先查绕过接口的直写路径;目标遗忘(挂起的目标再没恢复)——目标栈操作必须全部落航行日志,第 5 章的黑匣子舱会把这个机制做成硬要求;仪表黑洞(读接口返回 None 引发下游崩溃)——区分"没有数据"与"数据过期",给下游明确的降级语义,禁止用异常当控制流。
⚠️ 常见坑:把状态板做成大而全的上帝对象。字段一旦谁都能写,单一事实源就名存实亡。板子要小、白名单要严、降级语义要明。
至此整机检验收尾,飞行器拿到了滑跑许可。下一章钻进机头的感知舱:仪表上的读数,到底是怎么从物理世界里被造出来的。