fix(pybot×actpred): v3 审计 P2 清零——验收链 fixed_range + 导出断言 + overrun 节流

- v3-P2-1: _npu_test/_onnx_validate 按 normalize_mode 分支(fixed_range 免 stats,
  HailoPredictor 透传模式,torch 参考同口径反归一化)
- v3-P2-2: export_deploy/export_onnx 一致性 diff 超 1e-4 即 sys.exit(P2-f 闭环)
- v3-P2-3: run_loop overrun 告警节流(第1次+每100次),降 hz 建议仅 >10% 输出
- P2-a1: default_column_weights 扩展列(action_dim>18)默认 1.0
- P2-c: 补 predictor 异常清理路径测试(异常传播 + finally 落盘)
- P2-d/P2-e: Resize 口径与 resume scheduler T_max 语义文档化
- R2-4: _set.sh 安装后断言 CPU 版 torch,误装 CUDA 即拦截
测试: pybot 89 passed (+1)、actpred 148 passed/18 skipped
This commit is contained in:
qing committed 2026-08-14 23:01:56 +08:00
1 parent ad3a624b4c
commit 9179281420
13 files changed
+161 -54

No files matched your search

+1 -1
View File
@@ -179,7 +179,7 @@ recorder.flush()
| `--num-workers` | 2 | DataLoader 工作进程数 |
| `--checkpoint-dir` | ./checkpoints | 检查点保存目录 |
| `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) |
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) |
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch;scheduler 的 T_max 一并恢复,resume 时改 `--epochs` 不影响 LR 余弦曲线,只影响循环边界) |
| `--save-config` | 无 | 保存配置到 JSON 文件 |
| `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) |
| `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) |
+21 -7
View File
@@ -57,11 +57,13 @@ model.eval()
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d)
imu_dim = cfg.data.imu_dim
# 归一化模型:torch 参考在喂入前 z-score;HailoPredictor 传 stats_path
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)
# 归一化模型:torch 参考在喂入前归一化;HailoPredictor 传 normalize_mode
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)。
# fixed_range 免 action_stats.npy(该模式训练不产出统计文件)。
import os
stats_path = None
if cfg.data.normalize_actions:
norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
if norm_mode == "zscore":
stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
if not os.path.isabs(stats_path):
@@ -70,6 +72,8 @@ if cfg.data.normalize_actions:
if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
print(f"HailoPredictor stats: {stats_path}")
elif norm_mode == "fixed_range":
print("fixed_range 归一化:免 action_stats.npy,按固定物理范围 min-max")
from torchvision import transforms
tf = transforms.Compose([
@@ -82,9 +86,12 @@ tf = transforms.Compose([
def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
h = torch.tensor(hist).unsqueeze(0)
if stats_path is not None:
if norm_mode == "zscore" and stats_path is not None:
stats = np.load(stats_path)
h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1])
elif norm_mode == "fixed_range":
from action_norm import normalize_action
h = torch.from_numpy(normalize_action(h.numpy(), cfg.data.action_dim))
ii = None
if imu_dim > 0:
# 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一
@@ -104,7 +111,10 @@ def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
# R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化)
imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32)
with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
# normalize_actions=False 时 normalize_mode 无意义(stats_path=None 即不归一化)
with HailoPredictor(HEF_PATH, stats_path=stats_path,
normalize_mode=(norm_mode if norm_mode != "none"
else "zscore")) as p:
print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} "
f"history={p.history_len} action_dim={p.action_dim}")
t0 = time.perf_counter()
@@ -130,9 +140,13 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
ref = torch_pred(imgs_real, hist_real, True, imu_real)
ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real)
# torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化
# torch 参考是模型原始归一化空间输出;HailoPredictor 已反归一化
# 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义
if stats_path is not None:
if norm_mode == "fixed_range":
from action_norm import denormalize_action
ref = denormalize_action(ref, cfg.data.action_dim)
ref_zero_pred = denormalize_action(ref_zero_pred, cfg.data.action_dim)
elif stats_path is not None:
s = np.load(stats_path)
ref = ref * s[1] + s[0]
ref_zero_pred = ref_zero_pred * s[1] + s[0]
+9 -2
View File
@@ -25,7 +25,8 @@ model.load_state_dict(ck["model_state_dict"])
model.eval()
action_stats = None
if cfg.data.normalize_actions:
norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
if norm_mode == "zscore":
import os
stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
@@ -37,6 +38,8 @@ if cfg.data.normalize_actions:
raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim}),"
f"实际为 {action_stats.shape}: {stats_path}")
print(f"history 将 z-score 归一化({stats_path})")
elif norm_mode == "fixed_range":
print("history 将 fixed_range min-max 归一化(action_norm.py,免 stats)")
sess = ort.InferenceSession(ONNX)
in_names = [i.name for i in sess.get_inputs()]
@@ -62,7 +65,11 @@ hist_real = np.array([
], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32)
if action_stats is not None:
if norm_mode == "fixed_range":
from action_norm import normalize_action
hist_real = normalize_action(hist_real, cfg.data.action_dim)
hist_zero = normalize_action(hist_zero, cfg.data.action_dim)
elif action_stats is not None:
hist_real = (hist_real - action_stats[0]) / action_stats[1]
hist_zero = (hist_zero - action_stats[0]) / action_stats[1]
+11
View File
@@ -16,6 +16,17 @@ pip install onnxscript onnxruntime -i https://pypi.tuna.tsinghua.edu.cn/simple
pip check
mkdir -p checkpoints data
# R2-4 防呆:确认装到的是 CPU 版 torch——requirements.txt 含 torch,
# 若被镜像源重装成 CUDA 版(无 GPU 部署会被 CUDA 依赖拖垮)在此拦截
python3 -c "
import torch
assert torch.version.cuda is None, (
f'检测到 CUDA 版 torch({torch.__version__}),'
'请用 _set.sh 的 CPU 源重装: pip install torch torchvision '
'--index-url https://download.pytorch.org/whl/cpu --force-reinstall')
print('torch CPU build OK:', torch.__version__)
"
# Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple)
python3 -c "
import torch, sys, os
+5 -1
View File
@@ -54,9 +54,13 @@ def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray:
def default_column_weights(action_dim: int = 18) -> np.ndarray:
"""逐列损失权重默认表:舵机 1.0(安全关键)、spk 0.2(保留梯度)、
保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)"""
保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)。
action_dim>18 的扩展列默认 1.0:超出当前 18 列语义布局的通道视为
新执行器,静默给 0 会让其完全不参与训练(P2-a1)。"""
w = np.zeros(action_dim, dtype=np.float32)
w[:min(10, action_dim)] = 1.0
if action_dim > 10:
w[10:min(12, action_dim)] = 0.2
if action_dim > 18:
w[18:] = 1.0
return w
+4
View File
@@ -43,6 +43,10 @@ class ActionDataset(Dataset):
self.rollout_len = max(1, rollout_len)
self.transform = transforms.Compose([
# Resize 不保持宽高比(直接拉伸)是有意为之:推理端
# (predict/onnx_infer/_onnx_validate)使用完全相同的预处理,
# 训练/推理口径必须一致;改为 letterbox 会使既有 checkpoint
# 输入分布失配,如需变更须训练/推理同步修改并重训(P2-d)
transforms.Resize((image_height, image_width)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+15 -2
View File
@@ -13,6 +13,7 @@ Reshape 序列。此脚本逐项改造(权重数值完全不变):
import argparse
import os
import sys
import numpy as np
import torch
@@ -21,6 +22,10 @@ import torch.nn as nn
from config import parse_checkpoint_config
from model import ActionPredictor
# 一致性校验阈值:wrapper 改造/ORT 转换与原模型输出差异超过即拦截退出,
# 防止 ManualAttnBlock 依赖的 PyTorch 布局约定变更静默穿透(v3-P2-2)
MAX_TOLERATED_DIFF = 1e-4
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
@@ -280,7 +285,11 @@ def main():
with torch.no_grad():
orig_out = model(stacked, dummy_history.squeeze(1),
dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None)
print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}")
wrap_diff = (ref.squeeze(1) - orig_out).abs().max().item()
print(f"wrapper 与原模型最大差异: {wrap_diff:.6e}")
if wrap_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: wrapper 与原模型差异 {wrap_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}(ManualAttnBlock 布局约定可能已失效)")
try:
import onnxruntime as ort
@@ -292,7 +301,11 @@ def main():
if imu_dim > 0:
feeds["imu"] = dummy_imu.numpy()
ort_out = sess.run(None, feeds)[0]
print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})")
ort_diff = np.abs(ort_out - ref.numpy()).max()
if ort_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: ONNX 与 wrapper 差异 {ort_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}")
print(f"ONNX 一致性验证通过(最大差异: {ort_diff:.6e})")
except ImportError:
pass
+8
View File
@@ -1,9 +1,14 @@
import argparse
import sys
import torch
from torch.export import Dim
from config import parse_checkpoint_config
from model import ActionPredictor
# 一致性校验阈值:ORT 转换与原模型输出差异超过即拦截退出(v3-P2-2)
MAX_TOLERATED_DIFF = 1e-4
def main():
parser = argparse.ArgumentParser(description="Export actpred model to ONNX")
@@ -75,6 +80,9 @@ def main():
with torch.no_grad():
torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy()
max_diff = np.abs(ort_out - torch_out).max()
if max_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: ONNX 与 PyTorch 输出差异 {max_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}")
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
except ImportError:
print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)")
+4
View File
@@ -2011,6 +2011,10 @@ class TestFixedRangeNorm(unittest.TestCase):
# 12 列:舵机 1.0 + spk 0.2
w12 = default_column_weights(12)
np.testing.assert_allclose(w12[10:], 0.2)
# 20 列:超出 18 列语义布局的扩展列默认 1.0(新执行器须参与训练)
w20 = default_column_weights(20)
np.testing.assert_allclose(w20[12:18], 0.0)
np.testing.assert_allclose(w20[18:], 1.0)
# ---------------- dataset 链路 ----------------
+3
View File
@@ -246,6 +246,9 @@ def main():
model.load_state_dict(resume_ckpt["model_state_dict"])
optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"])
scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"])
# 注意:scheduler 状态(含 T_max)随 checkpoint 恢复,LR 余弦曲线
# 按原调度继续;resume 时改 --epochs 只影响循环边界,不重塑 LR
# 曲线(如需新曲线请重新训练,勿 resume)
start_epoch = resume_ckpt["epoch"] + 1
best_val_loss = resume_ckpt.get("best_val_loss", float("inf"))
patience_counter = resume_ckpt.get("patience_counter", 0)
+41 -35
View File
@@ -9,9 +9,10 @@
>
> 本报告对照 v2 报告逐项核对修复状态,并给出本轮新发现。
>
> **2026-08-14 更新:P0-2(方案 B)、两个新 P1(v3-P1-1/v3-P1-2)、P2-a 残余(ONNX 分支)
> 与 P2-a3(column_weights 文档)已修复,附回归测试(pybot +1 / actpred +7)。
> P0/P1 全部清零,剩余 3 项新 P2 + 5 项遗留 P2 + 2 项 P3。**
> **2026-08-14 更新:P0-2(方案 B)、两个新 P1、P2-a 残余、P2-a3 已修复;
> 3 项新 P2(验收脚本 fixed_range / diff 断言 / overrun 节流)与 5 项遗留 P2
> (P2-a1/P2-c 残余/P2-d/P2-e/P2-f/R2-4)亦全部闭环,附回归测试(pybot +2 / actpred +7)。
> P0/P1/P2 全部清零,仅剩 2 项 P3(边缘/环境便利)。**
---
@@ -21,10 +22,10 @@
|------|--------|----------------------|------|
| ~~P0(崩溃/数据损坏)~~ | 0 | ~~1~~ **0(P0-2 已按方案 B 修复)** | 见 §三 |
| ~~P1(功能风险)~~ | ~~2~~ **0(均已修复)** | ~~1~~ **0(P2-a 已闭环)** | 见 §四 |
| P2(代码质量) | 3 | 5(P2-a3 已随 P0-2 一并修复) | 见 §五 |
| ~~P2(代码质量)~~ | ~~3~~ **0(均已修复)** | ~~5~~ **0(全部闭环)** | 见 §五/§六 |
| P3(边缘/环境) | 2 | 0 | 见 §五 |
**重点:P0-2 曾随 85bf68f 未修复提交,本轮已按方案 B 闭环;两个新 P1 与 P2-a 残余已于本轮回合修复。**
**重点:P0-2 曾随 85bf68f 未修复提交,本轮已按方案 B 闭环;两个新 P1、P2-a 残余与全部 P2 已于本轮回合修复。**
---
@@ -42,8 +43,8 @@
| R2-9 predict.py --action-dim 陷阱 | P2 | ✅ 已修复 | predict.py 已移除该参数,统一用 `predictor.action_dim` |
| R1-2 parse_action_history 三处重复 | P2 | ✅ 已修复 | 三推理文件均从 [utils.py](../../actpred/utils.py) 导入 |
| R2-3 空验证集无 best.pt | P2 | ✅ 已修复 | [train.py:309-324](../../actpred/train.py#L309-L324) 保底保存最终权重 |
| P2-c 闭环测试覆盖 | P2 | ⚠️ 大部分修复 | test_loop.py 已含 recorder 集成(imu_recorded/multi_recorded);**predictor 异常清理路径仍无用例** |
| P2-b numpy 三处 lazy import | P2 | ⚠️ 修复引入回归 | 改为顶层导入,**破坏零依赖承诺** → 见新发现 v3-P1-1 |
| P2-c 闭环测试覆盖 | P2 | ✅ 已修复 | test_loop.py 含 recorder 集成 + 异常清理路径用例(test_predictor_exception_still_flushes_recorder) |
| P2-b numpy 三处 lazy import | P2 | ✅ 已修复 | 顶层导入曾破坏零依赖(v3-P1-1),已恢复函数级 lazy import 并加守卫测试 |
| P0-2 normalize_actions 默认值 | P0 | ✅ 已修复 | [README.md:170](../../actpred/README.md#L170) 更正为 `True` 并注明行为变更(方案 B,见 §三) |
---
@@ -91,23 +92,26 @@ _build_action_vec / _grab_frame)恢复函数内 lazy import,并在导入块
## 五、P2/P3 新发现
### v3-P2-1 验收脚本链未适配 fixed_range
### v3-P2-1 验收脚本链未适配 fixed_range —— ✅ 已修复(2026-08-14)
**位置**:[actpred/_npu_test.py:64-71](../../actpred/_npu_test.py#L64-L71)、[_onnx_validate.py:28-39](../../actpred/_onnx_validate.py#L28-L39)
[_npu_test.py:65-76](../../actpred/_npu_test.py#L65-L76)、[_onnx_validate.py:28-42](../../actpred/_onnx_validate.py#L28-L42)
均改为按 checkpoint config 的 `normalize_mode` 分支:zscore 照旧加载
action_stats.npy;fixed_range 走 action_norm min-max 且免 stats 文件;
_npu_test 另向 HailoPredictor 透传 normalize_mode、torch 参考输出按同
模式反归一化后再对比。
两脚本在 `normalize_actions=True` 时无条件 `np.load(action_stats.npy)`:fixed_range 训练不产出该文件 → `FileNotFoundError`;`_npu_test.py` 构造 HailoPredictor 时也未传 `normalize_mode`(默认 zscore),torch 参考只对 z-score 反归一化。**fixed_range 模型无法通过部署验收**(恰好是最推荐部署的模式)。建议按 checkpoint config 的 normalize_mode 分支处理(同 hailo_infer main 的自动判定逻辑)。
### v3-P2-2 导出一致性校验只打印不拦截 —— ✅ 已修复(2026-08-14)
### v3-P2-2 导出一致性校验只打印不拦截
[export_deploy.py:288-292,303-307](../../actpred/export_deploy.py#L288-L292)
与 [export_onnx.py:82-85](../../actpred/export_onnx.py#L82-L85) 新增
`MAX_TOLERATED_DIFF = 1e-4` 阈值断言,超限 `sys.exit` 拦截(P2-f 的
ManualAttnBlock 布局约定风险一并闭环)。
**位置**:[export_deploy.py:283](../../actpred/export_deploy.py#L283)、[export_deploy.py:295](../../actpred/export_deploy.py#L295)、[export_onnx.py:59](../../actpred/export_onnx.py#L59)
### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束 —— ✅ 已修复(2026-08-14)
wrapper vs 原模型、ORT vs wrapper 的 max diff 均仅 print,进程照常退出 0。ManualAttnBlock 依赖 PyTorch 内部布局约定(v2 P2-f 遗留),一旦上游变更布局,转换错误在自动化流水线中完全无感。建议加阈值断言(如 diff > 1e-4 → `SystemExit`)。
### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束
**位置**:[robot.py:308-311](../../pybot/pybot/robot.py#L308-L311)
每个 overrun 步都 `log.warning`——10Hz 持续 50% overrun 时每秒 5 条告警刷屏;且工程约束要求"overrun 比例 >10% 才建议降 hz",当前 [L318-321](../../pybot/pybot/robot.py#L318-L321) 只要 overrun>0 就给建议。建议:逐步告警改节流(如每 100 步汇总一次),最终建议仅在比例 >10% 时输出。
[robot.py:310-317](../../pybot/pybot/robot.py#L310-L317) 逐步告警节流为
第 1 次 + 每 100 次汇总;[robot.py:324-332](../../pybot/pybot/robot.py#L324-L332)
最终建议仅在 overrun 比例 >10% 时输出(否则 info 级汇总),对齐工程约束。
### v3-P3-1 dfc_compile.py --output 无 .hef 后缀时产物互相覆盖
@@ -123,30 +127,32 @@ wrapper vs 原模型、ORT vs wrapper 的 max diff 均仅 print,进程照常
---
## 六、v2 遗留 P2(仍未修复,维持原判级)
## 六、v2 遗留 P2(已全部修复,2026-08-14)
| # | 位置 | 问题 | 备注 |
| # | 位置 | 问题 | 处理 |
|---|------|------|------|
| P2-a1 | [action_norm.py:55-62](../../actpred/action_norm.py#L55-L62) | `default_column_weights` 对 action_dim>18 的超出列权重恒 0 | 未来扩展性 |
| P2-a3 | [README.md:172](../../actpred/README.md#L172) | ~~column_weights 始终生效(含不归一化训练)未在文档注明~~ ✅ 已随 P0-2 一并修复 | 已注明"始终生效" |
| P2-d | [dataset.py:46](../../actpred/dataset.py#L46) | Resize 不保持宽高比 | 训练/推理口径一致,仅变形质量 nit |
| P2-e | [train.py:236,248](../../actpred/train.py#L236) | resume 时 scheduler T_max 恢复旧值,改 --epochs 不影响 LR 曲线 | 语义需在文档明确 |
| P2-f | [export_deploy.py:42-59](../../actpred/export_deploy.py#L42-L59) | ManualAttnBlock 依赖 PyTorch in_proj 内部布局 | 数值验证已缓解,配合 v3-P2-2 加断言更稳 |
| R2-4 | actpred/_set.sh | CUDA 版 torch 误装风险 | 安装脚本注意事项 |
| P2-a1 | [action_norm.py:55-66](../../actpred/action_norm.py#L55-L66) | ~~`default_column_weights` 对 action_dim>18 的超出列权重恒 0~~ | ✅ 扩展列默认 1.0(新执行器须参与训练),补 20 列断言 |
| P2-a3 | [README.md:172](../../actpred/README.md#L172) | ~~column_weights 始终生效(含不归一化训练)未在文档注明~~ | ✅ 已随 P0-2 一并修复 |
| P2-c 残余 | test_loop.py | ~~predictor 异常清理路径无用例~~ | ✅ 新增 test_predictor_exception_still_flushes_recorder(异常传播 + finally 落盘 1 步) |
| P2-d | [dataset.py:46-49](../../actpred/dataset.py#L46-L49) | ~~Resize 不保持宽高比~~ | ✅ 注明有意为之(训练/推理同口径,变更须同步重训),不改行为 |
| P2-e | [train.py:248-251](../../actpred/train.py#L248-L251) | ~~resume 时 scheduler T_max 恢复旧值~~ | ✅ 语义已在代码注释 + README --resume 行明确 |
| P2-f | export_deploy.py | ~~ManualAttnBlock 依赖 PyTorch in_proj 内部布局~~ | ✅ 随 v3-P2-2 阈值断言闭环(布局失效即拦截退出) |
| R2-4 | [_set.sh:19-28](../../actpred/_set.sh#L19-L28) | ~~CUDA 版 torch 误装风险~~ | ✅ 安装后断言 `torch.version.cuda is None`,误装即拦截并给出修复命令 |
---
## 七、修复优先级
| 优先级 | 项 | 理由 |
| 优先级 | 项 | 状态 |
|--------|-----|------|
| ~~P0~~ | ~~P0-2 README 与默认值对齐(方案 B)~~ ✅ 已修复 | README 默认值更正 + 行为变更注明 |
| ~~P1~~ | ~~v3-P1-1 robot.py numpy lazy import~~ ✅ 已修复 | 零依赖承诺已恢复,附子进程回归测试 |
| ~~P1~~ | ~~v3-P1-2 ONNX predict_delta + 元数据~~ ✅ 已修复 | 元数据写入/识别/残差加回全链路 + 7 用例 |
| P2 | v3-P2-1 验收脚本 fixed_range 适配 | fixed_range 是最推荐部署模式,验收链不能缺席 |
| P2 | v3-P2-2 导出 diff 阈值断言 | 一行拦截,防布局约定变更静默穿透 |
| P2 | v3-P2-3 overrun 告警节流 + 10% 阈值 | 对齐工程约束,消除日志刷屏 |
| P3 | v3-P3-1/-P3-2 | 边缘情况与开发环境便利 |
| ~~P0~~ | P0-2 README 与默认值对齐(方案 B) | ✅ 已修复 |
| ~~P1~~ | v3-P1-1 robot.py numpy lazy import | ✅ 已修复(附子进程回归测试) |
| ~~P1~~ | v3-P1-2 ONNX predict_delta + 元数据 | ✅ 已修复(全链路 + 7 用例) |
| ~~P2~~ | v3-P2-1 验收脚本 fixed_range 适配 | ✅ 已修复 |
| ~~P2~~ | v3-P2-2 导出 diff 阈值断言 | ✅ 已修复(P2-f 一并闭环) |
| ~~P2~~ | v3-P2-3 overrun 告警节流 + 10% 阈值 | ✅ 已修复 |
| ~~P2~~ | P2-a1/P2-c 残余/P2-d/P2-e/R2-4 | ✅ 全部闭环(见 §六) |
| P3 | v3-P3-1 dfc_compile --output 后缀校验 / v3-P3-2 conftest 内置 fcntl mock | 未修复(边缘/环境便利) |
---
+15 -6
View File
@@ -308,9 +308,13 @@ class RobotStruct:
time.sleep(remain)
else:
overrun += 1
log.warning("loop overrun: step %d took %.1fms > %.1fms "
"(推理+执行超过周期,实际频率已低于 %.1fHz)",
step, (dt - remain) * 1000, dt * 1000, hz)
# 告警节流:第 1 次及每 100 次汇总一次,避免持续 overrun
# 刷屏(50% overrun @10Hz 时原实现每秒 5 条告警)
if overrun == 1 or overrun % 100 == 0:
log.warning("loop overrun ×%d: step %d took %.1fms > "
"%.1fms(推理+执行超过周期)",
overrun, step, (dt - remain) * 1000,
dt * 1000)
finally:
# max_steps 正常退出与 Ctrl-C(KeyboardInterrupt)都要落盘,
# 否则闭环录制的 actions.npy/imu.npy 全部丢失
@@ -318,9 +322,14 @@ class RobotStruct:
path = recorder.flush()
log.info("episode saved: %s", path)
if overrun:
log.warning("closed loop overrun %d/%d steps (%.0f%%),"
"建议降低 --hz 或检查推理后端延迟",
overrun, step, 100.0 * overrun / max(step, 1))
ratio = overrun / max(step, 1)
if ratio > 0.10: # 工程约束:overrun 比例 >10% 才建议降 hz
log.warning("closed loop overrun %d/%d steps (%.0f%%),"
"建议降低 --hz 或检查推理后端延迟",
overrun, step, 100.0 * ratio)
else:
log.info("closed loop overrun %d/%d steps (%.1f%%)",
overrun, step, 100.0 * ratio)
log.info("closed loop stop after %d steps", step)
def _loop_recent_frames(self, camera_var, F):
+24
View File
@@ -489,3 +489,27 @@ def test_robot_importable_without_numpy():
r = subprocess.run([sys.executable, "-c", child],
capture_output=True, text=True, cwd=repo)
assert r.returncode == 0 and "ok" in r.stdout, r.stderr
def test_predictor_exception_still_flushes_recorder(mock_i2c, tmp_path):
"""P2-c:predictor 中途抛异常时 run_loop 的 finally 仍落盘,
已录制 steps 不丢失且异常照常向上传播(由 with robot 清理硬件)"""
from pybot.recorder import EpisodeRecorder
class RaisingPredictor(MockPredictor):
def predict(self, images, action_history):
if self._step >= 1:
raise RuntimeError("backend boom")
return super().predict(images, action_history)
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active()
pred = RaisingPredictor(frame_len=3, history_len=2, action_dim=4)
rec = EpisodeRecorder(str(tmp_path), action_dim=4)
with pytest.raises(RuntimeError, match="backend boom"):
robot.run_loop(pred, hz=1000.0, max_steps=5, recorder=rec)
robot.deactive()
# 第 1 步已录制(第 2 步 predict 抛异常),finally flush 已落盘
acts = np.load(tmp_path / "episode_0000" / "actions.npy")
assert acts.shape[0] == 1