标签: 测试验证

  • 19 条迁移规则:migrate_all() 的合成字段不等于原始测量

    结论:process_replay 的历史日志迁移是离线回放输入的兼容层,不是车辆实时消息改写路径。migrate_all() 默认列出 15 条规则,manager_states 加 1 条、panda_states 加 2 条、camera_states 加 1 条,共最多 19 条。核心判定有两条:迁移产出的是合成字段,不等于原始测量;而 migrate() 让所有规则读取同一份原始列表,前一条的替换/新增不会成为后一条的输入——这是兼容性批处理,不是依次变换的管道。本文仅做静态源码与 Python AST 检查,本机缺 capnp,未运行日志迁移、整段回放、模型或实车。

    调度契约

    replay_process() 始终打开 manager 迁移,并按进程发布主题开启 panda 与相机迁移。migrate() 先把输入全量转列表并按原始 which() 分组:每条规则由 @migration 标注输入主题和可选 product,原始日志中只要出现一次该 product,整条规则跳过。结束时先替换、再倒序删除、再追加,最后按 logMonoTime 稳定排序。

    开关 规则数变化 触发条件
    默认 15 条 migrate_all() 的基础规则集
    manager_states +1 条 replay_process() 始终打开 manager 迁移
    panda_states +2 条 按进程发布主题开启
    camera_states +1 条 按进程发布主题开启

    由于每条规则都独立读取同一份原始列表,规则之间的书写顺序在语义上无关紧要;真正决定结果的是「哪些 product 出现在原始日志里」以及各规则自身的触发条件。也正因为如此,同一份日志在不同开关组合下会得到不同的迁移结果,比较前必须先固定开关集合。

    此外,规则跳过是「整条跳过」而非「逐条补齐」:新主题一旦在原始日志中出现,对应旧消息在整个路段都不会被迁移生成。

    逐函数规则表

    下表是按规则入口整理的全量清单。它们的共同特征是:多数规则只在「原始日志缺少对应新主题」时才生成消息,一旦新主题已存在就整条跳过;生成的字段往往直接置 valid=True 或沿用旧时间戳,因此回放里看到的「有效」并不等于原始采集时该值真实有效。

    规则入口 输入到输出或修改 证据/注意点
    migrate_sensorEvents() 旧聚合 sensorEventsDEPRECATED 拆为加速度、陀螺仪、光、磁力、温度消息,删旧消息 新消息无条件置 valid=True,即使旧 Event 无效
    migrate_carParams() 用 opendbc MIGRATION 重写车型指纹,并把每项 carFw.brand 设为当前品牌 为回放统一身份,不是 ECU 固件真实性校验
    migrate_gpsLocation() 当 flags==1 且无 fix 时补 hasFix=True,覆盖内部/外部 GPS 两主题 只做布尔兼容,不重新评估 GNSS 质量
    migrate_deviceState() 取首条 initData.deviceType 写到所有 deviceState 缺任一主题时不操作
    migrate_carOutput() 从旧 carControl.deprecated.actuatorsOutput 合成 carOutput 原始日志只要已有一条 carOutput 就跳过全部缺失时点
    migrate_controlsState() 从旧 controlsState.deprecated 合成 selfdriveState 的使能、告警等字段 合成状态沿用旧 valid/时间
    migrate_carState() 以先前 controlsState.deprecated.vCruise 修正 carState.vCruise/cluster 只在差值大于 0.1 时改;依赖原始遍历顺序
    migrate_liveLocationKalman() 旧姿态、速度、加速度、角速度及健康位转 deviceMotion 缺失向量改为 NaN,时间戳取 Event 的 logMonoTime
    migrate_deviceMotion() 若所有已有 deviceMotion.timestamp 均为 0,逐条补 logMonoTime 混合零/非零时整条规则不操作
    migrate_liveTracks() 旧 liveTracksDEPRECATED 换为 radarTracks.points 仅保留 trackId/dRel/yRel/vRel 等子集
    migrate_driverAssistance() 每条 longitudinalPlan 旁合成空 driverAssistance 存在并不证明旧算法输出了该辅助结果
    migrate_drivingModelData() 从 modelV2 提取帧、动作、车道线与轨迹多项式 meta 列表重复 laneChangeState,遗漏 laneChangeDirection
    migrate_onroadEvents() 旧事件列表换为新 onroadEvents,过滤 DEPRECATED 与无法映射枚举 空成员 RuntimeError 会打印并跳过
    migrate_driverMonitoringState() 旧 DMS 模式、注意力、事件映射为 vision/wheeltouch 策略、三级告警及 lockout 百分比与标定值由旧字段估算,不能当原生轨迹
    migrate_longitudinalPlan() 所有 aTarget==0 且存在 carParams 时,用 speeds/accels 估算 aTarget 与 shouldStop 若任一计划已有非零 aTarget,整条不操作
    migrate_managerState() 用当前 managed_processes 名单重建每条进程列表,统一置 running=True 合成结果不是当时真实进程健康快照
    migrate_pandaStates() 从旧单 panda 转列表,或修改现有列表最后一项的 safetyParam 与 alternativeExperience 需要 carParams,不能拿回放值当 MCU 实际配置读回
    migrate_peripheralState() 按原始 pandaState(s) 的时间与 valid 合成空 peripheralState 不能据此证明原设备供电/风扇等外围指标真实存在
    migrate_cameraStates() 从三路 EncodeIdx 的 frameId 找 segmentId,重建 CameraState 的 frameId/encodeId,缺 SOF 时估算 已有索引但某帧缺匹配则删除该相机状态

    从变换方向看,这些规则大致分三类:拆分与改名(如传感器事件、雷达轨迹、onroad 事件)、估算与合成(如纵向计划、驾驶员监测、外设状态与 carOutput)、统一身份与默认填充(如车型参数、manager 状态、deviceMotion 时间戳)。三类对回放结论的可信度不同,若把合成值当原始值,回归比较会得出错误结论。

    精确发现与证据级别

    1. migrate_drivingModelData() 的循环 for meta_field in ["laneChangeState", "laneChangeState"] 复制同一字段两次;源与目标均有该方向字段,故迁移方向保持 schema 默认值(源码静态确认)。
    2. product 判断只看原始日志主题是否出现,不逐时间戳补齐;新主题存在即阻止该规则为旧消息生成对应时点。
    3. migrate_pandaStates() 启用时无条件要求 carParams;回放需提供完整车辆身份输入。
    4. migrate_cameraStates() 用已见最小 frameId 作无索引回退,既非全段预扫描最小值,也非视频帧实体校验。

    AST 无依赖检查确认了 19 个装饰器规则及其输入/product 声明;本机 import capnp 失败,故未执行 Cap’n Proto 迁移单测。本文不能证明 19 条规则的运行结果、性能或跨版本 schema 兼容。

    对硬件与插件化重构的意义

    历史日志迁移应属于离线验证数据适配插件,与车端实时消息源严格隔离。插件清单要声明输入 schema、产出 schema、字段级变换、合成/丢弃策略与版本,并把「原始观测」「迁移估算」「默认填充」分别标注。车型、panda safety、相机时间与模型输出的迁移数据可以帮助回归比较,但不能给新硬件或车辆授予控制资格;新增硬件版本须保留原始日志、迁移后的日志和差异报告,尤其复核 valid、logMonoTime、方向枚举与安全参数。

    具体到回放实验,建议保留原始日志、迁移后的日志与差异报告三份产物;在比较前先核对被迁移主题的数量、顺序与时间是否单调,再判断差异来自算法改动还是迁移策略本身。缺少这三份产物时,一次「通过」既无法定位回归,也无法解释合成字段的来源。

    常见问题

    迁移后的日志能当原始测量吗?

    不能。迁移产出的是合成字段:如 migrate_sensorEvents() 无条件置 valid=True、migrate_controlsState() 沿用旧 valid/时间、migrate_peripheralState() 合成空结构,因此须把「原始观测」「迁移估算」「默认填充」分别标注。

    一条规则会用到前一条规则生成的消息吗?

    不会。migrate() 让所有规则读取同一份原始列表,前一条的替换/新增不会成为后一条的输入,结束时才统一替换、倒序删除、追加并按 logMonoTime 排序——这是兼容性批处理,不是管道。

    laneChangeDirection 迁移后为什么是默认值?

    migrate_drivingModelData() 的循环 for meta_field in ["laneChangeState", "laneChangeState"] 复制同一字段两次,未处理 laneChangeDirection,因此方向保持 schema 默认值。这是源码静态确认,尚无动态回放证据。

    迁移规则一共是多少条?

    最多 19 条:migrate_all() 默认 15 条,manager_states 加 1 条,panda_states 加 2 条,camera_states 加 1 条;replay_process() 始终打开 manager 迁移,panda 与相机迁移按进程发布主题开启。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PROCESS_REPLAY_SCHEMA_MIGRATION_RULES_OUTPUT_VALIDITY_DEEP_DIVE.md(19 条迁移规则、调度契约与证据级别)
    • 项目 02 —— openpilot-research/PROCESS_REPLAY_VALIDATION_DEEP_DIVE.md(迁移规则所处的回放主链路)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • road/device/view 三套轴向:相机与 IMU 接入的几何契约

    结论:坐标变换链里的矩阵和常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果。三套轴向必须分清:road 为前/左/上,device 为前/右/下,view 为右/下/前;euler2rot_single() 用 Rz(yaw) @ Ry(pitch) @ Rx(roll),角度是弧度。更隐蔽的是「什么时候接受标定值」这件事,模型与 UI 两道门并不一样;此外接入未登记的新设备/传感器键会在静态路径上直接抛 KeyError。以下基于源码快照 c8fb906 的静态阅读与独立探针,未取得实机镜头、安装位姿或模型实体做数值验证。

    坐标系与单位

    坐标系 源码定义 接口要求
    地理坐标 geodetic2ecef_single() 接收纬度、经度、海拔 输入纬经度是度,函数内转弧度;海拔按 ECEF 公式使用长度单位;输出 x/y/z 为同一长度单位(通常米)。
    ECEF / NED LocalCoord 以一处地理或 ECEF 坐标定原点 NED 的 z 朝下;ned2ecef 加回 ECEF 原点,ecef2ned 先减原点。不能把 GNSS 纬经度直接当车身坐标。
    路面/设备/视图 camera.py 轴向定义 road 前/左/上,device 前/右/下,view 右/下/前;view_frame_from_device_frame 是固定轴重排矩阵。
    姿态 euler2rot_single() 使用 Rz(yaw) @ Ry(pitch) @ Rx(roll) 角度用弧度,欧拉角排列为 roll/pitch/yaw;rpyCalib 与 IMU 输出不可擅自交换轴或度/弧度。

    五个文件与关键入口

    几何能力集中在 common/transformations 的五个文件里,各自有明确职责与边界:

    文件与关键入口 实际功能及边界
    camera.py: CameraConfig、DeviceCameraConfig 每路相机用宽、高、单个焦距构造 K(fx=fy、主点在画面中心);此对象不含真实镜头畸变系数或在线外参优化。
    DEVICE_CAMERAS 以 (deviceType, sensor) 选三路相机配置,含部分 unknown 兼容键;AR/OX 的 fisheye 焦距在源码注释里被标为可能不准。
    get_view_frame_from_road_frame()、get_view_frame_from_calib_frame() 按旋转角和高度构造 3x4 外参;模型预处理使用的高度为 0,其他调用不应隐去平移项。
    normalize()、denormalize() 用 K^-1 与 K 在像素坐标与归一化针孔坐标间变换;都不是带畸变的鱼眼矫正。
    device_from_ecef()、img_from_device() 前者依位姿四元数把 ECEF 点转设备系;后者转 view 后除以深度,负深度置 NaN,零深度无显式安全分支。
    model.py: get_warp_matrix() 计算 intrinsics @ view_frame_from_device_frame @ rot_from_euler(...) @ calib_from_model;bigmodel_frame=True 选 sbig 逆矩阵。

    运行链:模型与 UI 的标定门不一样

    modeld 在 extrinsicsCalibration 更新且已见设备/窄路相机状态时,用 (deviceType, sensor) 从 DEVICE_CAMERAS 取配置,按主/辅相机选内参并计算两套 warp。该访问没有默认配置分支——若接入新设备/传感器组合而未登记键,静态代码路径会抛 KeyError。而且这个触发条件没有显式要求 extrinsicsCalibration.valid 或 calStatus=calibrated,所以不能把 modeld 的矩阵更新等同于标定已通过全部质量门。

    行车叠加的 AugmentedRoadView._update_calibration() 同样选择 DEVICE_CAMERAS,但更新外参前显式检查消息更新、valid、三元素 rpyCalib 和 calStatus=calibrated;宽相机还有 wideFromDeviceEuler 路径。也就是说,模型与 UI 的「何时接受标定值」门不同;单凭叠加图像看起来对齐,不能证明模型输入几何或相机身份正确。Calibrator 用姿态估计累积筛选标定块、依 pitch/yaw 范围更新状态,并在文件头明确假设 roll 为零,模型输入不校正 roll。

    退化输入与边界

    独立探针给出了几处具体边界:零角度且输入 med 模型内参时 get_warp_matrix() 为单位矩阵;normalize([2000, 600]) 在宽 1928 的内参下仍返回有限值,因为 normalize() 只把带负值的点置 NaN、不限制图像上界;denormalize() 未给边界时也返回有限值,传入 width=1928, height=1208 后返回 NaN,但它只拒绝 x>width,x==width 仍被接受,所以该边界不是有效像素索引判据。img_from_device() 的批量 (1,3) 背向点返回 NaN,但 (3,) 单点输入会因末尾二维切片触发 IndexError——注释称支持单点,实际不支持。未知传感器键则直接抛 KeyError。

    测试现状方面:test_coordinates.py 含 ECEF/NED 数值样本、单点/批量和错误输入检查,test_orientation.py 含姿态转换一致性;但当前 transformations 测试目录未见独立的镜头畸变、DEVICE_CAMERAS 新键、img_from_device() 单点或 get_warp_matrix() 标定回归测试。用隔离运行器加载两份仓库原测试文件、仅把 OpenpilotTestCase 临时替换为标准 unittest.TestCase 后,12 个原测试方法全部通过——它执行的是原测试方法与原变换函数,但不运行项目测试夹具,不能替代镜头/IMU 实机标定。

    新硬件接入至少需要什么

    1. 为真实相机/传感器身份登记尺寸和内参,避免行车路径上的 KeyError;
    2. 以标定板或实景观测测量焦距、主点、鱼眼畸变、主宽相机相对姿态与安装高度;
    3. 确认 device/road/view 轴向与 rpyCalib 弧度定义,不要让度/弧度或轴序在链路中被悄悄替换;
    4. 对相机 SOF、IMU、GNSS 使用可比较时间基;
    5. 在回放中同时检查模型输入 warp、UI 叠加、定位残差与异常输入。

    应由受控平台层发布标定结果及质量状态,普通插件不得在行车中任意替换几何矩阵或伪造校准完成标志。实机误差、长期漂移与车辆级安全影响仍待台架和封闭场地验证。

    常见问题

    模型输入几何能用 UI 叠加看起来对不对来验证吗?

    不能。模型与 UI 的「何时接受标定值」门不同:UI 显式检查 valid、三元素 rpyCalib 与 calStatus=calibrated,而 modeld 的触发条件没有显式要求这些。叠加图像对齐不等于模型输入几何或相机身份正确。

    接入新的相机或传感器组合为什么会直接抛 KeyError?

    因为 DEVICE_CAMERAS 按 (deviceType, sensor) 取配置,代码没有默认配置分支。未登记键的设备/传感器组合会在静态路径上直接抛 KeyError,必须先登记尺寸与内参。

    img_from_device() 支持单点输入吗?

    注释称支持,但当前实现在单点 (3,) 输入时会因末尾二维切片抛 IndexError,批量 (N,3) 才可用。零深度也没有显式安全分支,调用者须自行处理非有限投影。

    几何常量可以直接搬到新摄像头上吗?

    不能。本文所述矩阵与常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果;AR/OX 的 fisheye 焦距在源码注释里也被标为可能不准。新硬件应重新测量并规定轴向与弧度约定。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GEOMETRY_TRANSFORMS_HARDWARE_DEEP_DIVE.md(坐标变换与相机几何接口契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/run_transformations_source_tests.py(加载原测试文件的隔离运行器,12 个原测试方法通过)
    • 项目 02 —— openpilot-research/probes/test_camera_geometry_plugin_contract.py(相机几何四项隔离探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • test_runner 的并行分组:继承的 setUpClass 为什么会被拆开

    结论:test_runner 的分组只认「类自身」定义的 fixture。make_batches() 检查类的 __dict__,只有类自己写了 setUpClass/tearDownClass 才按类合并;仅仅继承 setUpClass 的测试会被切到多个批次、甚至不同 worker,于是类级 fixture 分别运行。这不是「测试失败」,而是分组契约的静态结果。退出码由批次与收集决定:收集错误或失败返回 1,无记录返回 5,中断返回 2。

    调用与数据流

    main() 先切换到仓库根目录、设置警告策略,再在主进程收集测试。多进程模式把 test ID 字符串而不是测试对象交给 worker,每个批次在 worker 内重新导入/加载。默认 -j 为 CPU 数、-W error 将警告升级为异常,只有 workers >= 2 才创建进程池。

    环节 关键函数/组件 静态契约与观察
    路径转模块名 module_name() 目标必须位于 ROOT 下;将相对 .py 路径转为 dotted 模块名。
    测试收集 collect()、flatten() 默认递归 openpilot/test_*.py;仅无显式目标时应用 IGNORED;导入错误进入 errors,过滤后按完整 ID 去重。
    批次规划 make_batches() 模块有 setUpModule/tearDownModule 则按模块分组;否则仅类自身 __dict__ 含 setUpClass/tearDownClass 才按类分组;其他 ID 按 ceil(tests/(jobs*4)) 分块。
    单批执行 run_batch() 每批重载 suite;批次外层捕获 fixture/导入输出。
    多进程调度 run_parallel() future 完成顺序决定进度输出顺序;批次抛异常时,仅生成该批首个 ID 的 error 记录。
    输出捕获 Capture dup2 在进程级替换 stdout/stderr 文件描述符;仅失败、错误、意外通过保留测试输出。
    结果聚合 Result 六种状态:passed、skipped、xfailed、failed、error、xpassed;子测试失败/错误合并在父测试记录中。
    汇总退出 report() 收集错误或失败状态返回 1;有记录且无失败返回 0;无记录且无收集错误返回 5。

    fixture 与失败路径

    unittest.TestSuite 负责调用模块/类 fixture,runner 只决定哪些 test ID 同批。因为 make_batches() 检查的是类自身字典,它不会把仅继承 setUpClass/tearDownClass 的测试自动按类合并。例如 TestParams 继承 OpenpilotTestCase.setUpClass();若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。这是静态分组结论,不代表该测试已出现并发故障。

    反向地,显式定义类级 fixture 的测试按类同批,有模块级 fixture 的按模块同批。而 TestSensord 和 TestOnroad 只定义小写 setup_class,会被基类调用,但同样不满足 runner 的按类分组条件;对其硬件副作用应使用串行/独占设备执行。

    OpenpilotTestCase.run() 在每个测试外包住环境变量快照、OpenpilotPrefix、manager 清理与 GC 恢复;setUpClass() 另有类级环境快照。它们是测试辅助类自己的隔离语义,不是 runner 对任意测试的全局保证。带 COMMA_HARDWARE_TEST 的用例还会在 setUp() 初始化硬件、关闭省电并终止 athena 进程。

    失败记录也有讲究:Result.mark() 在 fixture 早于 startTest 失败时生成独立的 fixture ID 记录,严重状态 error 优先于 failed;跳过和预期失败的详细原因/堆栈未写入报告。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底,不能期待得到统一汇总。

    因此对带硬件副作用的测试,应显式串行并声明独占资源,而不是假设 runner 会自动隔离;显式目标也不会因此变得无副作用。

    参数与副作用的边界

    现象 源码事实 含义
    -j 0 没有参数校验,会在批次大小计算中除零 负数同样不构成可靠 worker 配置。
    -k 过滤 过滤发生在加载 suite 之后 不能阻止模块导入副作用。
    IGNORED 只约束默认无目标扫描 它不是安全沙箱,显式目录仍会递归发现 test_*.py。
    worker 异常 仅首个批次 ID 被标为 error 不能把未列出的同批测试当作通过。

    另外两点会影响结论的可信度:其一,收集会导入测试模块,即使只用 -k 过滤,过滤也发生在加载 suite 之后,不能阻止模块导入副作用;IGNORED 只约束默认无目标扫描。其二,CLI 的退出码固定为 0/1/2/5:main() 捕获 KeyboardInterrupt 返回 2,空结果返回 5,收集错误或失败返回 1。假 loader 探针验证了重复目标只加载一次、过滤发生在加载后,假 future 验证批次异常时仅首个 ID 被记为 error;这些均不是实进程池或真实 fixture 的执行证据。

    对硬件与插件化重构的意义

    1. 权限分层:纯算法/协议解析测试可以进入默认并行池;任何枚举真实 panda、串口、CAN、相机、刷写、Params 全局路径或终止系统进程的测试都应有显式硬件标签与独立执行环境。
    2. 资源声明:硬件插件测试应声明设备实例、总线、固件模式和独占锁;不要仅依赖类级 fixture 或 -j 1 防止其他进程争用。
    3. 可复现证据:记录源码提交、子模块版本、插件版本、硬件序列号、-W/-j/目标 ID、日志来源与结果;批次异常须标记整批为「未完成/未知」。
    4. 分级门禁:单元测试、离线回放、SIL、隔离台架 HIL、实车验证逐级放行;车端控制或固件修改不可由开发机 runner 的绿色结果单独授权。

    常见问题

    为什么继承来的 setUpClass 不会被按类分组?

    因为 make_batches() 检查的是类自身 __dict__,仅继承 setUpClass/tearDownClass 不满足条件。例如 TestParams 继承 OpenpilotTestCase.setUpClass(),若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。

    -j 0 会怎样?

    没有参数校验,会在批次大小计算中除零;负数同样不构成可靠 worker 配置。该行为由隔离探针验证,不是实进程池执行证据。

    跳过和预期失败的原因会写进报告吗?

    不会。源码中跳过和预期失败的详细原因/堆栈未写入报告,只有测试级耗时与六种状态。因此不能仅凭报告里的 skipped 数量判断原因。

    单 worker 抛异常会得到统一汇总吗?

    不一定。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底;多 worker 模式则仅首个批次 ID 被标为 error,不能把未列出的同批测试当作通过。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TEST_RUNNER_PARALLEL_FIXTURE_CONTRACT_DEEP_DIVE.md(取用了收集/分组/执行契约、fixture 与失败路径、结果与退出码,以及硬件插件重构意义)。
    • 项目 02 —— openpilot-research/NATIVE_TEST_DISCOVERY_BUILD_SKIP_CONTRACT_DEEP_DIVE.md(仅用于确认原生测试缺失即跳过的发现契约)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 回放输出相等≠实车安全:process_replay 的 14 个配置与三道门

    结论:process_replay 用 14 个 ProcessConfig 把「输入日志 → 单进程输出 → 多进程合流」重新跑一遍,再用容差比较参考日志;它产出的是回归证据,不是实车安全证明。决定性细节在于 get_output_msgs() 把输出 logMonoTime 设为输入最大时间加配置里的 processing_time,这是合成时间戳而非真实执行耗时;同时 model_replay 在 CI 环境中即使比较发现差异也把 failed 置假,test_fuzzy 明确排除含安全/控制/模型的 9 个进程。以下是对源码快照 c8fb906 的静态阅读,本地没有 Git 检出、参考日志或模型 LFS 实体,未运行回放。

    回放主链路:14 个配置与合成时间戳

    ProcessConfig 与 CONFIGS 声明各进程的发布输入、订阅输出、忽略字段、触发主题、容差、超时和 VisionIPC 依赖,含 selfdrived、card、controlsd 与模型等共 14 个。这是回放配置,不是受管进程全集;新增插件若要回放,必须补上配置与输入/输出契约。

    replay_process() 先调用 migrate_all() 统一旧日志 schema,再进入多进程调度;return_all_logs 会用新输出替换同主题原日志。ProcessContainer.start() 建立独立 OpenpilotPrefix、写入 Params/环境、建立 Pub/Sub 与可选 VisionIPC,再启动对应受管进程;ReplayContext 用假事件让输入发送与进程的 recv 对齐。

    run_step() 按触发回调积累消息,收集上一周期输出后送入本周期输入;相机主题从 FrameReader 重建 NV12 并经 VisionIPC 发送。到了 get_output_msgs(),输出 logMonoTime 被写成「输入最大时间 + 配置 processing_time」,这一步决定了整个比较链的时间语义。

    组件/函数 输入与行为 输出或限制
    ProcessConfig / CONFIGS 14 个配置声明发布输入、订阅输出、忽略字段、触发主题、容差、超时与 VisionIPC 依赖 回放配置,不是受管进程全集
    replay_process() 先 migrate_all() 统一旧 schema,再调多进程调度;return_all_logs 替换同主题原日志 返回模拟进程消息,不自动验证安全或性能
    ProcessContainer.start() 建隔离前缀、写 Params/环境、建立 Pub/Sub 与可选 VisionIPC 初始化依赖来自 generate_params_config() 与 generate_environ_config()
    ReplayContext 假事件令输入发送与进程 recv 对齐 只控制调度顺序,不等于真实总线抖动或丢帧
    get_output_msgs() 输出 logMonoTime = 输入最大时间 + 配置 processing_time 这是合成时间戳,不是实测执行耗时
    _replay_multi_process() 外部日志消息与进程新产出消息按 logMonoTime 合流,结束时冲刷残余输出 受配置主题图与合成时间戳约束,不能代替实车闭环时序

    还有两处回调值得单独记:ModeldCameraSyncRcvCallback 按单/双摄像头凑齐触发帧,MessageBasedRcvCallback 用指定主题触发周期。ProcessOutputCapture 可选地把被测进程 stdout/stderr 重定向到 /tmp,结束后读回,但仅辅助定位崩溃或告警,不参与消息差异判定。

    初始状态也会显著左右结果。get_custom_params_from_lr() 可从前一段日志取 CarParamsPrevRoute、标定、车辆参数与扭矩学习状态,因此初始状态不一致会造成差异,但这并非必然的算法回归;相机主题到 VisionIPC 流的对应关系另由 vision_meta.py 描述。

    输入迁移、比较与三类测试门

    migrate_all() 组织传感器、车辆状态、定位、驾驶模型等迁移,并按选项补 manager、panda 和相机状态;声明 product 的规则在该主题已存在时跳过。因此比较对象是迁移后的日志,不是原始字节流,新增 schema 必须定义可解释的迁移策略。

    compare_logs() 先过滤忽略主题、要求消息数量及顺序/类型对齐,再清零忽略字段后递归比较 Cap’n Proto,浮点按绝对/相对容差判断;长度或顺序不一致会直接报错。忽略字段不等于相等证明,时间字段通常被忽略。

    test_processes.py 从远端取片段与参考输出,检查输出主题与有效消息占比,再按配置比较并生成字段报告。它的默认覆盖并不全:默认排除 modeld、dmonitoringmodeld,其余 12 个配置中仅 card、controlsd、lagd 跑全部选定车型,其余只取 HYUNDAI/TOYOTA 片段。

    层次 实现与判定 易误读处
    历史 schema migrate_all() / migrate() 汇总替换、插入与删除后按时间排序 比较的是迁移后日志;声明 product 的规则在主题已存在时跳过
    字段比较 compare_logs() 要求数量及顺序/类型对齐,清零忽略字段后递归比较 忽略字段不是相等证明;不同长度或顺序直接报错
    通用回归 test_processes.py 检查输出主题与有效消息占比,再生成字段报告 默认排除两个模型进程,仅三个配置覆盖全部选定车型
    差异报告 diff_process() / diff_report() 按输出主题收集逐字段差异 报告只是解释差异,不能把预期差异自动视为安全可接受
    模型专项 model_replay() 用相机帧跑道路与座舱模型,检查最大/平均执行耗时 PC 路径使用 0.3 容差;CI 下发现差异仍 failed=False
    模糊测试 test_fuzzy.py 生成随机 Cap’n Proto 输入并回放 明确排除 9 个进程(含安全/控制/模型),只覆盖配置子集

    三类门里最容易被高估的是模型专项。PC 路径忽略多组模型字段且使用 0.3 容差;在 CI 环境中即使比较发现差异,主程序也会把 failed=False 用于报告,不能当作严格数值门。

    框架自身的静态风险

    run_step() 把周期操作置于 Timeout 内,但 _replay_multi_process() 最后冲刷残余输出所调用的 get_output_msgs() 并不在这个超时上下文里;它等待的 wait_for_one_event() 默认超时为 -1。若被测进程在尾部不再接收,回放可能卡住。

    另一条待测路径是容器加入 containers 后 start() 中途失败:finally 仍会调用 stop(),而此刻 rc 可能尚未初始化。这两处都是源码推导出的故障路径,尚未复现,不能用一次正常回放证明框架对崩溃也可靠。

    对插件与硬件重构的验证含义

    1. 保留每个插件的输入/输出主题、schema 版本、触发条件、初始 Params 与模拟环境,才能复用 ProcessConfig 与多进程回放;重构前先保存原版参考输出及模型/车型/固件哈希。
    2. 把「消息值回归」「最坏时延」「故障退出」「panda 安全策略」拆成独立门。processing_time 只是合成排序参数,忽略执行时间字段的回放结果不能证明新硬件满足控制周期。
    3. 模型插件需单独补齐 LFS 权重、NV12 帧、目标设备与更严格数值阈值;新车型还须增加能覆盖转向/制动路径的片段与安全测试。
    4. 本地源码归档没有 .git、ref_commit 或 fakedata;test_processes.py 还要求获取当前 Git commit、远端路段与参考日志。本次不运行这些测试,也不把静态阅读标记为「回归通过」。

    常见问题

    回放输出和参考日志一致,就说明改动是安全的吗?

    不一致是回归信号,但一致只是回归证据。get_output_msgs() 用的是「输入最大时间 + processing_time」的合成时间戳,model_replay 在 CI 下即使发现差异也 failed=False,test_fuzzy 又排除安全/控制/模型进程,因此相等不能证明实车安全、最坏时延或新硬件兼容。

    process_replay 覆盖了多少进程?

    ProcessConfig / CONFIGS 声明 14 个配置,含 selfdrived、card、controlsd 与模型等,这是回放配置而非受管进程全集。默认测试还排除 modeld、dmonitoringmodeld,其余 12 个配置中只有 card、controlsd、lagd 跑全部选定车型。

    比较时哪些差异会被忽略?

    compare_logs() 先过滤忽略主题,清零忽略字段后再递归比较 Cap’n Proto,浮点按绝对/相对容差判断,时间字段常被忽略;但消息数量、顺序或类型不齐会直接报错。忽略字段不等于相等证明。

    为什么回放有可能卡住?

    run_step() 的周期操作在 Timeout 内,但最后冲刷残余输出的 get_output_msgs() 不在该超时上下文,它等待的 wait_for_one_event() 默认超时为 -1;若被测进程尾部不再接收就可能卡住。该路径是源码推导的,尚未复现。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PROCESS_REPLAY_VALIDATION_DEEP_DIVE.md(回放主链路、14 个配置与迁移/比较/测试门)
    • 项目 02 —— openpilot-research/PROCESS_REPLAY_SCHEMA_MIGRATION_RULES_OUTPUT_VALIDITY_DEEP_DIVE.md(源文档指向的 19 条迁移规则专题)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11