离线拟合

This commit is contained in:
lxp
2026-09-24 12:23:40 +08:00
parent 50ff372ca3
commit 93e654578a
302 changed files with 7094 additions and 38863 deletions
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8138585956461776
- -0.008439712508702443
- 1.0397597678627144
quaternion_xyzw:
- -0.017450615663592975
- 0.6859174861464342
- 0.01494743127074939
- 0.7273164734212502
top:
translation_xyz_m:
- 0.03984872898745615
- -0.5795215841986667
- 1.0606733822933996
quaternion_xyzw:
- -0.7006937955854443
- -0.05305859990878122
- -0.03489268952144818
- 0.7106303469608818
quality:
passed: true
reprojection_rms_px: 0.8784854263662394
maximum_rotation_repeatability_deg: 0.03530519844936986
maximum_translation_repeatability_m: 0.0005675535417705807
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,111 @@
# O30 自动生成链路修复与实采验证
2026-09-23。本次实际修改生产代码并只读回放两批实采数据,未驱动硬件。
采集与离线求解已解耦,行程端点约束及 JSON→URDF 导出链已修复;当前生成的是
**未通过实测精度认证的候选**,不能作为正式正确版本发布。
修复前分析见 [O30_CURRENT_FLOW_REVIEW.md](O30_CURRENT_FLOW_REVIEW.md)。
剩余误差已按机位、标签和指令进一步定位,见
[O30_REMAINING_ISSUES.md](O30_REMAINING_ISSUES.md)。
## 1. 收敛后的流程
1. 启动前保存本次有效 Product、Profile、CAD URDF/mesh、内外参及标签配置。
默认配置同步已确认的四指 SDK 方向、中节标签绑定和新外参;历史会话仍按自身
快照及外参回放,不能将新外参套在旧图像上。
2. 保留既有路线、速度、2+1 分区、108 方向、一次局部补采及停滞重试保护。
采集完成先归位封存,关闭 SDK、相机、监控及 ROS 控制上下文,再写采集结果。
完整返回 0、缺项返回 3;均不进入拟合,也不因离线失败重采。
3. 使用会话 `capture_product.yaml` 和 `raw_samples.jsonl` 单独离线拟合。
原始文件与配置、mesh 均受清单哈希保护。验证轮 3 不参与参数优化或候选选择。
4. 先校验草稿 JSON,再读回 JSON 生成 URDF;共用一个导出函数,检查实际输出文件
FK 与标准加载。独立报告 JSON 双向曲线和纯 URDF 上下限线性驱动的精度。
5. 草稿允许保留失败证据用于诊断,精度失败不会更新 `latest_passed`。
普通单批离线入口及参数见 [README.md](README.md#低精度离线修正草稿o30)。
本次跨批验证脚本保存在仓库根目录下
`calibration_output/software_fix_20260923_auto_urdf_v2/replay.py`。
它调用包内拟合、范围证据、导出和验收函数,不另写一套 URDF 生成逻辑。
脚本对输入和实现哈希有缓存约束;代码变化后不能直接复用旧参数冒充重新验证。
## 2. 行程问题的修复
换向处通常只保存到达端点的方向标签;离开端点的另一个方向没有独立端点观测。
旧模型仍给两个方向分别分配端点自由参数,缺少直接约束的端点可能保留初值,
或由邻近指令外推,并进入最终 URDF 极值。这会使文件可加载而行程依据不足。
新模型对这类端点施加明确的换向连续性约束:有观测的到达端点同时决定未观测的
离开端点。如果两方向均有端点观测,则保留独立参数;若连来源端点都缺失,拒绝
导出该行程。该假设不等同于两次独立测量,使用
`observed_turnaround_continuity_v1` 明确记录。
优化只移除声明为从属的参数,其他秩亏仍拒绝;图像 Jacobian、角度导数和范围
不确定度均沿同一依赖传播,避免只修改导出数字。证据记录训练样本、轮次和方向,
JSON 回读检查端点连续性及来源。测试确认任意扰动从属参数不改变残差或行程。
普通 URDF 的上下限只表示可动范围,不能编码 SDK 指令对应的非线性、双向曲线。
因此增加 `draft_urdf_linear_holdout.json`,专门检查
`q = lower + (upper - lower) × u / 255`(按 SDK 方向处理)的实际误差。
曲线验证通过不能被当成该线性驱动方式通过;零位只应用一次,非零指令基准也不
强行当作线性驱动的零角度。
## 3. 两批真实数据的结果
| 数据 | 原始方向完成量 | 训练/验证帧数 | 用途 |
| --- | ---: | ---: | --- |
| 旧外参 `20260923_142201` | 107/108 | 4769 / 2681 | 重新拟合全手参考 |
| 新外参 `20260923_183138` | 24/24 | 922 / 516 | 固定旧四指参考后更新拇指 |
仅取封存通过的最后尝试及有效稳态帧,不拼接失败尝试。旧数据缺第 1 训练轮
`fingers_mcp_roll_front / increasing / linked_increasing`,不能由验证轮补齐。
旧四指侧摆保留此前确认的竖直定义基准,四项在 JSON 标为用户定义;五个末端
零位保留 CAD。不能将这九项说成此次新独立测量的零位。
跨批旧参考仍是条件参考,其系统误差没有完整传播,不能据此正式认证新拇指。
两个训练阶段均在原有预算内收敛,先冻结参数再验证。原始数据哈希保持不变。
最终候选位于:
- `calibration_output/software_fix_20260923_auto_urdf_v2/artifacts/o30_right_draft.urdf`
- `calibration_output/software_fix_20260923_auto_urdf_v2/artifacts/draft_calibration.json`
同目录 mesh 自包含;JSON 重建 URDF 字节一致,SHA256 为
`ecb36313311292cb1ebe37d8069ba18234ac1dd6e5ccda39902d10e74ed862ac`。
最终文件 FK 最大差 `6.66e-16`,ROS 标准加载及 MuJoCo 20 关节加载/前向计算通过。
根目录另有早期诊断文件,本次最终路径以 `result.json` 的 `artifacts/` 路径为准。
以下角度误差来自冻结模型的独立验证轮,单位为度;测量本身未通过全部图像及
不确定度门限,所以它们用于定位问题,不是物理真值精度证书。
| 拇指关节 | 生成上限 | 曲线最大角度误差 | 纯 URDF 线性最大误差 |
| --- | ---: | ---: | ---: |
| CMC roll | 52.916 | 0.837 | 0.910 |
| CMC yaw | 110.843 | 1.549 | 3.483 |
| MCP | 105.135 | 0.748 | 1.325 |
| IP | 85.484 | 1.066 | 2.625 |
当前不能发布的具体原因:
- 新拇指曲线角度子项通过,但独立角度测量最大重投影误差仍为 7.194 px,最大
3σ 不确定度约 1.733°,未通过原门限,不能只报告角度子项通过。
- 纯 URDF 线性驱动下 yaw 最大误差 3.483°;IP 的平均/95 分位误差为
1.557°/2.571°,这两个关节的角度子项失败。
- 旧四指曲线测量最大重投影误差 16.468 px、最大 3σ 约 8.713°;线性驱动验证
的独立角度求解未收敛,保留 `raw_joint_holdout_angle_measurement_failed`。
- 旧采集不完整,且跨批条件参考尚不满足正式认证。
下一步应先用已封存图像定位失败机位/标签的重投影残差和参考不确定度,验证
标签安装、相机几何及观测条件。只有数据证明需要时,再决定补采范围或更改模型。
需要保持 SDK 字节轨迹时,运行端应使用经验证的 JSON 曲线得到弧度并驱动 URDF;
若产品要求单靠上下限线性驱动,则必须以该独立报告为准,不能只扩大上限消除表象。
## 4. 软件回归与证据
采集/局部/重装组 43 项、离线导出/端点组 43 项、更新审计组 3 项,以及真实
coordinator 的完整 108 方向虚拟时钟集成 1 项通过。最新配置及 mesh 封存修改后,
对应 4 项边界检查再次通过;它们与前述用例重叠,不重复累计。
完整调度用例耗时约 97 秒,其余组分别约 17、34、0.3 和 2.4 秒。
软件调度回归不代表本轮实机连续采集通过。
产物目录的 `verification.json`、`mujoco_acceptance.json`、`curve_holdout.json`、
`linear_holdout.json`、`training_frozen.json`、`result.json` 及 `test_evidence/`
分别保存来源核验、加载、精度、冻结输入和软件测试记录。
本次未放宽精度/可观测性门限,未替换仿真默认模型,未发布正式版本。
@@ -0,0 +1,180 @@
# O30 当前采集与自动生成链路复核
本文为修复前的只读复核记录;后续实际改动、两批数据重拟合及验收结论见
[O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
2026-09-23。目标是完全由程序从采集数据生成正确、可复现的 JSON 和修正 URDF。
本次评估程序生成版,不用人工调整版作为拟合结果或精度依据。
结论:轻量采集、离线求解的方向合理,但目前还不能把“采完”或“草稿能加载”视为
“自动生成正确 URDF”。优先收敛采集边界和配置,再修复行程证据约束,最后用冻结
数据验证实际驱动方式。当前没有证据支持靠增大拇指上限或增加优化迭代解决全部偏差。
本轮只读复核代码和已有真实数据,运行针对性软件测试;未运动硬件、未重新拟合、
未修改生产代码/配置或替换模型。已有工作区改动和被删除的历史测试均保留。
## 1. 当前流程与合理之处
实际入口:`runtime/runner.py`;默认 `raw_joint_2_plus_1` / `raw_joint_images_v2`。
1. 启动检查、固定参考锁定、归基准;17 个任务覆盖 20 个关节。
2. 每任务训练轮 0/1、独立验证轮 3,共 108 个方向单元。
3. 移动标签在线只记录角点;保持反馈、同步、固定参考及硬件保护。质量不足的方向
立即补一次,不拼接失败尝试;恢复保留预算。硬件停滞重试与图像质量补采分别处理。
4. 归位、封存原始数据,状态为 `CAPTURE_COMPLETE`,随后关闭 SDK/相机/监控。
5. **启动器实际上还会自动调用草稿拟合**;显式 `--offline-raw` 则可单独离线运行。
值得保留:在线不依赖移动标签几何消歧;端点按已发送指令核验,不要求反馈恰好
达到 0/255;离线只取同步、有效稳态帧;验证轮不用于训练;原始证据封存;正式
发布保持来源、候选不确定度、独立验证及 JSON→URDF 回读门禁。
拟合固定 CAD 拓扑、连杆和关节轴,联合估计掌坐标、标签安装、关节零位和双向
分段指令曲线。15 个零位可估,五个末端零位沿用 CAD 假设;显式竖直侧摆草稿
另把四个侧摆零位设为用户定义基准,因此该草稿实际只估 11 个零位。
这些假设必须继续写进产物,不能改称全部零位都由视觉独立测得。
## 2. 优先处理的问题
### P0:采集完成仍绑定草稿求解结果
`runtime/runner.py:271` 在关闭控制栈后调用 `finish_captured_draft()`;
`runtime/artifacts/raw_joint_draft.py:352` 只有“数据完整且完整目标草稿生成”才返回 0。
因此即使采集完整,离线不收敛也会使整个命令返回 3。关闭控制栈的顺序是正确的,
但采集任务的耗时和成功语义仍受拟合影响,README 的“后续单独离线”也不完全准确。
建议默认入口结束于归位、封存和采集报告,退出码只描述采集;拟合由显式离线命令
执行。若保留一键流程,作为明确选项组合两个阶段,分别记录退出状态;拟合失败
不改变采集结论,更不据此安排重采。不要另建采集执行器。
### P0:默认配置与有效离线配置没有收敛
对照当前默认 Profile 与生成程序草稿使用的
`calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_profile.yaml`:
| 项目 | 默认 Profile | 草稿拟合 Profile |
| --- | --- | --- |
| 四指侧摆通道 2–5 的 SDK→URDF 方向 | −1 | +1 |
| 正面 ID12–15 安装连杆 | `*_metacarpals` | `*_middle` |
| 默认产品外参 | 20260922_101452 | 新拇指批次使用 20260923_181333 |
ID17 在两者中均已绑定 `thumb_proximal`。这些差异不是哈希损坏:默认配置自己的
`--validate-only` 可以通过,但这只证明内部结构和文件身份一致,不证明与现场安装一致。
原始角点仍有价值,不能因为默认建模配置错误就全部废弃数据。
建议把已确认的模型方向/标签连杆修订纳入版本化配置;每次采集保存完整配置包,
包括 Product、Profile、源 URDF、内外参与标签尺寸。旧数据按自己的快照回放;
模型修订另存,保持原采集身份。外参按实际安装选择,不能无条件把“最新文件”
应用到所有历史数据。新旧相机批次应各用各的外参。
### P1:未观测的方向端点可以改变“实测行程”
这是本次用当前代码重新复现的确定问题,不是照片推测。
读取新拇指真实训练帧及已保存参数,核验原始数据 SHA256 与封存清单,不重新求解:
| 关节 | 程序 URDF 上限 ° | 超出已观测正程端点 ° | 未观测回程参数加 5° 后上限变化 |
| --- | ---: | ---: | ---: |
| CMC roll | 52.9662 | 0.0520 | +5° |
| CMC yaw | 110.9948 | 0.1495 | +5° |
| MCP | 106.0734 | 0.9447 | +5° |
| IP | 85.4810 | 0.0018 | +5° |
四项的训练 Jacobian 对应列范数都是 0;修改后训练残差逐元素完全不变。
255 端点有正程方向历史的图像,没有独立 decreasing/255 图像。回程起点沿用
上一实际推进方向是正确记录,不能把它伪标成独立回程观测。
原因链:`UrdfCommandImages` 为每方向分配完整曲线 → `JointMapping.bounds_rad`
取两条完整曲线包络 → `prepare_raw_motion_ranges()` 将它直接写入范围证据。
当前范围证据检查了基准、轮次及图像身份,没有约束每个极值的方向有效域。
增量更新工具虽然冻结无观测参数,却仍保留其初值,并未阻止初值进入导出范围。
本批模型调用全参数协方差检查会报 `urdf_images_geometry_rank_deficient`;上述
零列已足以造成秩亏。该检查包含固定旧四指的参数,不能当成一次完整正式拟合
运行结果,但说明“方向单元采全”不保证全部自由参数可辨识。
建议统一处理模型、曲线和导出的有效域:每方向节点记录训练支持、可辨识性和
来源;未观测自由参数不进入实测范围。若使用共享端点/连续性约束,必须显式
标为模型假设并检查数据是否支持。JSON 读取、范围证据、URDF 导出及协方差应
使用同一份契约,不能只在最后裁剪 limit,也不能靠伪逆把未知量变成零不确定度。
必要回归:扰动无观测参数不得改变认证范围;训练/验证不串用;方向切换端点身份
正确;缺证据时明确报告范围不足;JSON 回读重建保持同一有效域。
### P1:JSON 曲线精度不等于纯 URDF 线性驱动精度
审查程序生成文件:`offline_combined_new_thumb_draft/o30_right_draft.urdf`,
SHA256:`9d6e71276ffcc2108cf0776cdee08a23803de9f048b1a42b7ca76e98b40e48a2`。
复核保存的独立第三轮报告,最大角度误差如下;本轮没有用第三轮重新拟合:
| 关节 | JSON 双向曲线 ° | 纯 URDF 线性驱动 ° |
| --- | ---: | ---: |
| roll | 0.837 | 0.919 |
| yaw | 1.538 | 3.516 |
| MCP | 0.749 | 2.285 |
| IP | 1.070 | 2.621 |
曲线模式的四关节角度子项通过,但整体像素/不确定度仍失败;两个模式的整体
报告均为 `passed=false`。这些角度是条件图像模型的估计,不是独立编码器真值。
当前模型的修正关系是 `T_origin_corrected = T_origin_CAD · R_axis(delta)`,
JSON 给出相对修正零位的 `q_output=f(command, direction)`。文件回读 FK 一致
证明写入关系一致,不证明 delta 或 f 是真实物理值。
当前纯 URDF 驱动使用 `q=lower+(upper-lower)*u/255`。上下限能表达角度范围,
不能同时保存中间点非线性和方向差异。此前冻结同一模型的训练诊断中,yaw 在
SDK80 处线性值比正程曲线大约 2.90°,在 SDK181 处大约 1.32°;不是全区间都
“行程偏小”。当前 yaw 的未观测端点影响约 0.15°,也不足以解释全部偏差。
目标应明确为程序同时生成可信几何 URDF 和指令映射。如果使用纯 URDF 线性
驱动,就必须用该驱动方式独立验收零位、两端、中段及组合姿态,并如实报告无法
用一条直线达到的精度;不能把扩大物理行程作为非线性补偿。
### P1:实际重复性与组合姿态仍未达到自动认证条件
已有现场记录中,同基准指令返回后,正面固定 ID0 角点 RMS 位移约 0.092 px,
拇指 ID1/ID2 分别约 7.39/16.23 px,反馈接近。证据说明存在需要解释的重复性
差异,不能仅凭一次对照唯一归因于硬件回差、标签松动或零位错误。
当前双向曲线都强制基准值为 0,单个固定零位不能自动解释不一致的基准观测。
建议先用已有各轮基准帧做相同路径/方向/其他通道状态下的重复性统计,单独报告
跨路径差异。保持既有原始帧,不为降低残差删掉不利观测,也不立即增加在线几何
暂停。若固定条件下仍无法重复,增加优化迭代不能获得可信的确定性映射。
新拇指与旧四指组合依赖冻结的旧四指及标签安装参数。它是条件草稿;旧参考误差
未完整传播,旧整手仍有 1/108 方向缺项。新拇指 24/24 不能补齐这些认证缺口。
组合生成还依赖输出目录中的专用脚本,没有形成统一的跨批次正式入口。
## 3. 建议实施顺序及验收边界
1. **先稳定采集交付**:收敛配置与快照,默认只采集并封存;保留原路线、速度、
2+1 分区、一次补采和硬件保护。采集报告明确已完成/缺项/中断、是否安全归位。
2. **用已有数据修离线契约**:先解决方向有效域与未观测参数,再用相同冻结输入
比较旧/新结果。统一共享模型和导出模块,避免另建拇指拟合器或手调常量。
3. **形成单一可复现离线入口**:训练模型选择 → 冻结 → 独立轮验证 → JSON 落盘 →
重建 URDF → 实际驱动方式的文件/FK/角度验收。跨批次模型显式携带外参及参考
不确定度。失败另存报告,不覆盖已发布模型、不自动重采。
4. **最后评估实机精度**:先区分基准、端点、中间映射、组合几何的误差,再决定
是否缺少必要观测。只有数据能支持轴线/安装修正时才扩展模型,不能靠任意放开
CAD 轴吸收外参和重复性误差。现有单关节验证不能认证任意多轴组合。
采集稳定、求解成功、文件正确、实机精度通过应有独立状态。满足前三项仍不能
自动承诺第四项;本轮没有把软件通过表述为现场已能自动生成准确 URDF。
## 4. 本轮验证与证据
- 采集、预算、断点、重装隔离、局部范围和增量更新:46 passed,16.93 秒;
按测试约定未重复运行耗时完整整手虚拟时钟用例。
- 草稿、离线初始化、训练隔离及文件生成相关:39 passed,24.41 秒。
- 默认正式 CLI `--validate-only` 通过;它不能检测与现场安装的配置漂移。
- 初次测试命令覆盖了 ROS 的 PYTHONPATH,收集失败;修正环境后运行上述分组。
一次 `python -m runtime.runner` 未调用 main、没有实际校验,改为直接调用 CLI main 后确认通过。
- 真实冻结数据的端点敏感性检查已完成;既有 85 项测试通过没有覆盖并消除该漏洞。
本轮机器可读证据:
- [端点审计](../../calibration_output/software_review_20260923_current_flow/endpoint_audit.json)
- [配置差异、程序原稿与角度误差](../../calibration_output/software_review_20260923_current_flow/review_summary.json)
- [采集相关测试](../../calibration_output/software_review_20260923_current_flow/capture_tests.json)
- [离线相关测试](../../calibration_output/software_review_20260923_current_flow/offline_tests.json)
既有现场重复性证据:
`calibration_output/thumb_recapture_20260923_175829/live_comparison_20260923/repeatability.json`。
@@ -0,0 +1,148 @@
# O30 右手离线修正逻辑复核(2026-09-23)
本轮使用封存会话 `splay_recapture_20260923_132346/O30_RIGHT_001/20260923_142201`。
原始数据未改写,没有 SDK 运动;训练轮 0/1 共 4769 帧,独立验证轮 3 共 2681 帧。
采集仍为 107/108 个方向完整。独立验证未参与参数拟合、候选选择或绑定判断。
## 已修正的问题
1. **标签所属连杆错误。** ID12–ID15 原配置为 `*_metacarpals`,程序因此认为 MCP/PIP 动作不会带动这些标签。
训练数据中四标签随 MCP 移动约 93–105 像素、随 PIP 移动约 37–49 像素,DIP 动作时仅约 0.2–0.3 像素。
小指对应 MCP/PIP 观测中掌部参考角点仅变化约 0.08/0.19 像素。
用户确认四标签都固定在中节或随中节运动的支架上,最终离线绑定为 `pinky_middle`、`ring_middle`、
`middle_middle`、`index_middle`。保持角色名称、ID、原始角点和 SDK 指令不变。
原始采集配置保留;本批求解必须使用下述新的离线配置,不能继续使用旧绑定配置。
2. **PnP 候选排序被误当成轨迹身份。** 实际 ID17 在 SDK 64→96 期间交换候选排序,固定取第 0/1 项会产生超过 40° 的虚假跳变。
草稿与正式求解的初始化现在复用候选连续关联,保留两个候选族,缺失的候选不复制成第二份证据。
初始化不用于授权唯一分支;原有在线严格连续性检查保持不变。最终拟合仍直接使用二维角点。
3. **扫描起点被上一段回程方向排除。** 初始化现在按正式扫描/分段归属纳入起点,四指补段只纳入实际运动的关节。
前向迟滞模型仍使用原始逐通道方向历史,不改写历史来伪造上行或下行。
4. **欠约束零位可能被漏判。** 当 Jacobian 行数小于列数时,保留完整右零空间,防止精简 SVD 遗漏不可观测方向。
5. **行程证据报告不准确。** 行程和基准样本只引用主视角有效可见标签及实际训练轮次,排除验证轮和无标签图像。
单训练轮草稿使用明确的规划选项;正式范围验收仍拒绝单轮证据。
6. **生成与验收混淆。** 终端分别显示全手参数生成、独立验证和草稿发布状态;结果清单记录模型绑定修正。
## 最终本批结果
目录:`calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_draft/`。
- `o30_right_draft.urdf` 与 `draft_calibration.json` 必须配套使用,SDK 指令不能直接作为 URDF 弧度。
- 11 个拟合零位、4 个用户定义的竖直侧摆零位、5 个保留 CAD 的末端零位;20 个拟合运动范围。
- 训练坐标 RMS:旧竖直草稿 5.5647 px → 本版 1.4867 px。
- JSON 与最终 URDF FK 最大差 5.55e-16;标准加载、原始文件哈希检查通过。
- 18/20 个关节的独立角度检查通过,包括四指全部关节及 `thumb_cmc_yaw`。
- `thumb_cmc_yaw` 独立角度 MAE 0.658°、P95 1.950°、最大 2.180°。
- `thumb_cmc_roll` 最大 10.306°、`thumb_mcp` 最大 4.262°,二者角度检查未通过。
- 全局最大测量重投影误差 16.47 px,最大角度置信界约 8.71°;全手精度仍未通过。
角度子项通过不等于零位绝对精度或任意多关节联动已认证。
- 没有更新 `latest_passed`,没有替换用户正在运行的 MuJoCo 模型。
`offline_motion_logic_review` 和 `offline_corrected_tag_links_draft` 是绑定核实过程中的对照产物,
已分别留下被最终中节绑定取代的说明,不能将其中的支座/近端绑定当成最终确认。
最终确认及像素依据见同级 `offline_confirmed_middle_tags.json`、`tag_motion_dependencies.json`。
## 只读离线重放
在项目根目录运行;输出目录必须是尚不存在的新目录:
```bash
source /opt/ros/jazzy/setup.bash
source install/setup.bash
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 ros2 run linkerhand_calibration calibrate_hand \
--config calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_product.yaml \
--workspace /home/lxp/projects/linkerhand_retarget_ros2 \
--offline-raw calibration_output/splay_recapture_20260923_132346/O30_RIGHT_001/20260923_142201/raw_samples.jsonl \
--offline-draft --draft-upright-splay-zero \
--offline-output calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_replay
```
## 回归范围
新增 7 项集中回归均通过:真实候选换序、起点方向/四指补段、候选缺失及严格连续性门限、
欠约束零位、两个单训练轮草稿与正式拒绝边界、确认中节绑定的原始配置隔离及关节依赖。
其余受影响的草稿初始化、重复观测目标等价、JSON 导出、独立验证失败记录、CLI 和关闭控制链测试分组通过。
首次测试有 4 项因 shell 未加载 ROS 缺少 rclpy,2 项因新增夹具字段名错误失败;修正后对应项通过。
未恢复被外部删除的历史测试,也没有把离线回放当作实机验收。
## 新版仿真仍有拇指偏差的复核
只读订阅确认当前拇指 SDK 指令仍为 roll=55、yaw=181、MCP=122、IP=0;
实机反馈约为 56、176、120、4。仿真与新草稿文件哈希相同,使用配套指令映射。
新旧映射在当前 yaw 指令下输出分别为 79.164°、79.207°;新旧拇指末端 FK
仅差 0.219 mm 和 0.271°,因此上一轮修改并没有实质解决这个拇指姿态差异。
当前联合求解器固定 CAD 的关节轴线和拓扑,只拟合掌姿态、标签安装、零位和指令曲线。
训练轮中 MCP 轴的连续 PnP 假设与该模型轴存在偏差:正面 ID1 最接近的候选约 10.2°,
同机位末端标签约 9.94°,顶机位 ID17 约 13.09°。这些是几何诊断线索,不能排除相机
外参和候选歧义的影响,也不能直接将其作为允许写入 URDF 的轴修正。
静止标签的微小旋转不作为轴证据;原始候选与运动量完整保存在诊断 JSON 中。
原采集主要为单关节扫描,MCP 扫描将 yaw 保持在 SDK 80;当前 roll/yaw/MCP 的
55/181/122 组合不属于已直接观测的组合姿态。独立轮次中的角度子项通过并不认证
绝对关节轴/零位或任意组合运动。后续应优先验证拇指几何约束及组合姿态,不能用降低
误差阈值、照片手调 yaw 或再次导出同一参数代替修正。
诊断:`offline_confirmed_middle_tags_draft/thumb_current_pose_model_audit.json`。
本次仅诊断,未改求解参数、仿真模型或实机姿态。
## yaw 行程专项复核
本轮保持 CAD 转轴及全部拟合参数不变,只读训练角点、URDF/JSON 和当前 ROS 指令。
两个训练轮的正反扫描均有 SDK 0/255 对应的顶机位 ID17 有效图像,每个端点 3 帧。
SDK=255 的实际方向历史为 increasing(反向扫描起点也沿用此前的上行历史),没有把扫描
名称当成实际指令方向,更没有要求反馈必须等于 255 才认可发送端点。
当前输出曲线:SDK 80→32.97°、128→54.11°、181→79.16°、223→98.26°、255→113.39°。
URDF 上限为 113.3957°,高于原 CAD 约 107.25° 的名义范围。单独加载 MuJoCo 后对
181/223/255 与三种方向状态逐项执行映射和 mj_forward,qpos 与映射完全一致,未发生限位截断。
当前实机控制话题仍是 yaw=181,反馈约 176;这不能当成实机已到全行程终点。
保留两个连续 IPPE 假设、分别检查两训练轮正反向后,原始角点估计的相对总转角为
114.54°–115.15°,181 处约 80.00°–80.99°。与当前曲线相比,存在约 1°–2° 的偏小线索,
但这是姿态候选诊断,不能直接当成独立物理真值或据此放大行程。
当前零位修正为 −5.13°;它影响绝对朝向,不等于减少 5.13° 的相对行程。
明显组合姿态差异尚不能只由这约 1°–2° 的相对行程差解释。
原始诊断与完整逐停点数据:`offline_confirmed_middle_tags_draft/yaw_range_review.json`。
仿真核验:`linker_hand_mujoco_ros2/reports/yaw_mujoco_range_check.json`。
未发送实机动作,未改轴、放大曲线或替换任何模型。
## 当前中间指令的现场图像诊断
用户明确为同一中间指令下仿真偏小。为避免只对单关节历史数据推断组合姿态,
在现有 55/181/122/0 拇指指令保持不变时,短时读取三相机图像;没有机械手控制发布。
侧面可见 ID1/ID2,各 8 帧;原始文件单独存入 `current_thumb_pose_readonly`,不加入训练。
固定当前几何/零位/安装的图像角度诊断:仅调 yaw 最佳约 +2.16°;四关节共同解释时
约 roll +4.44°、yaw +0.82°、MCP −0.73°、IP +0.41°。残差与不确定度仍较大,
yaw 条件 3σ 约 5.96°,未包括共享几何不确定度,不能据此写入新修正。
这支持继续区分中段映射、绝对零位及组合姿态误差,不能直接扩大 URDF 上限。
详见 `current_thumb_pose_readonly/README.md` 与 `thumb_pose_measurement.json`。
## 纯 URDF 模式下分开复核零位与组合姿态
后续仿真按用户要求默认仅使用 URDF 线性映射。对已保存 55/181/122/0 组合图像重新诊断:
只调 yaw 为 +1.581°,同时拟合四拇指角度时 yaw 为 −0.503°;不能沿用旧曲线模式的 +2.160°。
训练两轮的条件零位复核为 yaw −5.331° / −4.907°,并未独立证明绝对零位正确。
发现最终原始记录存在相同输入冲突:训练轮 0 的四指侧摆,13:47:45 上行末端与 13:53:29
下行起点,控制指令、方向历史、相机内参及拇指反馈相同,掌部 ID0 中心只差 0.041 px,
拇指 ID1/ID2 中心分别差 6.264/15.468 px。7 帧与原始 JSONL 逐字段核对一致。
这些辅助拇指观测进入了零位求解;重复观测聚合虽保留原目标,却不能解决输入本身的不一致。
诊断性暂不使用四指侧摆任务辅助拇指观测后,yaw 零位 −5.130°→−5.018°,
指令 181 的拟合角度仅 79.164°→79.185°,因此此问题不足以解释全部 yaw 差异。
细节见采集目录 `current_thumb_pose_readonly/ZERO_AND_COMBINATION_REVIEW.md`;对应三个 JSON
保留条件、证据和敏感性结果。未改 CAD 转轴、原始数据、仿真 URDF、生产求解器或正式发布状态,
未使用第三轮拟合,也未发送实机动作。
### 对故障归因的修正
“观测有差异”不等于“离线实现有 bug”。再次核查发现,重复观测压缩完整保留组内平方误差;
7 帧实测数据的完整目标与压缩目标差值小于 1e-11 px²。现有草稿如实记录独立验收失败并禁止正式发布。
在现有确定性模型下采用最小二乘折中是合理实现,不能只凭不同采集段图像不一致就要求删除观测或修改拟合。
前面对“必须修正离线数据使用逻辑”的表述过强,应以此处结论为准:软件缺陷尚未证实;
相机视角、线性驱动近似、实际回位差异和固定几何模型的适用范围仍是待区分解释。
详见 `current_thumb_pose_readonly/hypothesis_reassessment.json`。本轮仅复核证据及更正说明,未修改生产代码或模型。
@@ -1,19 +1,38 @@
# O30 轻量标定实现与验收边界
2026-09-22。产品默认入口已切换为 `raw_joint_2_plus_1` / `raw_joint_images_v1`。软件流程已经接通;本文不是实机标定通过证明。
2026-09-23。O30 正式入口为 `raw_joint_2_plus_1` / `raw_joint_images_v2`。
采集结束与 URDF 验收是独立结果,软件验证不代表实机通过。
## 正式流程
## 正式采集流程
实测光学核验 → 一次启动检查 → 17 个任务、20 个关节连续两轮训练及一轮验证 → 安全归位 → 离线联合拟合 → 冻结独立验收 → JSON/URDF 原子发布。
启动检查 → 17 个任务、20 个关节连续两轮训练及一轮验证 → 当前失败方向立即补一次 → 安全归位 → 封存数据并关闭控制栈。
- 使用原运动执行器、避让路径、四指侧摆分段、9 个训练停点与双向稳态采样。108 个方向单元,训练轮身份 0、1,验证轮身份 3。验证轮编号 3 不表示执行四轮。
- 原始角点、合格 PnP 候选、同帧标签、指令、反馈、时间、方向及参考证据保存。所有实际发送指令另存 `raw_command_sent`,统计包括到位、避让、归位及重试。
- 新入口不请求在线父模型、唯一移动标签分支、零位准备往返、辅助消歧或歧义回访。采集只检查同步、覆盖、端点、稳态和检测。
- 缺失单元移至队尾补采一次;再次不足记录缺项,继续其他任务,归位后报告失败。硬件停滞、反馈失联、参考移动仍保护停止。
- 新断点只接收同协议、同受保护输入、同参考/可核验安装条件。中断的尝试消耗原补采预算;第一单元尚未通过也保留断点。拒绝不兼容数据,不静默重采。不可见安装参考明确拒绝恢复。
- 保留原运动路线、避让、四指侧摆分段、训练及验证停点和速度。108 个正式方向单元,训练轮身份 0、1,独立验证轮身份 3。
- 移动标签只保存角点、身份、检测质量、相机参数、指令、反馈及时间。在线不运行移动标签 PnP、分支确认、父模型或辅助消歧;固定基准保护保留。每个任务只要求原指定主机位覆盖。
- 停点由稳态效果统一有界等待。中间指令步不再要求额外固定反馈位移;整个方向使用真实同向反馈推进保护,4 秒累计实际运动无推进即停止,计划内静止采样不消耗运动预算,切换停点不重置预算。
- 按用户最新要求,端点以实际发送的 SDK 指令为准,不要求反馈等于 0/255。有效稳态图中同步记录的已发送端点指令也用于端点核验,不重复要求扫描阶段另有一张端点图;稳态图不混入运动覆盖、反馈行程或分箱统计。没有实际发到的目标值不能作为端点证据。
- 反馈失联、硬件故障、控制权冲突、越界、固定参考移动仍停止;避让和归位到位要求不变。按用户补充要求,停滞后等待 3 秒、从当前指令位置继续同一方向一次;再次停滞停止,不循环重发。
- 当前方向质量不足立即按原准备路径回到起点再采一次。仍不足记录缺项、继续下一方向;不队尾补采、不循环回访、不合并两个失败尝试。重启保留已消耗预算。
- 结束状态为 `CAPTURE_COMPLETE`,不是标定通过。`raw_capture_manifest.json` 独立记录流程结束、覆盖完整、尝试次数、缺项、配置和证据文件哈希。完整返回码为 0,缺项返回码为 3;两者都关闭控制栈。
- 在线不启动最终拟合。后续 `--offline-raw` 只读封存数据,单独保存输出,缺项或拟合失败都不触发运动。
- 离线可重新计算已完成方向的采集质量,并在 `acquisition_reassessment` 保存原失败原因和新检查结果;不改写原始记录、不拼接尝试,不接受中断或未完成的方向。断点恢复仍按原记录消耗补采次数。
- 新会话计划为 `capture_plan_v7_raw_joint`,立即补采语义与 v2 角点绑定。旧 v1 数据保持离线读取,禁止混入新会话续采。断点仍要求安装条件可核验。
## 历史实机卡点
`20260922_233830` 只有拇指旋转的 6/108 单元通过,拇指 MCP 指令 32、反馈 0 三次失败。
此前拇指旋转指令 223/255、反馈 189/195 也曾停滞。本次针对逐停点额外推进要求进行结构调整,
不能由这些历史日志单独推断真实死区、阻挡或反馈异常;当前实机结果须另行记录。
## 离线求解与发布
- 未认证草稿按任务接受至少一轮完整训练,并使用另一轮已通过采集质量检查的完整方向。
例如侧摆第二轮缺一方向时,第一轮完整仍可保留四指关节链;初始化取完整训练轮。
完整方向仍按原始质量门限复核,不拼接两次失败尝试,第三轮不补训练缺项。
逐任务覆盖、缺项和算法身份写入独立输出;采集完整性与正式发布规则不变。
- 草稿可显式复用同批数据的局部训练结果作初值,核对原始文件/配置身份与训练选择记录,
共享参数仍全部重新拟合;第三轮不用于初始化或选择。重复预测做加权等价合并,
保留组内散差和原始逐图误差,以减少内存和重复计算。草稿数值停止容差单独记录。
- `UrdfCommandImages` 复用共享图像优化入口,直接沿原 CAD 拓扑计算四指及非平行拇指。一个掌变换、每个实体标签一个共享安装;不引入各阶段独立六自由度变换。
- 五根轴的方向与空间布局初始化掌坐标,子轴补充非平行约束;所有允许零位和双向单调指令映射在原始图像目标中联合求解。首次观测及行程端点不作为物理零位。
- 最多 64 个初始化,每个初始化的安装预优化与联合优化合计最多 200 次求值。正式后台进程沿用 600 秒上限;预算耗尽保留失败原因,不自动增加动作。
@@ -32,6 +51,18 @@ ros2 run linkerhand_calibration calibrate_hand \
--no-resume --camera-optical-observations /绝对路径/optical_observations.json
```
采集结束后,使用同一批只读数据离线修正;默认不发布:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o30_right_product.yaml \
--offline-raw /绝对路径/会话/raw_samples.jsonl \
--offline-output /绝对路径/新的离线结果目录
```
`--publish-offline` 仅在全部原精度与文件检查通过后才允许更新 `latest_passed`。
原始索引、压缩证据、导入证据和 `raw_capture_manifest.json` 须一起保留;不得编辑封存数据。
后续恢复省略 `--no-resume`,仍需提供当前安装条件下的光学观测。若安装参考不可核验,按拒绝原因处理,不能改哈希绕过。
2026-09-22 用户明确要求复用已完成的相机标定:可将上述光学观测参数换为
@@ -49,12 +80,129 @@ CameraInfo;日志及发布审计记录 `current_optical_verification: not_perf
- `raw_joint_holdout.json`:每个保留候选的冻结验证。
- `calibration_report.json` / `release_manifest.json`:协议、原始证据、安装参考、光学观测及候选集合身份;最终发布证据。
## 已验证与尚需实机完成
## 历史软件验证(2026-09-22)
- 全手运动效果测试覆盖 17 个任务、三轮正式往返、原避让和归位;实际 coordinator 虚拟时钟覆盖小指 18 个方向、全部已发送指令,无在线求解。
- 有界队尾补采、跨恢复预算、首单元中断发现、协议/参考拒绝、固定参考保护、光学缺失时禁止启动均有测试。
- 当时的有界队尾补采、跨恢复预算、首单元中断发现、协议/参考拒绝、固定参考保护、光学缺失时禁止启动均有测试。当前 v2 已改为当前方向立即补一次。
- ID5、ID9、ID12 的真实历史双候选角点可作为采集数据;ID10 真实缺测不能变成完整扫描。
- 独立 XML FK 真值:64 个初始化全部收敛,约 193 秒;32 个超过原图像门限,16 个被训练配对证据排除,剩余 16 个全部通过候选集合不确定度及第三轮验收。该批次生成合成 JSON/URDF,并通过标准 ROS 加载和最终文件 FK 回读。
- 合成产物位于 `calibration_output/software_review_20260922_raw_joint/known_truth/`,仅有软件测试指针 `synthetic_only_passed`;没有更新实机 `latest_passed`。
- 该软件产物保留生成时的检验审计;当前更严格的 0.01 家族错误率复核另存 `current_training_selection.json`,保留候选集合完全相同,未重复运行优化器或改写原验收文件。
- **尚需实机验收**:当前相机安装的实际光学核验、新协议首次全手采集、实机姿态对照。本次没有重新启动电机,不能将合成通过当作实际机械手的准确性证明。
- 当日没有重新启动电机;这些合成结果不能证明实际机械手的准确性。当前相机安装的光学核验和实机姿态对照仍须单独验收。
## 本次实施验证(2026-09-23)
软件完整 coordinator 模拟:108 方向均通过、没有补采,安全归位后封存且不再发送指令;
旧合成报告的 v1/v2 协议重建产生相同 URDF,原候选不确定度拒绝条件仍有效。
采集、立即补采、封存和只读离线检查详见 `TESTING.md`。
实机 `20260923_104837` 去程通过,拇指旋转回程指令降到 162 而反馈仍为 253,
累计实际运动无推进 4.005 秒后保护停止。角点记录亦未显示明显回程运动。
控制栈已退出,未重复强推、未自动归位;本次仅完成 1/108 方向,未生成通过的 URDF。
随后用户明确允许此类硬件停滞重试一次,已采用等待 3 秒后继续一次的策略;
新会话 `20260923_110651` 使用更新后的计划身份,不导入上述旧策略会话。
该新会话实际完成 35/108 个方向,原在线记录 29 个通过、6 个端点覆盖不足,
在小指 PIP 第三轮回程收到 `o30_sdk_identity_mismatch` 后停止。此时反馈约 29.81 Hz、
无活动硬件故障;通用“通信失联”提示不能证明物理断线。原日志没有保存异常身份报文,
无法确定具体不匹配字段;现已补充保留异常身份字段并单独显示身份错误,停止条件不变。
未触发硬件停滞重试,控制栈已退出,未完成归位和整手封存,未再次启动运动。
按最终端点规则对原始数据只读复核,35 个完整执行方向全部通过,原 6 个端点误判均消除;
还缺 73 个方向,禁止发布。复核报告位于
`calibration_output/software_review_20260923_lightweight/interrupted_capture_review.json`,
记录源数据哈希、算法身份、逐方向重算证据;源文件哈希未变化。
本次实机尚未完成全手流程和 URDF 精度验收。
同批数据随后做了真实拇指四关节局部图像拟合,发现 ID17 的安装连杆配置错误。
用户确认 ID17 位于随 MCP 弯曲的近端指节,仅顶机位可见;现改为 `thumb_proximal`,
同步产品配置哈希,保留机位、标签尺寸、任务和路线。原始记录与旧配置身份不改写。
仅训练数据的绑定对照使最大标签重投影 RMS 从 87.575 px 降到 8.047 px,
仍未通过原 1.5 px 门限,不能称为 URDF 修正成功或证明两轮足够。
具体结果与修改前配置快照见
`calibration_output/software_review_20260923_lightweight/thumb_fit_audit/README.md`。
本轮没有实机运动,没有使用第三轮选择或调整新绑定候选,也未发布 URDF。
## 用户允许先生成低精度修正文件(2026-09-23)
新增 `--offline-draft`,与正式精度认证分开。复用 `UrdfCommandImages` 的图像残差、
参数约束及优化器,仅为部分已采集关节增加轴线初始化;训练选解后冻结,第三轮仅报告。
能估计的零位与行程一起导出。单根手指的根零位与掌坐标存在等价变换,固定其 CAD
零位;其余估计零位须不落入图像雅可比的数值零空间。原已声明末端 CAD 零位仍明确列出。
不以收敛或多个初值一致冒充实际精度认证,草稿不更新 `latest_passed`。
实际旧数据生成于 `calibration_output/O30_RIGHT_001/draft_20260923_110651_v2/`:
拇指侧摆零位 -2.761403°,MCP 零位 -1.639888°;四关节行程分别为旋转 52.171947°、
侧摆 115.878573°、MCP 104.415960°、IP 82.065095°。其余缺测关节保持原 CAD。
完整结构 URDF 已通过 JSON 读回重建、最终 FK(最大差 4.44e-16)和标准 ROS 加载。
训练最大标签误差仍约 8.047 px,独立验证未通过原精度要求,报告如实保留。
原始数据哈希未变,没有实机运动。拇指旋转与 IP 的绝对零位未标为实测修正。
第一次导出发现有效稳态窗口中也存在无同步指令的原始帧,范围证据生成误读了这些帧。
已让正式/草稿共用范围证据函数,并统一通过既有 `fitting_frames` 选择同步完整记录;
保留全部原始日志,不把缺失指令填成零。失败 v1 结果保留,成功产物另存 v2。
## 全手草稿目标与采集后自动导出(历史,已由独立离线入口取代)
按用户要求,完整目标明确为 15 个非末端零位与全部 20 个运动范围;仅五指末端零位
保留 CAD。`correction_scope` 列出目标、实际估计与缺失关节,`full_target_generated`
只在目标均覆盖且文件校验通过后成立。旧局部拇指草稿不等于全手完成。
正常 CAPTURE_COMPLETE 后,启动器关闭 SDK/相机进程、销毁监控节点与自建 ROS 上下文,
刷新日志,然后才运行离线草稿。缺数据或求解失败都只保存结果,不进入补采或归位运动。
每次启动保存受保护 Profile 原始字节快照;后续仅修正安装连杆时可自动匹配旧会话身份。
上述自动拟合行为已在后续收敛中移除:默认采集现在归位、封存、关闭控制后直接退出,
仅写 `capture_result.json`;使用会话 `capture_product.yaml` 和 `--offline-raw` 单独拟合。
运动路线、2+1 分区、一次质量补采及硬件保护保持。配置快照一起进入封存清单。
独立 XML FK 图像软件检查使用当前 ID17 近端指节绑定、原正式采样网格和机位:
单个有界初始化收敛(12 次联合迭代),15 个零位参数均可观测,雅可比秩 463/463;
已知真值的最大零位差 0.001054°。复用这次拟合输出完整 URDF,确认 15 个 origin.rpy
实际改变、五个末端 origin 不变、20 个运动范围写入,最终 FK 最大差 5.55e-16、标准
ROS 加载通过。报告位于 `calibration_output/software_review_20260923_full_draft/`。
这是必要的全手软件检查,不是实机精度结论;没有以合成结果代替完整实机流程。
# 2026-09-23 ID10 调整与断点保留
实机 `20260923_120425` 保存 78 个通过方向(拇指、小指、无名指、中指),食指 MCP
六方向及 PIP 首方向两次都因 ID10 缺图失败,后续方向由操作者中止。原始日志、角点、
配置和哈希记录完整保留;这是中断会话,未宣称全手结束或归位。
操作者最初确认 ID10 仍在食指中节,后补充说明主要调整的是遮挡 ID10 的 ID15,且
ID15 仍在原来的侧摆支座;两者均未更换连杆。新增离线准备草稿断点入口,保留
连续通过前缀,并按 CAD 拓扑隔离受影响手指的历史观测;新数据采用新安装参数,
其他方向的补采预算不变。普通恢复及正式发布门限保持原义;该调整流程的未复核
安装明确标为草稿,不通过正式发布入口。
第一次启动恢复固定基准漂移不超过 0.325 px,但尚未测量的食指正面 ID15 归位位置
相对历史偏移 5.945 px,保护拒绝且未开始正式扫描。修正为整条受影响手指的历史
观测隔离,避免借用旧食指辅助观测绑定新安装,同时不放宽其他标签与固定基准门限。
## 2026-09-23 四指侧摆重采的恢复检查
操作者授权仅重采末项四指侧摆,在独立输出目录保存前 96 个方向的完整前缀,
旧侧摆全部记录保留在原会话,正式剩余计划为 12 个方向。两次实际启动在扫描前
被活动标签基准检查拒绝:`132507` 的 ID4/ID5 差异为 6.416/12.295 px,
`132938` 为 5.182/9.673 px;固定基准最大漂移分别为 0.264/0.219 px。
操作者确认小指标签与支架未调整。相同 SDK 指令及稳定反馈不能独立证明恢复了
相同物理姿态,因此在线不再将活动标签像素差直接判定为安装改变。
原始采集恢复仅将活动标签差异记录为 `requires_offline_review`,继续导入原证据
与尝试次数;不修改原始角点、旧基准或质量结论。固定基准和受保护输入检查仍为
阻断条件,失联、硬件故障及停滞保护不变。离线正式验收保留严格安装核验,并拒绝
带未复核安装记录的数据;草稿允许分析此类数据,但不得宣称精度或安装一致性通过。
本次只运行软件检查及已保存参考的只读回放,机械手未运动,后续由操作者启动采集。
## 2026-09-23 中指补段和四指联动的指令核对
`135113` 的中指向上补段(13:54:05.985–13:54:12.319)保存 625 条实际发送
记录,四指向量由 `[0,0,0,0]` 到 `[0,80,0,0]`;没有中指补段越过 80 的记录。
下一轮联动才继续增加中指指令。联动指令为 `[64,124,64,64]` 时,实际反馈为
`[59,120,0,55]`:无名指未动,而其他手指继续推进。操作者确认存在接触挤压,
因此补段范围正确不能证明同步联动的实体避让安全,现有 4 秒保护也不是同步到位保证。
状态增加子段名称及各通道声明范围,命令/反馈标明当前通道;实际下发日志增加
子段、方向、尝试次数和通道身份。这些只是显示及诊断改动,不改变运动路线、
速度或保护预算,不宣称解决接触问题。三轮/归位/立即补采相关检查 3 passed
(1.48 秒),另核对三轮全部 12 个方向目标及子段显示。未启动机械手,未操作
操作者运行的范围检查 SDK。实际指令审计保存在该会话的 `middle_lower_command_audit.json`。
@@ -0,0 +1,83 @@
# O30 剩余精度问题:封存数据定位结果
2026-09-24。只读复核旧外参全手 `20260923_142201` 和新外参拇指
`20260923_183138`,使用各自封存配置和本轮已冻结的参数。诊断脚本与 JSON 报告位于
`calibration_output/software_fix_20260923_auto_urdf_v2/`。没有修改原始证据、候选 JSON/URDF、
采集路线或精度门限;没有硬件运动。本轮新增的只是只读诊断脚本和这份报告。
## 已确认的三个独立问题
**字节到关节角不能仅靠 URDF 上下限线性换算。**逐字节比较程序生成的 JSON 曲线
与同一 URDF 的线性插值:拇指 yaw 上行在字节 96 的差为 3.167°,IP 上行在字节 128
的差为 2.273°;两端差均约为 0。这解释了为什么扩大 URDF 上限不能修正中间行程。
物理几何仍由 URDF 提供,仿真/ROS 的字节输入必须先经认证 JSON 的对应方向曲线
映射为关节弧度。当前 JSON 精度未认证,不能通过改变加载标志直接用于正式控制。
逐字节结果见 `driver_gap.json`。
**新拇指 roll 的正面图像有重复出现的空间残差。**冻结拟合在训练轮的 thumb IP
标签最大前向误差 7.238 px,验证轮独立测角后最大 7.194 px。训练轮字节 0 的
thumb IP 标签误差中位数约 2.093 px,字节 255 约 7.114 px;MCP 与 IP 标签在
高角度的误差方向接近。高误差在训练和验证轮均出现,继续增加同一模型的优化
迭代没有足够依据。旧全手的最坏观测另在顶部 thumb yaw 标签、thumb MCP 任务中,
最大 16.468 px,不应混作新拇指 roll 的误差。
受限的几何假设检验:冻结其他参数后,仅用新批次训练轮拟合一个相对字节 0
的 CMC roll 转轴平移,得到局部 y≈+0.052 mm、z≈−1.969 mm。用该值回放独立
验证轮,未做每图角度修正的 thumb IP 最大前向误差从 7.672 px 降到 5.261 px,
MCP 从 6.793 px 降到 3.057 px;依旧未通过 1.5 px 图像门限。
这只能说明“共同上游几何变化”可解释一部分误差,不能区分真实转轴位置、
标签随运动的位移或受力形变,更不能据此修改 URDF 的 `origin.xyz`。
试探代码及结果见 `probe_thumb_pivot.py` / `thumb_pivot_probe.json`。
**旧全手参考在新批次的辅助食指端点存在偏差。**新拇指 roll 任务明确令食指侧摆
通道保持字节 0,其他拇指任务为 255。该食指标签在验证轮字节 0 的误差中位数
5.192 px,在 255 时为 1.158 px。只用新批次训练图像试探一个 +0.974° 的
食指字节 0 临时角度修正,验证轮中位误差降到 1.954 px,仍高于门限。
这支持旧参考的端点角度有偏差,但不支持用单个辅助保持姿态改写整条四指曲线。
旧全手原始采集仍少一个训练方向(107/108),旧参考系统误差也没有完整传入
新拇指不确定度。试探代码及结果见 `probe_reference.py` / `reference_probe.json`。
## 观测条件与下一次采集
新批次拇指 roll 的三轮稳态窗口中,顶部相机分别保存了 32、29、37 帧,
**全部只有固定 `top_base` 标签**;随拇指运动的顶部 `thumb_cmc_yaw` 标签没有
一帧有效观测。正面相机同时看到了 thumb MCP/IP 标签。这足以发现投影误差,
却不足以从独立视角确认 CMC roll 转轴的三维位置。
逐帧来源计数见 `thumb_roll_visibility.json`。
下一批采集前的顺序:
1. 在当前拇指 roll 全程的 0、各中间节点和 255,确认顶部或另一独立视角持续
看到**随拇指运动的标签**,并且正面仍可看到 MCP/IP 与固定参考。先用静态
预览确认可见性,避免整手运动后才发现动态标签缺失。ID17 当前固定在
`thumb_proximal`;若能刚性固定在 yaw 子连杆 `thumb_metacarpals_base2` 并保持
全行程可见,可减少 MCP 运动对 roll/yaw 观测的混合,但必须更新标签连杆绑定。
移动相机需要重测外参;只移动标签需要重估其安装位姿。两者均需建立新批次,
不能沿用旧标签安装证据。
侧面机位当前仅声明四指运动标签;若用于 yaw,需要侧面实际可见的拇指运动
标签与固定参考,并接入 Profile 与离线求解。若侧面与顶部共用物理 ID17,
还需让求解器保存各机位观测并共用同一个标签安装参数,不能将两个机位当成
两个独立安装。
2. 对拇指 roll 的端点分别核对两轮方向、反馈稳定、标签角点和刚性;检查字节 255
是否存在遮挡、标签与手指碰撞或安装件受力。当前同一误差在训练/验证重复出现,
应优先排查物理条件和 CAD 转轴,再决定是否扩大几何拟合参数。
3. 若目标是**正式全手 URDF**,在同一有效外参和安装条件下采完整 108 方向的
全手 2+1 数据。旧批次缺失方向不能用新外参的几帧直接拼成已完成旧批次。
新拇指 24/24 可作诊断和拟合初值,但不替代同批全手独立认证。
4. 用新批数据先冻结训练解,再独立验收图像、零位、端点和整段曲线;另行验证
JSON 曲线驱动的 URDF。若确有多视角证据支持转轴位置偏差,再扩展共同几何
模型和 URDF `origin.xyz` 的授权字段,并验证 JSON 回读和最终文件 FK。
当前求解器只导出零位角与限位,增加图像本身不会自动修改转轴位置。
新批次 yaw 字节 16 下降方向的单张正面图像出现 3σ≈1.733° 的测角不确定度。
相同保持指令与反馈下,顶部也有三张动态标签图像。将该保持窗口的正面与顶部
观测作为同一角度的**诊断**时,3σ≈0.230°;时间跨度约 0.242 s。
这表明当前逐张单视角测角方式可能高估该稳态窗口的不确定度,但重复图像存在
共同几何误差,不能简单按独立噪声缩小正式置信区间。联合多视角验收须显式
处理曝光同步与相关误差;即便解决这项不确定度,前述 7 px 图像残差仍会拒绝发布。
数据见 `multiview_angle_probe.json`。
当前程序生成候选继续保持 `accuracy_passed=false`、`publication_allowed=false`;
仿真默认和 `latest_passed` 均未切换。保存的报告保留了每个受影响标签、任务、
方向和样本时间戳,见 `old_residual_diagnostics.json` 与
`new_residual_diagnostics.json`,便于新批次做同项对照。
+105 -2
View File
@@ -13,9 +13,18 @@ O30 右手的构建、相机外参重标和完整操作说明见 [O30 右手标
O30 当前使用反馈判断运动、稳定及零位恢复;允许指令与反馈存在固定偏差。最终 JSON 仍为指令→视觉角度,反馈映射只作诊断。
测试的分层、选择范围与耗时记录见 [测试说明](TESTING.md)。
## O30 当前默认:轻量采集,离线修正
默认 `raw_joint_2_plus_1` / `raw_joint_images_v2`:移动标签仅保存二维角点,
每任务两轮训练加一轮验证;当前失败方向立即补采一次,仍失败记录缺项并继续。
采完归位、封存数据、关闭控制栈,终态 `CAPTURE_COMPLETE` 不等于 URDF 通过。
后续使用 `--offline-raw` 读取同一批数据修正,失败不再触发运动。
启动、离线命令和协议边界见 [O30 轻量标定说明](O30_RAW_PROTOCOL_IMPLEMENTATION.md)。
以下分阶段和自适应章节为历史显式试验入口,不是 O30 当前默认流程。
## O30 分阶段训练试验
新增显式选项 `--training-policy staged_2_to_3`,默认仍为 `fixed`。Tag 数量、编号、尺寸、位置、
新增显式选项 `--training-policy staged_2_to_3`,该历史策略以 `fixed` 为对照。Tag 数量、编号、尺寸、位置、
相机布局、完整网格、运动速度和验收门限不变。新策略按原任务顺序执行全手轮次 0、全手轮次 1,
进行训练检查;必要时仅集中补一次轮次 2,随后冻结整手模型,最后采集独立验证轮次 3。
局部零位补采包含同轮几何依赖,分段任务整体补采,共享掌部问题扩大到全手。补采后仍不合格即失败。
@@ -47,7 +56,7 @@ JSON→URDF 重建、文件与像素验收、成对发布、失败不更新通
## O30 自适应训练试验
默认仍为 `fixed`:三轮训练加一轮独立验证。新会话可显式选择 `adaptive_2_to_3`:两轮训练后
该历史策略以 `fixed` 为对照:三轮训练加一轮独立验证。新会话可显式选择 `adaptive_2_to_3`:两轮训练后
检查覆盖、稳态网格、双向重复性和可计算的几何零位质量;证据不足补第三轮,第三轮仍不合格
就失败。每个方向的缺样恢复仍最多同速重扫一次,准备缺样仍最多局部恢复一次。
速度、双向运动顺序、节点、准备路径及最终精度门限均保留。
@@ -840,3 +849,97 @@ ros2 run linkerhand_calibration compare_calibration_urdfs \
主观测必须能与原始图像记录逐项对应,同一图像不能重复计数;其他动作下的有效角点也直接
投影验收,不先拟合成三维位姿。缺少主观测的原始记录或二者 SDK 条件不一致时拒绝发布。
这不表示联合求解或外参自校准已经通过验证。
## 低精度离线修正草稿(O30)
最新程序修复、两批实采数据验证及未通过的精度项目见
[O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
当前目标是 **15 个非末端关节零位+20 个关节运动范围**;拇指 IP 与四指 DIP 的五个
末端零位保留 CAD,其运动范围仍由数据拟合。完整全手数据可提供局部拇指缺少的掌坐标
约束;只采部分任务时,不能将局部输出当作上述全手目标已完成。
O30 原始采集正常结束后,启动器完成归位封存、关闭 SDK/相机及控制上下文后退出。
`capture_result.json` 只报告采集结果,完整返回 0、缺项返回 3,不自动拟合。
每个新会话保存可供离线使用的 `capture_product.yaml`、Profile、源 URDF、内外参和
标签配置快照,并随原始证据封存。离线失败不改变采集状态,也不会再次运动。
也可手动从已完成任务生成完整结构的修正 URDF,再优化精度:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config /绝对路径/会话/capture_product.yaml \
--offline-raw /绝对路径/会话/raw_samples.jsonl \
--offline-draft --offline-output /绝对路径/新的草稿目录
```
草稿复用现有图像拟合器、零位约束和 URDF 写入器。训练数据估计可观测的零位及
双向指令曲线,验证数据仅报告误差;超过原精度门限仍可生成文件。
未独立观测的反向换向端点采用有来源的连续性约束,不保留可任意改变行程的自由参数;
约束写入 `motion_range_evidence.command_support`。缺少端点依据时拒绝生成行程。
`draft_urdf_linear_holdout.json` 单独报告纯 URDF 上下限线性驱动的误差;曲线精度
不能替代线性驱动验收,未通过精度验收的草稿不更新正式发布指针。
每个任务至少有一轮完整训练即可参与草稿求解,另一训练轮已通过采集质量检查的
完整方向也保留;不拼接失败尝试,不用第三轮补训练缺项。初始化使用该任务已完整的
训练轮,逐任务覆盖与选择理由写入 `draft_result.json` 的 `training_selection`。
父关节观测约束仍保留;草稿可覆盖全手与原始采集完整是两个独立结果。
缺测关节保留原 CAD,单根手指无法观测的根零位及已声明的 CAD 末端零位单独列出。
不从数值不可观测的自由度导出任意零位。JSON 读回、字段授权、运动范围、最终 FK、
标准 URDF 加载和来源哈希检查仍必须通过。
离线模型纠错可使用单独的 Profile/Product 文件,只允许修改标签连杆绑定和
`sdk_to_joint_direction`;原始会话仍按保存的 `capture_profile.yaml` 校验。
基准 SDK 指令、通道、采样路线和速度不允许借此更改。新旧方向及配置哈希写入
`draft_result.json`,不重写原始记录,也不影响在线控制。
求解前以训练轮的双向运动和两种平面姿态候选检查 CAD 平行根轴的方向一致性;
所有候选均反向时拒绝拟合,记录 `draft_sdk_to_urdf_direction_conflict`。
不能靠零位补偿错误的 SDK/URDF 方向。文件能加载及参数数量齐全,也不等于物理零位正确。
现场明确确认四指在采集基准指令下竖直时,离线命令可增加
`--draft-upright-splay-zero`。此选项先检查源 CAD 的四指零位为掌坐标竖直,
再从求解变量中移除四个侧摆零位,重新拟合其他零位与全部指令曲线;不是导出后改姿态。
JSON 将这四项标为 `operator_defined`,分别统计 11 个拟合零位、4 个用户定义零位及
5 个 CAD 末端零位,不能把四个定义值当作独立测量。原始基准指令、采集配置和记录不变;
该选项不能进入在线采集或正式发布。
同批数据已有局部草稿时,可增加 `--draft-initialization-from /绝对路径/已有草稿目录`。
入口核对原始数据及配置身份,只读取训练选出的起始姿态和曲线;所有待求参数仍在
全手训练图像中联合调整。不同批次、使用验证选择的结果不能作为该初值。
其文件哈希和用途写入新报告,不覆盖已有草稿。
全手草稿按相同标签、相同祖先关节指令/方向及相机参数合并重复预测,使用样本数权重
及组内散差保持原始最小二乘目标、梯度与信息矩阵;误差报告仍逐张原始图像计算。
草稿数值停止 `ftol=1e-5`,正式求解保持 `1e-9`;这不改变任何精度、可观测性或
发布门限,草稿无论数值收敛与否都不能自动更新 `latest_passed`。
草稿最多保留四个收敛初值的候选并按训练误差选择,报告已计算初值数量及是否穷尽;
不以这个有限搜索声称已排除全部几何解。正式候选搜索与发布验收保持原规则。
输出 `o30_right_draft.urdf`、`draft_calibration.json`、`draft_result.json` 及训练/验证报告。
草稿 JSON 使用独立格式,不作为正式控制映射或精度证书自动加载;URDF 可标准加载。
该入口允许读取中断会话的完整方向,不启动 SDK、不补采、不更新 `latest_passed`。
`--offline-draft` 不能与 `--publish-offline` 同用,正式精度验收入口保持不变。
若旧数据的标签连杆配置有误,提供 `--capture-profile /绝对路径/原Profile.yaml`;
新会话已保存 `capture_profile.yaml` 时会自动使用该快照。
先核对原 Profile 与会话哈希,再仅允许当前 Profile 修正标签安装连杆。
标签身份、尺寸、运动策略、相机与 CAD 来源不允许借此改变,原始记录保持只读。
采集中途重新安装移动标签时,不能直接沿用该标签原来的安装参数。O30 草稿流程可先
离线建立新断点,再按普通断点恢复:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o30_right_product.yaml \
--prepare-remount-from /绝对路径/原会话 --remounted-tag-id 10
```
此步骤不启动相机或 SDK。只允许保留调整标签之前连续通过的任务前缀;原会话不修改,
新断点按 CAD 拓扑排除受影响手指的全部旧标签角点,从首个受影响任务重新采集,
其他方向的补采次数保持原值。未开始新扫描的准备断点可扩大排除范围;已开始的尝试
不能借此重新获得预算。
原会话保存一次性后继指针,后续重启使用普通恢复,不能再次从旧安装重置尝试次数。
原始角点续采时,活动标签在同一 SDK 基准指令下的像素/反馈差异及不可见情况
列为“待离线复核”,不将回位姿态差异直接判成标签重装,不据此暂停采集。
固定基准、配置身份及运动保护仍须通过。前后安装参考、差异和原补采预算均保留;
未核实差异只允许进入未认证修正草稿,正式发布仍拒绝未复核的安装关系。
标签必须仍在原配置连杆上;若移到了其他指节,先修正连杆绑定。
+197
View File
@@ -1,5 +1,85 @@
# 标定测试的执行范围
2026-09-24 O30 剩余误差诊断:只读两批封存数据及已冻结参数,旧/新独立验证图像
分别 2681/516 帧;按机位/标签/任务/指令拆分残差,受限试探仅用训练图像选参数、
验证轮报告结果。顶部相机在新拇指 roll 三轮只见固定标签;跨批食指端点偏差及
拇指 roll 高角度重复性残差已定位。JSON/URDF 逐字节差、来源哈希和所有试探结果
记录于 [O30_REMAINING_ISSUES.md](O30_REMAINING_ISSUES.md)。未修改生产拟合、采集、
门限或已生成候选;诊断结果不计作正式精度测试通过,也未驱动硬件。
2026-09-23 自动 URDF 修复:采集/局部/重装组 43 passed(17.06 秒),离线草稿及
换向端点组 43 passed(34.18 秒),更新审计 3 passed(0.33 秒)。完整真实
coordinator 的虚拟时钟 108 方向集成 1 passed(97.21 秒),用于核对完整调度和
训练/验证隔离,未启动硬件。最后补齐快照 mesh 与封存路径检查后,仅复查受影响
4 项,4 passed(2.38 秒);此组与前面重叠,不重复累计。
早期失败包括旧夹具仍使用已纠正的标签/方向配置、近端插值掩盖未观测换向端点;
分别修正历史场景声明、改用有来源的端点连续性约束后通过,没有降低验收门限。
旧外参全手与新外参拇指均按自身配置回放,训练重新收敛,JSON 重建 URDF 字节
一致,FK 最大差 6.66e-16,ROS/MuJoCo 加载通过;独立精度仍未通过,未发布。
证据、物理精度限制及候选路径见 [O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
2026-09-23 离线逻辑复核:复用连续候选关联替代逐帧 PnP 排序,扫描起点按扫描身份纳入;
补全欠约束 SVD 零空间,范围证据记录实际可见训练样本和轮次,单轮仅允许未认证草稿。
新增 7 项集中回归通过;其余受影响项分组通过。首次 24 passed/4 failed/2 errors(22.21 秒)
中的失败分别为未 source ROS 和新增夹具字段名错误;修正后对应及 CLI 10 passed(2.13 秒)。
范围边界组 5 passed(1.20 秒),中节绑定/历史数据隔离组 2 passed(1.12 秒)。
用户确认 ID12–ID15 随中节运动后,新离线配置将四标签绑定至 `*_middle`;封存数据回放生成
`offline_confirmed_middle_tags_draft`,训练坐标 RMS 1.4867 px,最终 FK 差 5.55e-16。
四指角度子项通过,但拇指 roll/MCP 及全局图像/不确定度未通过;未发布,未控制实机。
详细问题、证据和复现命令见 [O30_OFFLINE_LOGIC_REVIEW.md](O30_OFFLINE_LOGIC_REVIEW.md)。
2026-09-23 四指侧摆竖直基准:新增显式离线选项 `--draft-upright-splay-zero`,
四项作为用户定义基准移出自由零位参数,全部行程与其余零位重新拟合。
新增/CLI 用例 6 passed(13.04 秒),相关范围、缺项、导出及生命周期用例
18 passed(16.88 秒)。`offline_upright_zero_draft` 最终 URDF 的四指正面侧偏均为
0°;JSON/FK 最大差 8.89e-16,标准加载与来源哈希检查通过。
报告区分 11 个拟合零位、4 个用户定义零位、5 个 CAD 末端零位和 20 个拟合范围。
本次独立验证已算出角度误差,但整手精度仍未通过,未更新 latest_passed。
原始数据保持不变,未进行硬件运动;上一版侧摆偏移 −6.6° 至 −9.4° 的草稿已附拒绝说明。
2026-09-23 物理零位复核:旧 `offline_full_hand_draft` 因四指 SDK/URDF 方向反转而
错误估计约 −20° MCP 零位,已附拒绝说明,不能将其文件检查通过理解为零位正确。
独立离线配置纠正方向后,`offline_corrected_zero_draft_v2` 的四指 MCP 为
+0.385°、−0.306°、−0.690°、−1.475°,没有强制置零;JSON/URDF FK 最大差
7.78e-16、标准加载及原始数据哈希检查通过。独立验证求解器仍报 IndexError,
保留 traceback,精度/发布均为 False;没有硬件动作。
本次 `test_raw_draft.py` 27 passed(30.15 秒),新增实采角点方向回归、离线模型
修订边界及验证求解异常不冒充精度通过的检查。原始数据仍为 107/108 单元。
全手草稿候选搜索收敛为最多四个收敛结果,明确记录非穷尽;相关数值不可观测拒绝、
候选预算和训练初值契约 3 passed、21 deselected(0.90 秒)。
实机封存数据 `20260923_142201` 已生成全手草稿于
`calibration_output/splay_recapture_20260923_132346/offline_full_hand_draft/`:
15 个非末端零位、20 个范围,五个末端零位保留 CAD;原始采集仍为 107/108。
复用已计算且收敛的同目标训练候选,重算原始像素误差确认一致,再运行正常草稿验收;
缓存文件、脚本和来源哈希见 `numerical_fit_reuse.json`。JSON/最终 URDF FK 最大差
6.66e-16,标准加载通过。独立验证角度求解失败,四个零位接近 ±20° 模型边界;
精度和发布均未通过,`latest_passed` 未更新。没有硬件动作。
2026-09-23 同批数据全手离线求解支持:最终 `test_raw_draft.py` 23 passed(14.47 秒)。
新增重复观测合并的原始目标、梯度和信息矩阵等价检查,原始图像误差仍逐帧报告;
训练初值拒绝不同数据批次或使用验证选择的结果,相关 CLI 不能进入在线运动。
首次等价测试的近零梯度逐元素断言受浮点消减影响(最大绝对差 4.62e-7、全梯度相对差
3.32e-13),改为完整梯度范数误差小于 1e-11 后通过,未改模型或采集精度门限。
试验过的自适应参数缩放未在合成全手收敛检查中通过,已撤回;最终沿用原参数缩放。
仅未认证草稿的数值停止容差为 1e-5,正式求解默认值及发布检查不变。
2026-09-23 离线草稿支持单轮完整训练:`test_raw_draft.py` 20 passed(8.42 秒)。
覆盖任一训练轮缺四指联动上行时仍保留全手求解范围、初始化选择另一完整训练轮、
两轮互补缺项或完整验证轮不能冒充完整训练轮,以及原有不可观测零位拒绝、JSON
重建和只读离线生命周期契约。未改变正式采集完整性或发布门限;未恢复历史测试。
实机数据的实际离线求解另行记录,不把上述软件测试当作精度验收。
2026-09-23 原始续采的活动标签基准检查:12 passed、30 deselected(1.37 秒)。
覆盖活动标签像素/反馈差异记录后继续导入、缺少基准记录的离线复核标记、固定基准
移动仍拒绝、非有限反馈拒绝、正式发布拒绝未复核安装、草稿仍检查来源完整性,
以及现有标签重装/次数持久化契约。真实 coordinator 检查确认没有因活动标签
基准差异暂停,且将未复核状态写入日志。未启动 SDK 或相机。
两份实机停止记录 `132507` / `132938` 的固定参考与 96 个方向断点只读复核位于
`calibration_output/splay_recapture_20260923_132346/software_review_resume_reference.json`。
不重判旧采样质量、不修改角点;下一任务为四指侧摆第 1 轮正向联动。
这只证明恢复检查和任务位置正确,不代表后续实机运动或 URDF 精度验收通过。
2026-09-22 正式 2+1 局部失败调度收敛:新增 test_taskwise_recovery.py,覆盖
完整依赖任务组延后、训练/验证顺序不变、已完成前缀不变、一次回访预算、真实
coordinator 的带视角错误处理、多视角参考变化不被掩盖、失败后归位且不进入拟合、
@@ -583,3 +663,120 @@ Schur 协方差与完整逆矩阵一致、冻结模型拒绝无法解释的阶
只写入 `calibration_output/software_review_20260922_raw_joint/known_truth/` 和独立软件测试指针;实机 `latest_passed` 未修改。
未进行本次新协议全手实测或实机姿态对照;不能把以上软件结果当作现场精度证明。
## O30 轻量原始角点采集与立即补采(2026-09-23)
新增集中回归 `test_raw_lightweight.py`,不恢复外部删除的历史测试。覆盖当前方向立即补一次、
中断预算、仅固定标签在线跟踪、死区/平台、堵转/反向/噪声、静止采样冻结计时、
不稳定终点有界退出、采完禁止发送、封存校验、旧协议读取与混合拒绝、旧合成 URDF 重建。
首组 6 passed、5 failed(4.25 秒):五个失败是扫描测试没有先进入任务避让姿态,
修正夹具起始向量后只复查该组,5 passed(0.98 秒)。后续边界组 10 passed、
1 failed(4.18 秒):竞争控制测试缺少 Start 后反馈,补齐后单项 1 passed(0.77 秒)。
完整真实 coordinator 虚拟时钟、单主机位移动标签、108 方向、无补采、归位封存与
训练/验证分区检查 1 passed(85.13 秒),属于必要整手调度集成,不是实机通过。
旧合成产物 v1/v2 JSON→URDF 重建与终态入口 3 passed(0.87 秒)。
删重复图像记录并保留未稳定原始帧后,受影响采集/补采恢复/封存/兼容组
7 passed(6.25 秒)。各组存在重叠,不累计为一次全量测试。
正式 CLI 配置校验通过。实机 `20260923_104837` 去程通过,拇指旋转回程指令 162、
反馈 253,累计实际运动无推进 4.005 秒触发保护;未重复启动,控制栈已退出,
停在当前位置,未执行自动归位。仅 1/108 方向完成,未封存为完整采集、未发布 URDF。
同段移动标签角点最大位移约 0.47 px、固定基准约 0.50 px,未见明显回程运动;
不能据此删除停滞保护,也不能宣称全手稳定采集通过。
补充只读离线入口检查初次失败(3.61 秒):虚拟相机夹具缺少实际入口要求的
`rectified_camera_model` 日志,补齐夹具后在后续组通过,确认缺项拒绝发生于
离线验收、原始文件哈希不变且无新运动。
用户随后明确授权硬件停滞重试一次:复用现有 StallRetry,O30 原始采集限制为
首次尝试加一次重试,等待 3 秒,同方向共享预算;其他硬件保护不进入重试。
首组 6 passed、1 failed(4.19 秒):测试将保护停止发送的最后保持指令也误认为
等待期间推进,修正断言后四种实际 coordinator 场景 4 passed(1.31 秒)。
运动保护代码和计划身份同步更新,旧策略会话不得混入新断点。
补充端点阶段切换与反馈抖动回归:180 ms 图像延迟单项通过(7.87 秒),
扩大到 300 ms 并增加 4 u8 往返抖动后复现 3 个失败、4 个通过(14.95 秒)。
隔离模块验证有限端点等待和推进历史最高值修正,8 passed(7.98 秒),未影响当时运行的实机。
用户随后明确端点只看实际发送 SDK 指令:最终采用有效稳态记录中的已发送指令核验端点,
不额外增加端点等待、不要求反馈到 0/255,也不把稳态图混入运动覆盖。
真实 coordinator 300 ms 延迟、反馈端点仅 10/240、离线误判复核与中断拒绝,
加上方向推进、补采、封存及旧协议边界,17 passed(10.04 秒)。
SDK 身份错误保留原字段、其他硬件保护和单次停滞重试、旧合成 URDF 重建,
9 passed(1.86 秒)。
实机 `20260923_110651` 完成 35 个方向,原记录 29 个通过、6 个端点误判;
在小指 PIP 验证回程因 `o30_sdk_identity_mismatch` 停止,反馈仍约 29.81 Hz。
未触发停滞重试,未归位封存,未更新 `latest_passed`。异常身份字段在旧日志中缺失,
不能将此状态直接等同于物理通信断开。修正后只读复核同一数据,35 个完整方向均通过,
缺 73 个方向,禁止发布,源数据哈希未变化;报告见实现文档。
最终端点与推进规则下,完整 coordinator 单主机位、108 方向、归位封存及
训练/验证分区回归:1 passed(87.51 秒)。该必要整手集成仍仅为软件验证。
源码编译和 `git diff --check` 通过;未恢复外部删除的历史测试。
2026-09-23 用户确认 ID17 在拇指近端指节后修正安装连杆及产品哈希。
正式配置加载、拓扑反馈依赖 0/1/6、仅顶机位、17 任务/108 方向校验通过;
临时校验脚本首次误用 TagSpec.id,改为 tag_id 后通过,生产加载未失败。
既有原始角点、立即补采和旧协议边界回归 3 passed(0.85 秒)。
现有真实数据的局部拟合揭示原绑定错误,但修正后最大训练标签误差仍为 8.047 px,
原精度门限未通过;软件回归通过不代表实机 URDF 可发布。未重新运动或改写源证据。
2026-09-23 低精度离线草稿:新增集中 `test_raw_draft.py`,加已有合成正式 URDF
重建检查,13 passed(1.55 秒)。覆盖真实零位写入与 FK、缺测关节不改、JSON 零位/
曲线/来源篡改拒绝、不可观测零位拒绝、精度超限仍允许草稿、旧 Profile 仅允许标签
连杆修正、同步缺失帧不供给范围证据,以及 CLI 不进入运动或正式发布。
实测旧数据的正式 CLI 草稿路径已运行,生成完整结构 URDF 并通过标准 ROS 加载;
JSON/URDF FK 最大差 4.44e-16,两个拇指零位和四个行程实际改变。
独立精度验收仍失败,输出 `accuracy_passed=false`,没有更新 `latest_passed`。
第一次实数据导出因原始帧含空指令失败,修复共享证据过滤后 v2 成功;未删除失败审计。
2026-09-23 全手 15 零位/20 行程目标及采集后自动导出:受影响范围与真实 runner 生命周期
5 passed(1.75 秒),验证完整目标、部分数据不能冒充全手,以及成功/缺项/求解失败三种
情况下 SDK 栈、监控节点和自建 ROS 上下文均先关闭,离线阶段从不重新启动运动。
补充 Profile 原字节快照后,仅复查三个生命周期场景,3 passed(1.66 秒)。
正式 CLI --validate-only 通过,明确列出 15 个零位和 20 个行程目标。
必要的一次全手数学检查:独立 XML FK 生成 2028 帧训练角点,463 参数图像模型单初值
12 次联合迭代收敛;包括生成数据和秩检查共 31.82 秒,最大零位差 0.001054°。
复用保存参数进行完整 JSON→URDF 导出与标准加载,不重复拟合;15 个零位修正、五个
末端零位保留、20 个行程、最终 FK 最大差 5.55e-16 均通过。非实机验收、不更新正式指针。
2026-09-23 ID10 调整后的草稿断点:新增 `test_raw_remount.py`,验证 78 个连续通过方向
保留、旧 ID10 角点排除、原始像素不改、已用补采次数保留、重复调整声明拒绝、未知/
固定标签拒绝,以及固定基准和已知标签漂移仍拒绝。基准不可见标签只能进入草稿,不能
正式发布。首轮 5 passed,测试夹具共享列表导致 1 failed;修正夹具后,对失败项、CLI、
中断预算及受影响草稿契约运行 19 passed(2.74 秒)。没有恢复已删除的历史测试。
随后按 CAD 拓扑隔离整条受影响手指,验证旧观测不混用、已开始扫描禁止扩大排除范围,
受影响 6 passed(1.14 秒)。
2026-09-23 中指侧摆 0–80 短段:按已声明段长度缩放反馈分箱密度,保留图像/指令覆盖、
实际运动分辨率、4 秒推进及其他硬件保护。相关短段、堵转/反向/噪声、硬件保护、离线
生命周期与范围证据检查 14 passed(3.02 秒)。只读复核 `20260923_125329` 的两个
第二次完整尝试均通过,原始失败记录未改写,结果保存为 `short_segment_reassessment.json`。
## 离线零位方向回归
`test_raw_draft.py` 中的 `parallel_root_motion` 用同一批不变角点核对平行根轴方向,
四指 SDK/URDF 符号反转时必须拒绝,不能通过关节零位吸收错误。
`old_capture_model_correction` 验证离线绑定/方向修订仍保留原始采集身份,并拒绝速度变更。
方向用例来自首轮实采角点节选 `fixtures/o30_recorded_root_directions.json.gz`,
保留来源 SHA256,不含验证轮。其余数值回归仍使用合成数据;不发送硬件指令。
2026-09-23 拇指独立原始采集:局部范围编译保留 `raw_joint_2_plus_1`,清除被保留关节的
零位测量声明;训练策略允许经过范围契约验证的 O30 局部配置。复用原执行器和补采预算,
未修改拟合模型或运动保护。新增范围往返序列化、24 单元原路线/分区一致、立即补一次与归位后
结束测试,连同全手原调度两项回归共 4 passed(1.03 秒)。
实机会话 `thumb_recapture_20260923_175829/O30_RIGHT_001/20260923_180129` 在启动归位阶段
MCP 指令 0/反馈 120,两次无推进后停止;正式扫描 0/24,不能记为实机采集通过。
标定控制链已关闭,原全手数据未修改,等待现场机械接触观察。
2026-09-23 新外参拇指与旧四指离线组合:修复独立验证总判定的 NumPy bool_ JSON
序列化失败;新增冻结旧参考参数的图像拟合更新与显式局部验证范围。参数按语义转移,
不共享新旧相机外参,不改变 CAD 轴,无观测增量冻结,零位存在自由规范时拒绝。
`test_raw_joint_update.py test_raw_draft.py` 共 37 passed(20.74 秒);随后加入零位
可观测性保护,更新工具相关 3 passed(0.19 秒)。全手默认验证仍拒绝缺失关节,
局部报告不得视为全手验收。未恢复删除的历史测试。
实采组合草稿通过 JSON 重建、最终 FK(7.36e-16)、标准加载与 MuJoCo 编译。
曲线模型角度检查通过不等于 URDF 线性驱动通过:纯 URDF yaw 最大误差约 3.52°,
精度未认证,不更新 latest_passed。详见
`calibration_output/thumb_recapture_20260923_175829/offline_combined_new_thumb_draft/README.md`。
@@ -1,7 +1,7 @@
schema_version: 3
profile_id: O30/right/o30_right_18/v1
profile_config: package://linkerhand_calibration/config/profiles/o30_right_18.yaml
profile_config_sha256: d782fcea36ba753af18ebde4a48c9f4b74d0a93fd27359a8fbfd70b9d23a49c7
profile_config_sha256: 0de1efee3ca95a52d04f289f4a118d2119b81e4a84e247f56f2176ea64130b39
model: O30
side: right
tag_layout: o30_right_18
@@ -26,8 +26,8 @@ cameras:
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf
source_urdf_sha256: 2be8428498ed8c17d7c39dee50e76ece6d362310cf26cd43834b8f4c79021b5b
camera_extrinsics: config/o30_three_camera_extrinsics_20260922_101452.yaml
camera_extrinsics_sha256: d28d3e8771f5bc20d4ca6c9bfd11977f26fefb8478d125a113c013fb7a208120
camera_extrinsics: config/o30_three_camera_extrinsics_20260923_181333.yaml
camera_extrinsics_sha256: e65a09b31bf10aed2fe0926585d446d209929fe7fc84f55f641b0b08a9ef41aa
calibration_config: package://linkerhand_calibration/config/o30_three_camera_calibration.yaml
calibration_config_sha256: aeb7826ac00e7ab4c32b131319b34bc11a849c668f268eec2bb4fa12fb081970
tag_config: package://linkerhand_calibration/config/o30_right_18_tags.yaml
@@ -70,7 +70,7 @@ command:
'17': 17
'18': 18
'19': 19
sdk_to_joint_direction: [1, 1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
sdk_to_joint_direction: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
vision:
views:
- name: front
@@ -94,22 +94,22 @@ vision:
id: 12
fixed_reference: false
size_m: 0.016
link: pinky_metacarpals
link: pinky_middle
- role: ring_mcp_roll
id: 13
fixed_reference: false
size_m: 0.016
link: ring_metacarpals
link: ring_middle
- role: middle_mcp_roll
id: 14
fixed_reference: false
size_m: 0.016
link: middle_metacarpals
link: middle_middle
- role: index_mcp_roll
id: 15
fixed_reference: false
size_m: 0.016
link: index_metacarpals
link: index_middle
- name: side
tags:
- role: side_base
@@ -168,7 +168,8 @@ vision:
id: 17
fixed_reference: false
size_m: 0.016
link: thumb_metacarpals_base2
# 顶机位标签实际贴在近端指节,随 CMC 和 MCP 运动,不随 IP 运动。
link: thumb_proximal
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
@@ -16,6 +16,8 @@ STAGED = "staged_2_to_3"
TASKWISE_STAGED = "taskwise_staged_2_to_3"
TASKWISE_TWO_ROUND = "taskwise_2_plus_1"
RAW_JOINT = "raw_joint_2_plus_1"
RAW_PROTOCOL = "raw_joint_images_v2"
LEGACY_RAW_PROTOCOL = "raw_joint_images_v1"
STAGED_POLICIES = frozenset((STAGED, TASKWISE_STAGED))
PREPARATION_POLICIES = STAGED_POLICIES | {TASKWISE_TWO_ROUND}
LEGACY_TRAINING = (0, 1, 2)
@@ -75,8 +77,8 @@ class CapturePlan:
def task(self, key):
return next(task for task in self.tasks if task.task_key == key)
def as_dict(self):
version = ("capture_plan_v6_raw_joint" if self.policy == RAW_JOINT else
def as_dict(self, *, legacy_raw=False):
version = (("capture_plan_v6_raw_joint" if legacy_raw else "capture_plan_v7_raw_joint") if self.policy == RAW_JOINT else
"capture_plan_v5" if self.policy == TASKWISE_TWO_ROUND else
"capture_plan_v3" if self.policy == TASKWISE_STAGED else
"capture_plan_v2" if self.policy == STAGED else PLAN_VERSION)
@@ -108,12 +110,15 @@ class CapturePlan:
if self.policy == RAW_JOINT:
result["schedule"] = "each_task_two_training_rounds_then_independent_validation"
result["observation_protocol"] = {
"version": "raw_joint_images_v1",
"version": LEGACY_RAW_PROTOCOL if legacy_raw else RAW_PROTOCOL,
"online_geometry_authorization": False,
"maximum_unit_recaptures": 1,
"recapture_order": "queue_tail",
"recapture_order": "queue_tail" if legacy_raw else "immediate",
"incomplete_capture_blocks_publication": True,
}
if not legacy_raw:
result["observation_protocol"].update(hardware_stall_retries=1,
stall_retry_delay_seconds=3.0, motion_progress_scope="direction_active_time")
return result
@property
@@ -168,8 +173,8 @@ def select_task_training(profile, task_key, cycles):
def configure_training(profile, policy):
if policy not in {FIXED, ADAPTIVE, *STAGED_POLICIES, TASKWISE_TWO_ROUND, RAW_JOINT}:
raise ValueError("unknown_training_policy")
if policy == RAW_JOINT and (profile.key.profile_id != 'O30/right/o30_right_18/v1' or profile.retained_joints):
raise ValueError("raw_joint_capture_requires_o30_full_hand_profile")
if policy == RAW_JOINT and profile.key.profile_id != 'O30/right/o30_right_18/v1':
raise ValueError("raw_joint_capture_requires_o30_profile")
if profile.artifacts.output_schema_version in {2, 3} and (
tuple(profile.quality.training_cycles) != LEGACY_TRAINING
or profile.quality.holdout_cycle != LEGACY_HOLDOUT):
@@ -17,6 +17,8 @@ class TaskStatus:
required_tag_ids_by_view: Mapping[str, tuple[int, ...]] = field(
default_factory=dict
)
segment_key: str = ""
segment_commands: Mapping[str, tuple[float, float]] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -0,0 +1,77 @@
"""Training-only support for the physically shared turnaround endpoints.
The last incoming command owns the turnaround image. An unobserved outgoing
endpoint is a continuity constraint, never a second independent measurement.
Interior parameters and endpoints with actual directional evidence stay free.
"""
import numpy as np
TURNAROUND_POLICY = 'observed_turnaround_continuity_v1'
def compile_turnaround_constraints(bundle):
constraints = {}
for name, columns in bundle.curve_columns.items():
channel = bundle.channels[name]
indices = sorted({int(index) for role, data in bundle.observations.items()
if any(joint.name == bundle.cad_names[name] for joint in bundle.chains[role])
for index in data[0]})
if not indices:
continue
commands = bundle.commands[indices, channel]
directions = bundle.directions[indices, channel]
for node in (0, len(bundle.knots[name])-1):
for recipient in (0, 1):
column = int(columns[recipient, node])
# Unknown startup history observes the common endpoint; it
# does not independently identify the outgoing branch.
at_endpoint = commands == bundle.knots[name][node]
if column < 0 or np.any(at_endpoint & (directions == recipient)):
continue
donor = 1-recipient
sources = [bundle.rows[index] for index in indices
if bundle.commands[index, channel] == bundle.knots[name][node]
and bundle.directions[index, channel] == donor]
if not sources:
continue
constraints.setdefault(name, []).append(dict(node=node, recipient=recipient,
donor=donor, column=column, command=float(bundle.knots[name][node]),
source_sample_ids=sorted({f"{row['view']}:{row['image_stamp_ns']}" for row in sources}),
source_cycles=sorted({row['cycle'] for row in sources})))
return constraints
def validate_turnaround_mapping(mapping, support):
"""Read-back must preserve the saved constraint, including its sources."""
if support is None: # Historical artifacts retain their old contract.
return
if support.get('policy') != TURNAROUND_POLICY or not isinstance(support.get('constraints'), list):
raise ValueError('invalid_turnaround_support:' + mapping.joint)
endpoints = support.get('endpoints', ())
if (len(endpoints) != 2 or [row.get('command') for row in endpoints]
!= [mapping.knots[0], mapping.knots[-1]]):
raise ValueError('invalid_turnaround_endpoints:' + mapping.joint)
for endpoint in endpoints:
if not endpoint.get('samples') or any(
sample.get('cycle') not in (0, 1) or not isinstance(sample.get('id'), str)
or not sample['id'] or sample.get('direction') not in ('', 'increasing', 'decreasing')
for sample in endpoint['samples']):
raise ValueError('invalid_turnaround_samples:' + mapping.joint)
branches = (mapping.increasing_rad, mapping.decreasing_rad)
seen = set()
for row in support['constraints']:
node, recipient, donor = row['node'], row['recipient'], row['donor']
if (node not in (0, len(mapping.knots)-1) or recipient not in (0, 1)
or donor != 1-recipient or (node, recipient) in seen
or row['command'] != mapping.knots[node]
or not row['source_sample_ids'] or not row['source_cycles']
or not set(row['source_cycles']) <= {0, 1}
or abs(branches[recipient][node]-branches[donor][node]) > 1e-10):
raise ValueError('turnaround_mapping_differs_from_evidence:' + mapping.joint)
seen.add((node, recipient))
endpoint = endpoints[0 if node == 0 else 1]
observed = {sample['id'] for sample in endpoint['samples']
if sample['direction'] == ('increasing', 'decreasing')[donor]}
if not observed.intersection(row['source_sample_ids']):
raise ValueError('turnaround_source_not_observed:' + mapping.joint)
@@ -0,0 +1,322 @@
"""Approximate command curves using the existing CAD image objective.
Drafts estimate supported internal zeros. Unobserved root frames and declared
CAD leaf zeros remain explicit premises, never arbitrary measured offsets.
"""
from collections import defaultdict
from dataclasses import replace
from itertools import product
import numpy as np
from scipy.spatial.transform import Rotation
from ..domain.capture_plan import task_for_joint
from ..geometry.tag_pose.motion_evidence import _axis
from ..geometry.tag_pose.urdf_command_images import UrdfCommandImages, pose_vector
from .motion_fit import joint_input_direction
UPRIGHT_SPLAY_JOINTS = frozenset(finger+'_mcp_roll' for finger in ('index', 'middle', 'ring', 'pinky'))
UPRIGHT_SPLAY_REFERENCE = 'operator_defined_upright_splay_baseline'
def define_upright_splay_zero(profile, model):
"""Use the operator's upright baseline as a definition, not a measurement."""
if (profile.key.model != 'O30' or profile.key.side != 'right'
or not UPRIGHT_SPLAY_JOINTS <= profile.zero.active_joints):
raise ValueError('draft_upright_splay_requires_o30_right_four_fingers')
for name in UPRIGHT_SPLAY_JOINTS:
finger = name.split('_')[0]
positions = [model.link_transform(profile.command.urdf_joint_by_joint.get(joint, joint),
zero_offsets={}, joint_angles={}, independent_mimic_angles=True)[:3, 3]
for joint in (finger+'_mcp_pitch', finger+'_pip')]
direction = positions[1]-positions[0]
if not np.allclose(direction/np.linalg.norm(direction), [0., 0., 1.], atol=1e-6):
raise ValueError('draft_source_zero_is_not_upright:' + name)
assumptions = dict(profile.zero.cad_zero_assumptions)
assumptions.update({name: UPRIGHT_SPLAY_REFERENCE for name in UPRIGHT_SPLAY_JOINTS})
return replace(profile, zero=replace(profile.zero, cad_zero_assumptions=assumptions))
def draft_training_coverage(completed_units, units):
"""Audit accepted directions without borrowing from independent validation."""
completed = set(completed_units)
coverage = {}
for task in sorted({unit.task_key for unit in units}):
cycles = {}
for cycle in (0, 1):
expected = {unit.identity for unit in units if unit.task_key == task and unit.cycle == cycle}
accepted = expected & completed
cycles[cycle] = dict(expected_units=len(expected), accepted_units=len(accepted),
complete=bool(expected) and accepted == expected)
complete = [cycle for cycle, row in cycles.items() if row['complete']]
coverage[task] = dict(cycles=cycles, complete_cycles=complete,
initialization_cycle=complete[0] if complete else None)
return coverage
def draft_targets(profile, selected=None):
"""Report the requested whole-hand result independently of partial output."""
target_ranges = set(profile.zero.active_joints)
retained_leaves = set(profile.zero.cad_zero_assumptions)
target_zeros = target_ranges-retained_leaves
measured_ranges = set() if selected is None else set(selected.zero.active_joints)
measured_zeros = set() if selected is None else measured_ranges-set(selected.zero.cad_zero_assumptions)
defined_zeros = set() if selected is None else {name for name, reason in
selected.zero.cad_zero_assumptions.items() if reason == UPRIGHT_SPLAY_REFERENCE}
return dict(zero_target_joints=sorted(target_zeros), range_target_joints=sorted(target_ranges),
retained_leaf_zero_joints=sorted(retained_leaves), estimated_zero_joints=sorted(measured_zeros),
defined_zero_joints=sorted(defined_zeros),
estimated_range_joints=sorted(measured_ranges),
missing_zero_joints=sorted(target_zeros-measured_zeros-defined_zeros),
missing_range_joints=sorted(target_ranges-measured_ranges),
full_target_covered=(target_zeros <= measured_zeros | defined_zeros and target_ranges <= measured_ranges))
def draft_profile(profile, model, completed_units, units, training):
"""One complete training cycle supports a draft, with observed ancestors."""
coverage = draft_training_coverage(completed_units, units)
tasks = {task for task, row in coverage.items() if row['complete_cycles']}
names = {name for task in profile.motion.tasks if task.key in tasks for name in task.joints}
cad_names = {name: profile.command.urdf_joint_by_joint.get(name, name) for name in names}
observed = set(cad_names.values())
names = frozenset(name for name in names if all(j.kind == 'fixed' or j.name in observed
for j in model._chain(cad_names[name])))
if not names:
raise ValueError('draft_no_complete_ancestor_supported_training')
from ...profiles.observations import compile_tag_links
links = compile_tag_links(profile, model)
observed = {cad_names[name] for name in names}
visible = {role for row in training for role in row.get('tags', {})}
roles = {role for role, link in links.items() if role in visible and (link == model.root_link or all(
j.kind == 'fixed' or j.name in observed
for j in model._chain(model.parent_joint_by_child[link])))}
# Fixed tags belong to the root, which has no parent joint.
views = tuple(replace(view, tags=tuple(tag for tag in view.tags
if tag.fixed_reference or tag.role in roles)) for view in profile.vision.views)
assumptions = {name: reason for name, reason in profile.zero.cad_zero_assumptions.items() if name in names}
for name, observer in profile.zero.spatial.get('offset_observer_joint', {}).items():
if name in names and observer not in names:
assumptions[name] = 'partial_missing_zero_observer:' + observer
roots = [name for name in names if not any(j.kind != 'fixed'
for j in model._chain(cad_names[name])[:-1])]
# With only one root chain, rotating palm and root zero together leaves
# every image identical. Fix that gauge, while fitting the internal zeros.
if len(roots) == 1:
assumptions[roots[0]] = 'partial_single_root_zero_unobservable_without_palm_reference'
selected = replace(profile,
zero=replace(profile.zero, active_joints=names,
cad_zero_assumptions=assumptions),
motion=replace(profile.motion, tasks=tuple(task for task in profile.motion.tasks
if task.key in tasks and set(task.joints) <= names)),
vision=replace(profile.vision, views=views))
required = {profile.measurement.measurements[name].child_role for name in names}
if not required <= roles:
raise ValueError('draft_required_training_tags_missing:' + str(sorted(required-roles)))
return selected
class DraftCommandImages(UrdfCommandImages):
"""Shared image objective, with exact repeated-pose least-squares reduction."""
# Numerical stopping for an explicitly uncertified draft; release angular,
# image, observability and final-file acceptance limits remain independent.
solver_ftol = 1e-5
def __init__(self, profile, model, extrinsics, rows, tag_links, *, initialization_cycles=None,
training_initialization=None):
super().__init__(profile, model, extrinsics, rows, tag_links)
self.training_initialization = training_initialization
self.initialization_cycles = (dict(initialization_cycles) if initialization_cycles is not None
else {task.key: 0 for task in profile.motion.tasks})
if any(self.initialization_cycles.get(task.key) not in (0, 1) for task in profile.motion.tasks):
raise ValueError('draft_initialization_training_cycle_required')
self.original_observations = self.observations
self.observations, weights, within_group_error = {}, [], 0.
local_names = {cad: name for name, cad in self.cad_names.items()}
for role, (indices, pixels, matrices, objects) in self.original_observations.items():
channels = [self.channels[local_names[joint.name]] for joint in self.chains[role]
if joint.kind != 'fixed']
groups = defaultdict(list)
for position, index in enumerate(indices):
key = (tuple(self.commands[index, channels]), tuple(self.directions[index, channels]),
matrices[position].tobytes())
groups[key].append(position)
representatives, means = [], []
for positions in groups.values():
mean = pixels[positions].mean(axis=0)
within_group_error += float(np.sum((pixels[positions]-mean)**2))
representatives.append(positions[0])
means.append(mean)
weights.extend([np.sqrt(len(positions))]*8)
self.observations[role] = (indices[representatives], np.asarray(means),
matrices[representatives], objects)
self.residual_weights = np.asarray(weights)
self.within_group_error = within_group_error
self.original_residual_count = sum(len(data[0])*8 for data in self.original_observations.values())
def residual(self, parameters):
# sum ||prediction - pixel_i||² = n ||prediction - mean||² + scatter.
# Keep scatter in the objective, including the solver stopping test.
return np.r_[super().residual(parameters)*self.residual_weights,
np.sqrt(self.within_group_error), np.zeros(7)]
def jacobian(self, parameters):
weighted = super().jacobian(parameters)*self.residual_weights[:, None]
return np.vstack((weighted, np.zeros((8, len(parameters)))))
def original_residual(self, parameters):
predicted = self.project_observations(parameters, self.commands, self.directions, self.original_observations)
return np.concatenate([(predicted[role]-data[1]).ravel()
for role, data in self.original_observations.items()])
def audit_root_directions(self):
"""Reject opposite SDK signs for CAD-parallel roots, before fitting zeros.
Only complete training cycles enter this check. Both planar candidates
and both scan directions are retained; an ambiguous axis is not a
direction correction. Nothing here changes commands or chooses a pose.
"""
roots = [name for name in self.profile.zero.spatial.get('root_anchor_joints', ())
if name in self.joints]
axes = {}
for name in roots:
spec = self.profile.measurement.measurements[name]
task = task_for_joint(self.profile, name)
channel = self.channels[name]
observed = []
for direction in range(2):
indices = self.motion_indices(name, direction, self.initialization_cycles[task.key])
if len(indices) < 3:
continue
for branch in (0, 1):
_, poses = self.motion_poses(indices, spec.child_role, branch)
axis, phase = _axis(Rotation.from_matrix(np.asarray(poses)[:, :3, :3]))
if abs(phase[-1]-phase[0]) < np.radians(15):
continue
observed.append(axis*np.sign(phase[-1]-phase[0])
*joint_input_direction(self.profile, self.model, name))
axes[name] = observed
comparisons = []
for i, first in enumerate(roots):
a, _ = self.model.axis_line(self.cad_names[first], zero_offsets={}, joint_angles={})
for second in roots[i+1:]:
b, _ = self.model.axis_line(self.cad_names[second], zero_offsets={}, joint_angles={})
if abs(a @ b) < .999 or not axes[first] or not axes[second]:
continue
dots = np.asarray(axes[first]) @ np.asarray(axes[second]).T * np.sign(a @ b)
comparisons.append(dict(joints=[first, second], minimum_cosine=float(dots.min()),
maximum_cosine=float(dots.max()), reversed_for_all_candidates=bool(dots.max() < -.5)))
return dict(training_only=True, validation_used=False, comparisons=comparisons,
passed=not any(row['reversed_for_all_candidates'] for row in comparisons))
def initialize(self, branches):
roles = sorted(self.tags)
if len(branches) != min(6, len(roles)) or any(b not in (0, 1) for b in branches):
raise ValueError('draft_initialization_seed_invalid')
branch_by_role = {role: branches[min(i, len(branches)-1)] for i, role in enumerate(roles)}
initial = np.zeros(len(self.scale))
lines = {}
for name in self.joints:
task = task_for_joint(self.profile, name)
spec = self.profile.measurement.measurements[name]
channel = self.channels[name]
sign = joint_input_direction(self.profile, self.model, name)
for direction in range(2):
indices = self.motion_indices(name, direction, self.initialization_cycles[task.key])
if len(indices) < 3:
raise ValueError('draft_directional_training_missing:' + name)
indices, poses = self.motion_poses(indices, spec.child_role, branch_by_role[spec.child_role])
axis, phase = _axis(Rotation.from_matrix(poses[:, :3, :3]))
if (phase[-1]-phase[0])*sign < 0:
axis, phase = -axis, -phase
commands = self.commands[indices, channel]
phase -= np.interp(self.profile.command.baseline_values[channel], commands, phase)
self.set_initial_curve(initial, name, direction, np.interp(self.knots[name], commands, phase))
if direction == 0:
turns = poses[:, :3, :3] @ poses[0, :3, :3].T
blocks = np.eye(3)-turns
target = poses[:, :3, 3]-np.einsum('nij,j->ni', turns, poses[0, :3, 3])
lines[name] = (axis, np.linalg.lstsq(blocks.reshape(-1, 3), target.ravel(), rcond=1e-6)[0], indices[0])
angles = self.joint_angles(initial, self.commands, self.directions)
cad_axes, axes, cad_points, points = [], [], [], []
for name, (axis, point, index) in lines.items():
cad_axis, cad_point = self.model.axis_line(self.cad_names[name], zero_offsets={},
joint_angles={joint: values[index] for joint, values in angles.items()}, independent_mimic_angles=True)
cad_axes.append(cad_axis); axes.append(axis)
cad_points.append(cad_point); points.append(point)
if np.linalg.matrix_rank(np.asarray(cad_axes), tol=.01) < 2:
raise ValueError('draft_initialization_requires_nonparallel_observed_axes')
rotation, _ = Rotation.align_vectors(np.asarray(axes), np.asarray(cad_axes))
blocks = np.asarray([np.eye(3)-np.outer(axis, axis) for axis in axes])
targets = np.einsum('nij,nj->ni', blocks, np.asarray(points)-rotation.apply(cad_points))
initial[:3] = rotation.as_rotvec()
initial[3:6] = np.linalg.lstsq(blocks.reshape(-1, 3), targets.ravel(), rcond=1e-6)[0]
if self.training_initialization is not None:
hint = self.training_initialization
initial[:6] = hint['palm']
for name, mapping in hint['mappings'].items():
if name not in self.joints:
continue
if name in self.zero_columns:
initial[self.zero_columns[name]] = hint['offsets'][name]
for direction, values in enumerate((mapping.increasing_rad, mapping.decreasing_rad)):
self.set_initial_curve(initial, name, direction,
np.interp(self.knots[name], mapping.knots, values))
transforms = self.link_poses(initial, self.commands, self.directions)
for role, column in self.mount_columns.items():
index = self.observations[role][0][0]
initial[column:column+6] = pose_vector(np.linalg.inv(transforms[self.tag_links[role]][index])
@ self._pose(index, role, branch_by_role[role]))
self._initialization_costs[tuple(branches)] = self._refine_mount_guesses(initial, transforms,
observations=self.original_observations)
return initial
def fit_draft(bundle, candidate_completed=lambda _row: None):
"""Training-only representative; image precision is reported, not certified."""
candidates, audit = [], []
seed_width = min(6, len(bundle.tags))
for seed in product((0, 1), repeat=seed_width):
row = dict(seed=list(seed), converged=False)
try:
fit = bundle.fit(seed)
if not fit.success or not np.all(np.isfinite(fit.x)) or not np.all(np.isfinite(fit.fun)):
raise ValueError('draft_optimizer_did_not_converge')
bundle.output_mappings(fit.x)
residual = bundle.original_residual(fit.x) if hasattr(bundle, 'original_residual') else fit.fun
errors = np.sqrt(np.mean(np.sum(residual.reshape(-1, 4, 2)**2, axis=-1), axis=-1))
row.update(converged=True, coordinate_rms_px=float(np.sqrt(np.mean(residual**2))),
tag_rms_p95_px=float(np.percentile(errors, 95)), maximum_tag_rms_px=float(errors.max()),
original_image_precision_passed=bool(errors.max() <= 1.5),
evaluations=int(fit.nfev+fit.initialization_evaluations))
candidates.append((row, fit.x.copy()))
except (ValueError, np.linalg.LinAlgError) as error:
row['reason'] = str(error)
audit.append(row)
candidate_completed(row)
# A reviewable draft needs a bounded representative search, not a
# release claim that all geometric alternatives have been excluded.
if len(candidates) == 4:
break
if not candidates:
raise ValueError('draft_no_converged_training_candidate')
selected, parameters = min(candidates, key=lambda pair: pair[0]['coordinate_rms_px'])
jacobian = bundle.jacobian(parameters)*bundle.scale[None, :]
_u, singular, vectors = np.linalg.svd(jacobian, full_matrices=jacobian.shape[0] < jacobian.shape[1])
rows = getattr(bundle, 'original_residual_count', jacobian.shape[0])
rank = int(np.count_nonzero(singular > singular[0]*max(rows, jacobian.shape[1])*np.finfo(float).eps))
nullspace = vectors[rank:]
unresolved = [name for name, column in bundle.zero_columns.items()
if len(nullspace) and np.linalg.norm(nullspace[:, column]) > 1e-6]
if unresolved:
raise ValueError('draft_estimated_zero_unobservable:' + ','.join(unresolved))
zeros = {name: dict(estimated_deg=float(np.degrees(bundle.zero_offsets(parameters)[name])),
candidate_spread_deg=float(np.degrees(np.ptp([bundle.zero_offsets(value)[name]
for _, value in candidates])))) for name in bundle.zero_columns}
return parameters, dict(selected_seed=selected['seed'], selection='minimum_training_coordinate_rms',
validation_used_for_selection=False, precision_certified=False, candidates=audit,
solver_stopping_ftol=getattr(bundle, 'solver_ftol', 1e-9),
candidate_search_policy='uncertified_draft_up_to_four_converged_v1',
initializations_evaluated=len(audit), available_initializations=2**seed_width,
candidate_search_exhaustive=len(audit) == 2**seed_width,
estimated_zeros=zeros, local_parameter_rank=rank, parameter_count=len(bundle.scale)), candidates
@@ -11,12 +11,11 @@ def angle_derivatives(bundle, commands, directions):
jac = np.zeros((len(commands), width))
if name in bundle.zero_columns:
jac[:, bundle.zero_columns[name]] = 1.
basis = bundle.curve_basis(name, commands[:,channel])
weights = bundle.curve_weights(name, commands[:,channel], directions[:,channel])
for direction in range(2):
weight = np.where(directions[:,channel] == -1, .5, directions[:,channel] == direction)
for node, column in enumerate(bundle.curve_columns[name][direction]):
if column >= 0:
jac[:,column] += weight*basis[:,node]
jac[:,column] += weights[:,direction,node]
derivatives[bundle.cad_names[name]] = jac
return derivatives
@@ -62,11 +61,12 @@ def physical_intervals(bundle, candidate):
for i, name in enumerate(bundle.joints):
if name in bundle.zero_columns:
zeros[i,bundle.zero_columns[name]] = 1.
basis = bundle.curve_basis(name, bundle.knots[name])
for columns in bundle.curve_columns[name]:
for direction in (0, 1):
basis = bundle.curve_weights(name, bundle.knots[name],
np.full(len(bundle.knots[name]), direction))
for weights in basis:
row = zeros[i].copy()
for column, weight in zip(columns, weights):
for column, weight in zip(bundle.curve_columns[name].ravel(), weights.ravel()):
if column >= 0:
row[column] += weight
commands.append(row)
@@ -0,0 +1,88 @@
"""Constrained batch updates of the existing image objective, without motion.
The caller supplies the new batch's camera geometry and immutable reference
parameters. Unselected joints and tag installations cannot move in the fit.
"""
import numpy as np
from scipy.optimize import least_squares
def check_update_zero_observability(bundle, parameters, active, joints):
"""Reject zero gauges, conditional on the explicitly frozen reference."""
jacobian = bundle.jacobian(parameters)[:, active]*bundle.scale[active]
_, singular, vectors = np.linalg.svd(jacobian,
full_matrices=jacobian.shape[0] < jacobian.shape[1])
rows = getattr(bundle, 'original_residual_count', jacobian.shape[0])
rank = int(np.count_nonzero(singular > singular[0]*max(rows, len(active))*np.finfo(float).eps))
nullspace = vectors[rank:]
positions = {column: i for i, column in enumerate(active)}
unresolved = [name for name in joints if name in bundle.zero_columns
and (bundle.zero_columns[name] not in positions or (len(nullspace)
and np.linalg.norm(nullspace[:, positions[bundle.zero_columns[name]]]) > 1e-6))]
if unresolved:
raise ValueError('batch_update_zero_unobservable:' + ','.join(unresolved))
return rank
def transfer_parameters(source, parameters, target):
"""Copy semantic parameters; array positions may differ between scopes."""
result = np.zeros(len(target.scale))
result[:6] = parameters[:6]
for name, columns in target.curve_columns.items():
if not np.array_equal(source.knots[name], target.knots[name]):
raise ValueError('batch_update_training_knots_changed:' + name)
mask = columns >= 0
original = source.curve_columns[name]
if not np.array_equal(mask, original >= 0):
raise ValueError('batch_update_baseline_changed:' + name)
result[columns[mask]] = parameters[original[mask]]
for name, column in target.zero_columns.items():
result[column] = source.zero_offsets(parameters)[name]
for role, column in target.mount_columns.items():
if source.tag_links[role] != target.tag_links[role]:
raise ValueError('batch_update_tag_link_changed:' + role)
original = source.mount_columns[role]
result[column:column+6] = parameters[original:original+6]
return result
def fit_joint_update(bundle, initial, joints, mount_roles, *, fit_palm=True):
"""Use the shared bounded solver while keeping the reference hand fixed."""
if not set(joints) <= set(bundle.joints) or not set(mount_roles) <= set(bundle.tags):
raise ValueError('batch_update_scope_invalid')
columns = list(range(6)) if fit_palm else []
for name in joints:
if name in bundle.zero_columns:
columns.append(bundle.zero_columns[name])
columns.extend(bundle.curve_columns[name][bundle.curve_columns[name] >= 0])
for role in mount_roles:
start = bundle.mount_columns[role]
columns.extend(range(start, start+6))
proposed = np.asarray(sorted(set(columns)), dtype=int)
# Unobserved increments cannot acquire invented travel through optimizer
# drift. Retain their input values and report that they were not fitted.
supported = np.linalg.norm(bundle.jacobian(initial)[:, proposed], axis=0) > 1e-8
active = proposed[supported]
if not len(active):
raise ValueError('batch_update_no_observed_parameters')
def unpack(values):
parameters = initial.copy()
parameters[active] = values
return parameters
options = dict(bundle.solver_options)
lower, upper = options.pop('bounds')
fit = least_squares(lambda x: bundle.residual(unpack(x)), initial[active],
jac=lambda x: bundle.solver_jacobian(unpack(x))[:, active],
bounds=(lower[active], upper[active]), x_scale=bundle.scale[active],
max_nfev=100, ftol=bundle.solver_ftol, **options)
parameters = unpack(fit.x)
rank = check_update_zero_observability(bundle, parameters, active, joints)
derived = {row['column'] for rows in getattr(bundle, 'turnaround_constraints', {}).values() for row in rows}
audit = dict(converged=bool(fit.success), evaluations=int(fit.nfev),
coordinate_rms_px=float(np.sqrt(np.mean(bundle.original_residual(parameters)**2))),
active_columns=active.tolist(),
derived_turnaround_columns=sorted(derived.intersection(proposed.tolist())),
unobserved_retained_columns=[int(column) for column in proposed[~supported] if column not in derived],
validation_used=False, fixed_cad_axes=True, conditional_parameter_rank=rank,
observability_condition='unselected_joints_and_tag_mounts_fixed')
return parameters, audit
@@ -98,7 +98,11 @@ def _measure_group(bundle, candidate, rows, names, roles, cancelled):
return fit.x.reshape(count,width), forward, measured, confidence, predicted_poses, measured_poses
def validate_raw_joint_candidate(bundle, candidate, rows, *, cancelled=lambda: False):
def validate_raw_joint_candidate(bundle, candidate, rows, *, cancelled=lambda: False,
report_joints=None):
names_to_report = tuple(bundle.joints if report_joints is None else report_joints)
if not names_to_report or not set(names_to_report) <= set(bundle.joints):
raise ValueError('raw_joint_holdout_report_scope_invalid')
angles, positions, rotations = defaultdict(list), defaultdict(list), defaultdict(list)
primary_counts, pixels, forward_pixels, uncertainty = defaultdict(int), [], [], []
for (view,names,roles), group in _groups(bundle,rows).items():
@@ -118,7 +122,7 @@ def validate_raw_joint_candidate(bundle, candidate, rows, *, cancelled=lambda: F
positions[name].extend(np.linalg.norm(a[:,:3,3]-b[:,:3,3],axis=1).tolist())
rotations[name].extend(Rotation.from_matrix(a[:,:3,:3].transpose(0,2,1)@b[:,:3,:3]).magnitude().tolist())
reports = {}
for name in bundle.joints:
for name in names_to_report:
if primary_counts[name] < bundle.profile.acquisition.minimum_valid_samples:
raise ValueError('raw_joint_holdout_measurements_missing:'+name)
angular,orientation = angular_metrics(angles[name]),angular_metrics(rotations[name])
@@ -129,7 +133,7 @@ def validate_raw_joint_candidate(bundle, candidate, rows, *, cancelled=lambda: F
passed = (bool(reports) and all(r['passed'] for r in reports.values())
and max(uncertainty,default=np.inf) <= np.radians(1)
and max(pixels,default=np.inf) <= DEFAULT_POSE_TRACKING_PARAMETERS.maximum_reprojection_error_px)
return dict(passed=passed,joints=reports,
return dict(passed=bool(passed),joints=reports,
maximum_measurement_confidence_rad=max(uncertainty,default=None),
maximum_measurement_reprojection_px=max(pixels,default=None),
frozen_forward_reprojection_p95_px=float(np.percentile(forward_pixels,95)) if forward_pixels else None,
@@ -182,11 +182,27 @@ class FingerChainImages:
def fit(self, branches):
initial = self.initialize(branches)
columns = getattr(self, 'optimization_columns', np.arange(len(initial)))
def unpack(values):
parameters = initial.copy()
parameters[columns] = values
return parameters
jacobian = getattr(self, 'solver_jacobian', getattr(self, 'jacobian', '2-point'))
options = dict(getattr(self, 'solver_options', {}))
if 'bounds' in options:
options['bounds'] = tuple(np.broadcast_to(bound, initial.shape)[columns]
for bound in options['bounds'])
# Small shared graph: exact solver avoids LSMR conditioning ambiguity.
result = least_squares(self.residual, initial, x_scale=self.scale,
jac=getattr(self, 'solver_jacobian', getattr(self, 'jacobian', '2-point')),
**getattr(self, 'solver_options', {}),
max_nfev=getattr(self, 'maximum_joint_evaluations', 200), ftol=1e-9, xtol=1e-9, gtol=1e-7)
result = least_squares(lambda values: self.residual(unpack(values)), initial[columns],
x_scale=self.scale[columns],
jac=(lambda values: jacobian(unpack(values))[:, columns]) if callable(jacobian) else jacobian,
**options,
max_nfev=getattr(self, 'maximum_joint_evaluations', 200),
ftol=getattr(self, 'solver_ftol', 1e-9), xtol=1e-9, gtol=1e-7)
result.x = unpack(result.x)
if len(columns) != len(initial):
# Consumers use the stable semantic layout, not optimizer indices.
result.jac = jacobian(result.x)
result.initialization_evaluations = getattr(self, '_initialization_costs', {}).get(tuple(branches), 0)
return result
@@ -217,6 +217,41 @@ def motion_frame_rejection(frame: MotionEvidenceFrame, relations: Sequence[Motio
return ""
def connect_candidate_families(items, *, strict=True):
"""Associate candidate identities, never their per-image reprojection ranks.
Missing alternatives remain missing. Non-strict association is only a
numerical initializer; it must never authorize a branch or certify motion.
"""
# Anchor where both eligible roots are available. If an alternative
# temporarily fails the image-quality gate it stays missing; never copy
# the surviving root into both families to invent evidence.
anchor = max(range(len(items)), key=lambda i: len(items[i].candidates))
count = len(items[anchor].candidates)
families = [[None] * len(items) for _ in range(count)]
for index, candidate in enumerate(items[anchor].candidates):
families[index][anchor] = candidate
for sequence in (range(anchor + 1, len(items)), range(anchor - 1, -1, -1)):
previous = list(items[anchor].candidates)
for frame_index in sequence:
current = items[frame_index].candidates
def cost(order):
return sum(_distance(previous[family], candidate)[0]
for family, candidate in zip(order, current))
orders = sorted(permutations(range(count), len(current)), key=cost)
if (strict and len(orders) == 2 and abs(cost(orders[0]) - cost(orders[1])) < 1e-9
and not _equivalent(previous[0], previous[1])):
return None, "motion_candidate_assignment_ambiguous"
for family, candidate in zip(orders[0], current):
angle, translation = _distance(previous[family], candidate)
p = DEFAULT_POSE_TRACKING_PARAMETERS
if strict and (angle > p.maximum_pose_jump_rad or translation > p.maximum_translation_jump_m):
return None, "motion_candidate_family_discontinuous"
families[family][frame_index] = candidate
previous[family] = candidate
return families, ""
def _connect_paths(frames: Sequence[MotionEvidenceFrame], roles: Sequence[str]):
"""Preserve both continuous families without treating continuity as truth."""
candidates = [_candidate_map(frame.roles, roles) for frame in frames]
@@ -227,32 +262,9 @@ def _connect_paths(frames: Sequence[MotionEvidenceFrame], roles: Sequence[str]):
items = [frame[role] for frame in candidates]
if any(item.frozen != items[0].frozen for item in items):
return None, "motion_candidate_family_interrupted"
# Anchor where both eligible roots are available. If an alternative
# temporarily fails the image-quality gate it stays missing; never copy
# the surviving root into both families to invent evidence.
anchor = max(range(len(items)), key=lambda i: len(items[i].candidates))
count = len(items[anchor].candidates)
families = [[None] * len(frames) for _ in range(count)]
for index, candidate in enumerate(items[anchor].candidates):
families[index][anchor] = candidate
for sequence in (range(anchor + 1, len(frames)), range(anchor - 1, -1, -1)):
previous = list(items[anchor].candidates)
for frame_index in sequence:
current = items[frame_index].candidates
def cost(order):
return sum(_distance(previous[family], candidate)[0]
for family, candidate in zip(order, current))
orders = sorted(permutations(range(count), len(current)), key=cost)
if (len(orders) == 2 and abs(cost(orders[0]) - cost(orders[1])) < 1e-9
and not _equivalent(previous[0], previous[1])):
return None, "motion_candidate_assignment_ambiguous"
for family, candidate in zip(orders[0], current):
angle, translation = _distance(previous[family], candidate)
p = DEFAULT_POSE_TRACKING_PARAMETERS
if angle > p.maximum_pose_jump_rad or translation > p.maximum_translation_jump_m:
return None, "motion_candidate_family_discontinuous"
families[family][frame_index] = candidate
previous[family] = candidate
families, reason = connect_candidate_families(items)
if reason:
return None, reason
paths[role] = families
# Near-coincident IPPE roots represent one physical hypothesis, not two.
for role, families in paths.items():
@@ -10,8 +10,9 @@ from scipy.spatial.transform import Rotation
from .finger_chain_images import FingerChainImages
from .ippe import solve_square_tag_ippe, square_object_points
from .motion_evidence import _axis
from .motion_evidence import _axis, RoleCandidates, connect_candidate_families
from ...domain.capture_plan import task_for_joint
from ...domain.motion_path import task_segment
from ...domain.sampling import command_nodes
from ...domain.result import JointMapping
from ...fitting.motion_fit import channel_for_joint, joint_input_direction
@@ -119,6 +120,10 @@ class UrdfCommandImages(FingerChainImages):
self.maximum_joint_evaluations = 200-5*len(self.tags)
self.chains = {role: model._chain(model.parent_joint_by_child[link])
for role, link in self.tag_links.items()}
from ...fitting.command_support import compile_turnaround_constraints
self.turnaround_constraints = compile_turnaround_constraints(self)
derived = {row['column'] for rows in self.turnaround_constraints.values() for row in rows}
self.optimization_columns = np.asarray([i for i in range(len(scale)) if i not in derived])
def _directions(self, rows):
result = []
@@ -137,8 +142,24 @@ class UrdfCommandImages(FingerChainImages):
mask = columns >= 0
values[mask] = parameters[columns[mask]]
result[name] = values @ self.curve_basis(name, self.knots[name]).T
for row in self.turnaround_constraints.get(name, ()):
result[name][row['recipient'], row['node']] = result[name][row['donor'], row['node']]
return result
def curve_weights(self, name, commands, directions):
"""Derivative of the constrained curves in the original parameter layout."""
basis = self.curve_basis(name, commands)
weights = np.stack([basis*np.where(directions == -1, .5,
directions == direction)[:, None] for direction in (0, 1)], axis=1)
for row in self.turnaround_constraints.get(name, ()):
hat = np.zeros(len(self.knots[name])); hat[row['node']] = 1.
weight = np.interp(commands, self.knots[name], hat)*np.where(
directions == -1, .5, directions == row['recipient'])
endpoint = self.curve_basis(name, [row['command']])[0]
weights[:, row['recipient']] -= weight[:, None]*endpoint
weights[:, row['donor']] += weight[:, None]*endpoint
return weights
def curve_basis(self, name, commands):
"""Nonnegative increments enforce the declared native SDK direction."""
knots = self.knots[name]
@@ -260,16 +281,15 @@ class UrdfCommandImages(FingerChainImages):
if name in self.zero_columns:
jac[output_rows, self.zero_columns[name]] = pixel_velocity
channel = self.channels[name]
basis = self.curve_basis(name, self.commands[indices, channel])
weights = self.curve_weights(name, self.commands[indices, channel],
self.directions[indices, channel])
for direction in range(2):
weight = np.where(self.directions[indices, channel] == -1, .5,
(self.directions[indices, channel] == direction).astype(float))
for node, column in enumerate(self.curve_columns[name][direction]):
if column >= 0:
jac[output_rows, column] += pixel_velocity*(weight*basis[:,node])[:,None]
jac[output_rows, column] += pixel_velocity*weights[:,direction,node,None]
return jac
def _pose(self, index, role, branch):
def _pose_candidates(self, index, role):
key = index, role
if key not in self._pnps:
row, tag = self.rows[index], self.tags[role][1]
@@ -278,12 +298,53 @@ class UrdfCommandImages(FingerChainImages):
candidates = self._pnps[key]
if not candidates:
raise ValueError('urdf_images_initialization_candidates_missing:' + role)
pose = candidates[min(branch, len(candidates)-1)]
return candidates
def _pose_matrix(self, role, pose):
camera = np.eye(4)
camera[:3, :3] = Rotation.from_quat(pose.quaternion_xyzw).as_matrix()
camera[:3, 3] = pose.translation_xyz_m
return self.extrinsics.transform(self.tags[role][0]) @ camera
def _pose(self, index, role, branch):
candidates = self._pose_candidates(index, role)
return self._pose_matrix(role, candidates[min(branch, len(candidates)-1)])
def motion_indices(self, name, direction, cycle):
"""Scan membership includes its start, regardless of incoming history.
Actual per-channel history remains the forward model's hysteresis
input. Constant channels in another joint's support segment are omitted.
"""
task = task_for_joint(self.profile, name)
spec = self.profile.measurement.measurements[name]
channel = self.channels[name]
indices = []
for i, row in enumerate(self.rows):
if (row['task_name'] != task.key or row['view'] != spec.view
or row['cycle'] != cycle or spec.child_role not in row.get('tags', {})):
continue
if task.segments:
segment = task_segment(task, row.get('segment_key', ''))
if name not in segment.joints or channel not in segment.moving_channels:
continue
increasing = dict(segment.end_commands)[channel] > dict(segment.start_commands)[channel]
else:
increasing = row['direction'] == 'increasing'
if int(not increasing) == direction:
indices.append(i)
return sorted(indices, key=lambda i: self.commands[i, channel])
def motion_poses(self, indices, role, branch):
items = [RoleCandidates(role, self._pose_candidates(i, role)) for i in indices]
families, _ = connect_candidate_families(items, strict=False)
# A single available family is one seed, not evidence of a second one.
family = families[min(branch, len(families)-1)]
observed = [(i, pose) for i, pose in zip(indices, family) if pose is not None]
if len(observed) < 3:
raise ValueError('urdf_images_motion_family_incomplete:' + role)
return [i for i, _ in observed], np.asarray([self._pose_matrix(role, p) for _, p in observed])
def initialize(self, branches):
if len(branches) != 6 or any(b not in (0, 1) for b in branches):
raise ValueError('urdf_images_seed_invalid')
@@ -304,16 +365,13 @@ class UrdfCommandImages(FingerChainImages):
spec = self.profile.measurement.measurements[name]
channel = self.channels[name]
for direction in range(2):
indices = [i for i, row in enumerate(self.rows) if row['task_name'] == task.key
and row['view'] == spec.view and row['cycle'] == 0
and spec.child_role in row.get('tags', {}) and self.directions[i, channel] == direction]
indices.sort(key=lambda i: self.commands[i, channel])
indices = self.motion_indices(name, direction, 0)
if len(indices) < 3:
raise ValueError('urdf_images_directional_training_missing:' + name)
# A common motion-curve guess is only a numerical initializer.
# Each candidate retains its own installation seeds below;
# no PnP branch or zero is frozen by this starting guess.
poses = np.asarray([self._pose(i, spec.child_role, 0) for i in indices])
indices, poses = self.motion_poses(indices, spec.child_role, 0)
rotations = Rotation.from_matrix(poses[:, :3, :3])
axis, phase = _axis(rotations)
sign = joint_input_direction(self.profile, self.model, name)
@@ -338,13 +396,10 @@ class UrdfCommandImages(FingerChainImages):
task = task_for_joint(self.profile, root)
spec = self.profile.measurement.measurements[root]
role = spec.child_role
indices = [i for i, row in enumerate(self.rows) if row['task_name'] == task.key
and row['view'] == spec.view and row['cycle'] == 0 and role in row.get('tags', {})
and row['direction'] == 'increasing']
indices.sort(key=lambda i: self.commands[i, self.channels[root]])
indices = self.motion_indices(root, 0, 0)
if len(indices) < 3:
raise ValueError('urdf_images_root_observations_missing:' + root)
poses = np.asarray([self._pose(i, role, branch) for i in indices])
indices, poses = self.motion_poses(indices, role, branch)
rotations = Rotation.from_matrix(poses[:, :3, :3])
axis, _ = _axis(rotations)
progression = (rotations[-1]*rotations[0].inv()).as_rotvec() @ axis
@@ -415,7 +470,7 @@ class UrdfCommandImages(FingerChainImages):
self._initialization_costs[tuple(branches)] = self._refine_mount_guesses(initial, transforms)
return initial
def _refine_mount_guesses(self, initial, transforms):
def _refine_mount_guesses(self, initial, transforms, *, observations=None):
"""Bounded block initialization on the SAME training image objective.
Five evaluations per installation plus the remaining joint evaluations
@@ -423,8 +478,9 @@ class UrdfCommandImages(FingerChainImages):
"""
from scipy.optimize import least_squares
evaluations = 0
observations = self.observations if observations is None else observations
for role, column in self.mount_columns.items():
indices, observed, matrices, objects = self.observations[role]
indices, observed, matrices, objects = observations[role]
camera = np.linalg.inv(self.extrinsics.transform(self.tags[role][0])) @ transforms[self.tag_links[role]][indices]
def project(values):
mount = rigid(values)
@@ -453,13 +509,17 @@ class UrdfCommandImages(FingerChainImages):
return evaluations
def covariance(self, fit):
jac = (fit.jac.toarray() if hasattr(fit.jac, 'toarray') else np.asarray(fit.jac))*self.scale[None, :]
active = self.optimization_columns
jac = (fit.jac.toarray() if hasattr(fit.jac, 'toarray') else np.asarray(fit.jac))[:, active]*self.scale[active]
information = jac.T @ jac
eigen = np.linalg.eigvalsh(information)
if len(fit.fun) <= len(fit.x) or eigen[0] <= eigen[-1]*np.finfo(float).eps*len(self.scale):
if len(fit.fun) <= len(active) or eigen[0] <= eigen[-1]*np.finfo(float).eps*len(active):
raise ValueError('urdf_images_geometry_rank_deficient')
variance = max(.03**2, float(fit.fun @ fit.fun/(len(fit.fun)-len(fit.x))))
return np.linalg.inv(information)*variance*self.scale[:, None]*self.scale[None, :]
variance = max(.03**2, float(fit.fun @ fit.fun/(len(fit.fun)-len(active))))
covariance = np.zeros((len(self.scale), len(self.scale)))
covariance[np.ix_(active, active)] = (np.linalg.inv(information)*variance
*self.scale[active, None]*self.scale[None, active])
return covariance
def output_mappings(self, parameters):
return {name: JointMapping(name, self.channels[name], 'command_u8', tuple(self.knots[name]),
@@ -31,18 +31,22 @@ class MotionRangeEvidence:
command_training_cycles: tuple[int, ...] = (0, 1, 2)
command_training_sample_ids: tuple[str, ...] = ()
release_basis: str = "feedback_and_command"
command_support: dict | None = None
def _interval(value):
return len(value) == 2 and all(math.isfinite(v) for v in value) and value[0] < value[1]
def validate_range_evidence(name, original, delta, interval, evidence):
def validate_range_evidence(name, original, delta, interval, evidence, *, allow_single_training_cycle=False):
"""Validate trusted fit evidence at both planning and final read-back."""
cycles = {LEGACY_TRAINING, LEGACY_TRAINING[:2]}
if allow_single_training_cycle:
cycles |= {(0,), (1,)} # Explicit uncertified draft only; never holdout.
if (not isinstance(evidence, MotionRangeEvidence) or evidence.joint != name
or evidence.source_limits != "calibrated_motion_envelope" or not evidence.evidence_source.strip()
or evidence.training_cycles not in {LEGACY_TRAINING, LEGACY_TRAINING[:2]} or not evidence.training_sample_ids
or evidence.command_training_cycles not in {LEGACY_TRAINING, LEGACY_TRAINING[:2], (4,)}
or evidence.training_cycles not in cycles or not evidence.training_sample_ids
or evidence.command_training_cycles not in cycles | {(4,)}
or (evidence.command_training_cycles == (4,) and not evidence.command_training_sample_ids)
or not evidence.reference_sample_ids or not evidence.source_joint
or evidence.independently_measured != (evidence.source_joint == name)
@@ -0,0 +1,45 @@
"""Validate the actual limits-linear SDK driver without refitting any curve."""
import numpy as np
from ..geometry.tag_pose.urdf_command_images import UrdfCommandImages
from ..fitting.motion_fit import joint_input_direction
class UrdfLinearImages:
"""Keep frozen camera/mount geometry, replacing only the angle provider."""
def __init__(self, bundle, corrected):
self.bundle, self.corrected = bundle, corrected
def __getattr__(self, name):
return getattr(self.bundle, name)
def joint_angles(self, parameters, commands, directions):
offsets = self.bundle.zero_offsets(parameters)
angles = {}
for name, channel in self.channels.items():
cad = self.cad_names[name]
joint = self.corrected.joints[cad]
ratio = np.asarray(commands[:, channel], dtype=float)/255.
if np.any((ratio < 0) | (ratio > 1)):
raise ValueError('urdf_linear_command_outside_byte_range')
if joint_input_direction(self.profile, self.model, name) < 0:
ratio = 1-ratio
# The projector keeps the original CAD origin. Account for the
# exported origin exactly once, including nonzero byte baselines.
angles[cad] = offsets[name]+joint.lower+(joint.upper-joint.lower)*ratio
return angles
def link_poses(self, parameters, commands, directions, angle_corrections=None):
return UrdfCommandImages.link_poses(self, parameters, commands, directions, angle_corrections)
def project_observations(self, parameters, commands, directions, observations, angle_corrections=None):
return UrdfCommandImages.project_observations(self, parameters, commands, directions,
observations, angle_corrections)
def validate_urdf_linear_drive(bundle, parameters, corrected, holdout, *, report_joints=None):
from types import SimpleNamespace
from ..fitting.raw_joint_validation import validate_raw_joint_candidate
result = validate_raw_joint_candidate(UrdfLinearImages(bundle, corrected),
SimpleNamespace(parameters=parameters), holdout, report_joints=report_joints)
return dict(result, drive='urdf_limits_linear_sdk_u8', parameters_refitted=False)
@@ -62,6 +62,7 @@ def build_standard_correction_plan(
independent_joint_channels: Mapping[str, int] | None = None,
fitted_mimics_output: Mapping[str, LinearMimicFit] | None = None,
preserve_source_mimic: bool = False,
allow_single_training_cycle: bool = False,
) -> StandardUrdfPlan:
"""Generate authorized standard-URDF edits, never clamp an invalid fit.
@@ -133,7 +134,8 @@ def build_standard_correction_plan(
if len(interval) != 2 or not all(math.isfinite(v) for v in interval) or interval[0] >= interval[1]:
raise ValueError(f"invalid corrected interval: {name}")
if name in motion_ranges:
validate_range_evidence(name, joint, delta, interval, motion_ranges[name])
validate_range_evidence(name, joint, delta, interval, motion_ranges[name],
allow_single_training_cycle=allow_single_training_cycle)
elif interval[0] < mechanical[0] - 1e-9 or interval[1] > mechanical[1] + 1e-9:
raise ValueError(f"corrected range exceeds source mechanical range: {name}")
if name in envelopes:
@@ -49,13 +49,13 @@ def compile_capture_scope(profile):
suffix = "_" + name
return value if path.stem.endswith(suffix) else path.stem + suffix + path.suffix
return replace(profile,
quality=replace(profile.quality, training_policy='taskwise_2_plus_1')
if profile.quality.training_policy == 'raw_joint_2_plus_1' else profile.quality,
motion=replace(profile.motion, tasks=tuple(tasks),
joint_zero_references=keep(profile.motion.joint_zero_references)),
measurement=replace(profile.measurement, measurements=keep(profile.measurement.measurements)),
zero=replace(zero, direct_zero_joints=tuple(j for j in zero.direct_zero_joints if j not in retained),
axis_joints=tuple(j for j in zero.axis_joints if j not in retained),
cad_zero_assumptions=keep(zero.cad_zero_assumptions),
known_baseline_geometry=keep(zero.known_baseline_geometry),
cad_frozen_joints=zero.cad_frozen_joints | retained, spatial=spatial),
artifacts=replace(profile.artifacts,
directional_command_joints=profile.artifacts.directional_command_joints - retained,
@@ -39,7 +39,10 @@ class O30RosAdapter(LegacyByteSdkAdapter):
or not str(data.get("uid") or "").strip()
or tuple(data.get("joint_names", ())) != self.command_layout.names
or not isinstance(data.get("online"), bool)):
raise ValueError("o30_sdk_identity_mismatch")
observed = ({key: data.get(key) for key in
('hand_type', 'model', 'side', 'uid', 'joint_names', 'online')}
if isinstance(data, dict) else data)
raise ValueError("o30_sdk_identity_mismatch:" + json.dumps(observed, ensure_ascii=False))
if self.info is not None and data["uid"] != self.info["uid"]:
raise ValueError("o30_sdk_device_changed")
faults = data.get("joint_faults", {})
@@ -15,9 +15,18 @@ from ..resume import ResumeVerifier, fingerprint_from_mapping
from ..scan_quality import evaluate_capture_unit
def validate_capture_provenance(profile, serial_number, hashes, records):
def validate_capture_provenance(profile, serial_number, hashes, records, *, allow_installation_draft=False):
"""A journal cannot substitute today's hashes for old evidence."""
reject_diagnostic_capture(records)
if not allow_installation_draft and any(row.get('kind') == 'raw_tag_remount_checkpoint' for row in records):
raise ValueError('capture_provenance:remounted_capture_requires_separate_installation_certification')
for row in records:
if row.get('kind') == 'raw_resume_verified':
check = row.get('installation_verification', {})
if (not allow_installation_draft and (check.get('requires_offline_review')
or check.get('changed_roles') or check.get('unavailable_roles')
or check.get('installation_verified') is False)):
raise ValueError('capture_provenance:resume_installation_requires_offline_review')
headers = [row for row in records if row.get("kind") == "session_start"]
references = [row for row in records if row.get("kind") == "fixed_base_reference_locked"]
if len(headers) != 1 or len(references) != 1:
@@ -0,0 +1,348 @@
"""Offline approximate URDF export, separate from precision certification."""
from dataclasses import asdict, replace
import json
from pathlib import Path
from types import SimpleNamespace
import traceback
import numpy as np
from ...core.domain.result import JointMapping
from ...core.fitting.command_support import validate_turnaround_mapping
from ...core.fitting.raw_joint_draft import (
DraftCommandImages, draft_profile, draft_targets, draft_training_coverage, fit_draft,
define_upright_splay_zero, UPRIGHT_SPLAY_JOINTS, UPRIGHT_SPLAY_REFERENCE,
)
from ...core.fitting.raw_joint_validation import validate_raw_joint_candidate
from ...core.geometry.extrinsics import load_camera_extrinsics
from ...core.urdf.kinematics import UrdfKinematicModel
from ...core.urdf.limits import MotionRangeEvidence
from ...core.urdf.plan import build_standard_correction_plan
from ...profiles import load_hand_profile
from ...profiles.observations import compile_tag_links
from ...storage import atomic_write_json
from ..acquisition import load_capture
from ..engine import CalibrationEngine
from ..raw_completion import file_digest, implementation_digest, verify_capture_manifest
from ..raw_evidence import partition_raw_capture, fitting_frames
from .capture_validation import validate_capture_provenance
from .raw_joint_report import prepare_raw_motion_ranges
from .serializers.unified_v1 import _mapping
from .standard_loader import validate_with_robot_state_publisher
from .urdf_from_json import serialize_urdf_correction
DRAFT_FORMAT = 'raw_joint_urdf_draft_v1'
def evaluate_draft_holdout(bundle, parameters, holdout):
"""Preserve evaluator failures without certifying or refitting a draft."""
try:
return validate_raw_joint_candidate(bundle, SimpleNamespace(parameters=parameters), holdout)
except Exception as error:
# An evaluator failure is not a measured accuracy result. Retain its
# diagnostics; only the uncertified training draft remains exportable.
return dict(passed=False, status='evaluation_failed', reason=str(error),
exception_type=type(error).__name__, traceback=traceback.format_exc())
def capture_profile_for_draft(profile, header, path):
"""Replay may correct model bindings/signs, never the captured SDK route."""
if path is None:
return profile
path = Path(path)
if file_digest(path) != header.get('profile_config_sha256'):
raise ValueError('draft_capture_profile_hash_mismatch')
captured = load_hand_profile(path)
current = {tag.role: tag.link for view in profile.vision.views for tag in view.tags}
corrected = replace(captured,
command=replace(captured.command, sdk_to_joint_direction=profile.command.sdk_to_joint_direction),
vision=replace(captured.vision, views=tuple(
replace(view, tags=tuple(replace(tag, link=current.get(tag.role, tag.link)) for tag in view.tags))
for view in captured.vision.views)))
if corrected != profile:
raise ValueError('draft_profile_change_must_only_correct_model_bindings_or_directions')
return captured
def read_draft_plan(payload, source, profile):
"""Rebuild the plan from saved numeric evidence, with the original writer."""
if (payload.get('format') != DRAFT_FORMAT or payload.get('profile_id') != profile.key.profile_id
or payload.get('accuracy_passed') is not False or payload.get('publication_allowed') is not False):
raise ValueError('draft_identity_or_status_invalid')
joints = payload['joints']
if not joints or not set(joints) <= profile.zero.active_joints:
raise ValueError('draft_joint_scope_invalid')
if set(payload['motion_range_evidence']) != set(joints):
raise ValueError('draft_range_scope_mismatch')
ranges, mappings, offsets = {}, {}, {}
for name, row in joints.items():
table = row['command_to_rad']
mappings[name] = JointMapping(name, table['channel_index'], table['input_domain'],
*(tuple(table[key]) for key in ('knots', 'angle_rad', 'increasing_rad', 'decreasing_rad')))
offsets[name] = float(row['zero_offset_rad'])
if (not np.isfinite(offsets[name]) or abs(offsets[name]) > np.radians(20)+1e-10
or (row['zero_status'] in ('retained_cad', 'operator_defined') and offsets[name] != 0.)):
raise ValueError('draft_zero_outside_authorized_model:' + name)
if row['zero_status'] == 'operator_defined' and (name not in UPRIGHT_SPLAY_JOINTS
or row.get('zero_assumption') != UPRIGHT_SPLAY_REFERENCE
or payload.get('baseline_command') != list(profile.command.baseline_values)):
raise ValueError('draft_operator_zero_reference_invalid:' + name)
evidence = payload['motion_range_evidence'][name]
ranges[name] = MotionRangeEvidence(**{key: tuple(value) if isinstance(value, list) else value
for key, value in evidence.items()})
validate_turnaround_mapping(mappings[name], ranges[name].command_support)
if (mappings[name].motor_index != profile.command.command_index_by_joint[name]
or mappings[name].input_domain != 'command_u8'
or mappings[name].bounds_rad != ranges[name].command_bounds_rad
or abs(mappings[name].evaluate(ranges[name].baseline_command)) > 1e-10):
raise ValueError('draft_mapping_differs_from_urdf_evidence:' + name)
plan = build_standard_correction_plan(source_urdf=source,
source_sha256=payload['urdf_correction']['source_urdf_sha256'], zero_offsets_rad=offsets,
measured_ranges_output_rad={name: value.output_limits_rad for name, value in ranges.items()},
authorized_fields=profile.urdf_authorized_fields, range_evidence=ranges, preserve_source_mimic=True,
allow_single_training_cycle=True)
if serialize_urdf_correction(plan) != payload['urdf_correction']:
raise ValueError('draft_saved_correction_differs_from_evidence')
return plan, mappings
def load_training_initialization(directory, config, input_sha256):
"""A prior same-data training result supplies a guess, never a fixed zero."""
directory = Path(directory).resolve()
paths = [directory/name for name in ('draft_result.json', 'draft_calibration.json',
'draft_training.json', 'draft_candidates.npz')]
digests = {str(path): file_digest(path) for path in paths}
state, payload, training = (json.loads(path.read_text()) for path in paths[:3])
from ..runner_support import protected_inputs
if (state.get('format') != DRAFT_FORMAT or not state.get('generated')
or state.get('input_sha256') != input_sha256
or state.get('solver_protected_inputs') != protected_inputs(config)
or training.get('validation_used_for_selection') is not False
or training.get('selection') != 'minimum_training_coordinate_rms'
or payload.get('training') != training):
raise ValueError('draft_initialization_must_match_source_and_training_only_selection')
_, mappings = read_draft_plan(payload, config.source_urdf, config.calibration_contract.typed_profile)
with np.load(paths[3], allow_pickle=False) as arrays:
matches = np.flatnonzero(np.all(arrays['seeds'] == training['selected_seed'], axis=1))
if len(matches) != 1:
raise ValueError('draft_initialization_selected_seed_missing')
parameters = arrays['parameters'][matches[0]]
if not np.all(np.isfinite(parameters)) or len(parameters) < 6:
raise ValueError('draft_initialization_parameters_invalid')
hint = dict(palm=parameters[:6], mappings=mappings,
offsets={name: float(row['zero_offset_rad']) for name, row in payload['joints'].items()})
return hint, dict(source_directory=str(directory), input_sha256=digests,
policy='same_capture_training_initial_guess_v1', shared_parameters_frozen=False,
validation_used=False)
def export_draft_artifacts(payload, source, profile, directory, probes, *,
standard_loader=validate_with_robot_state_publisher):
"""One writer for single/multiple-batch drafts; JSON owns every edit."""
directory = Path(directory)
json_path, urdf_path = directory/'draft_calibration.json', directory/'o30_right_draft.urdf'
if json_path.exists() or urdf_path.exists():
raise ValueError('draft_artifacts_already_exist')
# Check before saving so invalid evidence cannot look like a finished pair.
read_draft_plan(payload, source, profile)
atomic_write_json(json_path, payload)
saved = json.loads(json_path.read_text())
plan, mappings = read_draft_plan(saved, source, profile)
plan.write(source, urdf_path)
original, corrected = UrdfKinematicModel(source), UrdfKinematicModel(urdf_path)
offsets = {name: row['zero_offset_rad'] for name, row in saved['joints'].items()}
commands = {(tuple(row['command_vector']), tuple(row['command_direction_by_index'])) for row in probes}
if not commands:
raise ValueError('draft_final_file_probes_missing')
maximum = 0.
for values, directions in sorted(commands):
angles = {name: mapping.evaluate(values[mapping.motor_index], directions[mapping.motor_index])
for name, mapping in mappings.items()}
for name in mappings:
cad = profile.command.urdf_joint_by_joint.get(name, name)
before = original.link_transform(cad, zero_offsets=offsets, joint_angles=angles,
independent_mimic_angles=True)
after = corrected.link_transform(cad, zero_offsets={}, joint_angles=angles,
independent_mimic_angles=True)
maximum = max(maximum, float(np.max(np.abs(before-after))))
if maximum > 1e-8:
raise ValueError('draft_json_urdf_fk_mismatch')
standard_loader(urdf_path)
return json_path, urdf_path, corrected, maximum
def replay_draft(config, raw_path, *, output, capture_profile=None, initialization_from=None,
upright_splay_zero=False,
standard_loader=validate_with_robot_state_publisher):
"""No SDK, resume, publisher or automatic recapture can be reached here."""
from ..runner_support import protected_inputs, create_session_directory
raw_path = Path(raw_path).resolve()
installation_note = raw_path.parent/'operator_installation_clarification.json'
files = sorted({raw_path, *raw_path.parent.glob(raw_path.stem + '.*'),
*raw_path.parent.glob('imported_evidence_*.zlib'), installation_note})
files = [path for path in files if path.exists()]
digests = {str(path): file_digest(path) for path in files}
records = load_capture(raw_path)
header = next(row for row in records if row.get('kind') == 'session_start')
profile = config.calibration_contract.typed_profile
if profile.key.model != 'O30':
raise ValueError('draft_currently_requires_o30_raw_capture')
snapshot = raw_path.parent/'capture_profile.yaml'
if (capture_profile is None and snapshot.is_file()
and header.get('profile_config_sha256') != config.profile_config_sha256):
capture_profile = snapshot
captured = capture_profile_for_draft(profile, header, capture_profile)
hashes = protected_inputs(config)
captured_hashes = dict(hashes)
if capture_profile is not None:
captured_hashes['profile_config_sha256'] = file_digest(Path(capture_profile))
validate_capture_provenance(captured, config.serial_number, captured_hashes, records,
allow_installation_draft=True)
from ..motion_provenance import validate_source_geometry
validate_source_geometry(captured, config.source_urdf, records)
sealed = (raw_path.parent/'raw_capture_manifest.json').exists()
if sealed:
manifest = verify_capture_manifest(raw_path)
if manifest['capture_plan'] != header['capture_plan'] or manifest['serial_number'] != config.serial_number:
raise ValueError('draft_manifest_identity_changed')
evidence = partition_raw_capture(captured, records, require_complete=False, reassess_quality=True)
training = fitting_frames(captured, evidence.training)
model = UrdfKinematicModel(config.source_urdf)
captured_links = compile_tag_links(captured, model)
corrected_links = compile_tag_links(profile, model)
units = CalibrationEngine(captured).scan_units()
coverage = draft_training_coverage(evidence.completed_units, units)
selected = draft_profile(profile, model, evidence.completed_units, units, training)
if upright_splay_zero:
selected = define_upright_splay_zero(selected, model)
tasks = {task.key for task in selected.motion.tasks}
roles = {tag.role for view in selected.vision.views for tag in view.tags if not tag.fixed_reference}
def scoped(rows):
return tuple(row for row in rows if row['task_name'] in tasks and roles.intersection(row['tags']))
training, holdout = scoped(training), scoped(fitting_frames(captured, evidence.validation))
# Every accepted image must use the locked rectified camera projection.
matrices = {row['view']: row['camera_matrix'] for row in records if row.get('kind') == 'rectified_camera_model'}
if any(not np.array_equal(row['camera_matrix'], matrices[row['view']]) for row in (*training, *holdout)):
raise ValueError('draft_camera_projection_changed')
extrinsics = load_camera_extrinsics(config.camera_extrinsics, required_views=profile.vision.view_names,
reference_view=profile.vision.extrinsic_reference_view, quality_limits=profile.vision.extrinsics_quality_limits,
minimum_capture_counts=profile.vision.minimum_capture_counts)
hint, hint_audit = (load_training_initialization(initialization_from, config, digests)
if initialization_from is not None else (None, None))
bundle = DraftCommandImages(selected, model, extrinsics, training, compile_tag_links(selected, model),
initialization_cycles={task: coverage[task]['initialization_cycle'] for task in tasks},
training_initialization=hint)
if output is None:
directory = create_session_directory(config.session_root/'drafts')
else:
directory = Path(output).expanduser().resolve()
directory.mkdir(parents=True, exist_ok=False)
state = dict(format=DRAFT_FORMAT, commands_enabled=False, accuracy_passed=False,
publication_allowed=False, generated=False, input_sha256=digests, sealed_capture=sealed,
missing_units=evidence.missing_units, raw_capture_complete=not evidence.missing_units,
acquisition_reassessment=evidence.reassessed_units, implementation_sha256=implementation_digest(),
training_selection=dict(policy='at_least_one_complete_training_cycle_v1',
tasks=coverage, included_tasks=sorted(tasks), training_frames=len(training),
validation_frames=len(holdout), validation_used_for_training=False),
training_initialization=hint_audit,
operator_zero_reference=(dict(policy=UPRIGHT_SPLAY_REFERENCE,
joints=sorted(UPRIGHT_SPLAY_JOINTS), baseline_command=list(profile.command.baseline_values),
independently_measured=False) if upright_splay_zero else None),
solver_observations=dict(policy='exact_repeated_pose_least_squares_v1',
original_tag_observations=bundle.original_residual_count//8,
grouped_tag_observations=len(bundle.residual_weights)//8,
within_group_squared_error=bundle.within_group_error),
captured_protected_inputs=captured_hashes, solver_protected_inputs=hashes,
remount_history=[row for row in records if row.get('kind') == 'raw_tag_remount_checkpoint'],
resume_installation_checks=[row.get('installation_verification', {}) for row in records
if row.get('kind') == 'raw_resume_verified'],
operator_installation_clarification=(json.loads(installation_note.read_text())
if installation_note.is_file() else None),
model_direction_corrections={name: dict(captured=captured.command.joint_directions[channel],
offline=profile.command.joint_directions[channel])
for name, channel in profile.command.command_index_by_joint.items()
if captured.command.joint_directions[channel] != profile.command.joint_directions[channel]},
model_tag_link_corrections={role: dict(captured=captured_links[role], offline=link)
for role, link in corrected_links.items() if captured_links.get(role) != link},
correction_scope=draft_targets(profile, selected), full_target_generated=False)
atomic_write_json(directory/'draft_result.json', state)
print(f'离线草稿:{len(tasks)} 个任务,{len(bundle.joints)} 个关节;'
f'训练 {len(training)} 帧,独立验证 {len(holdout)} 帧;'
f'保留 {len(evidence.missing_units)} 个采集缺项。', flush=True)
# Keep every usable steady frame, but release the much larger sweep and
# rejected-frame journals before allocating the optimizer's Jacobian.
range_frames = fitting_frames(selected, evidence.training)
del records, evidence
audit_rows = []
def progress(row):
audit_rows.append(row)
atomic_write_json(directory/'draft_training.json', dict(candidates=audit_rows, validation_used=False))
try:
direction_audit = bundle.audit_root_directions()
atomic_write_json(directory/'draft_direction_audit.json', direction_audit)
if not direction_audit['passed']:
raise ValueError('draft_sdk_to_urdf_direction_conflict')
parameters, audit, candidates = fit_draft(bundle, progress)
atomic_write_json(directory/'draft_training.json', audit)
np.savez_compressed(directory/'draft_candidates.npz',
parameters=np.asarray([values for _, values in candidates]),
seeds=np.asarray([row['seed'] for row, _ in candidates]))
# Freeze the training choice before any validation. Precision cannot
# block a draft; structural and source-integrity checks still do.
atomic_write_json(directory/'draft_selection.json', audit)
validation = evaluate_draft_holdout(bundle, parameters, holdout)
atomic_write_json(directory/'draft_holdout.json', validation)
mappings, offsets = bundle.output_mappings(parameters), bundle.zero_offsets(parameters)
ranges = prepare_raw_motion_ranges(bundle, parameters, range_frames)
plan = build_standard_correction_plan(source_urdf=config.source_urdf,
source_sha256=config.source_urdf_sha256, zero_offsets_rad=offsets,
measured_ranges_output_rad={name: value.output_limits_rad for name, value in ranges.items()},
authorized_fields=profile.urdf_authorized_fields, range_evidence=ranges, preserve_source_mimic=True,
allow_single_training_cycle=True)
payload = dict(format=DRAFT_FORMAT, profile_id=profile.key.profile_id, serial_number=config.serial_number,
accuracy_passed=False, publication_allowed=False, baseline_command=list(profile.command.baseline_values),
coordinate_convention='q_CAD=q_output+zero_offset; mapping emits q_output',
scope='observed_training_travel_and_held_poses; arbitrary_multiaxis_not_validated',
correction_scope=state['correction_scope'],
retained_joints=sorted(profile.zero.active_joints-set(mappings)),
joints={name: dict(zero_offset_rad=offsets[name],
zero_status=('operator_defined' if selected.zero.cad_zero_assumptions.get(name)
== UPRIGHT_SPLAY_REFERENCE else 'retained_cad'
if name in selected.zero.cad_zero_assumptions else 'estimated_draft'),
zero_assumption=selected.zero.cad_zero_assumptions.get(name), command_to_rad=_mapping(mapping))
for name, mapping in mappings.items()},
urdf_correction=serialize_urdf_correction(plan),
motion_range_evidence={name: asdict(value) for name, value in ranges.items()},
training=audit, holdout=validation, provenance=state)
json_path, urdf_path, corrected, maximum = export_draft_artifacts(payload,
config.source_urdf, profile, directory, training, standard_loader=standard_loader)
from ...core.urdf.linear_drive import UrdfLinearImages
linear_validation = evaluate_draft_holdout(UrdfLinearImages(bundle, corrected), parameters, holdout)
linear_validation.update(drive='urdf_limits_linear_sdk_u8', parameters_refitted=False)
atomic_write_json(directory/'draft_urdf_linear_holdout.json', linear_validation)
if digests != {str(path): file_digest(path) for path in files}:
raise ValueError('draft_source_capture_changed_during_fit')
if hint_audit is not None and hint_audit['input_sha256'] != {
path: file_digest(Path(path)) for path in hint_audit['input_sha256']}:
raise ValueError('draft_initialization_changed_during_fit')
state.update(generated=True, source_unchanged=True, urdf=str(urdf_path),
full_target_generated=state['correction_scope']['full_target_covered'],
urdf_sha256=file_digest(urdf_path), calibration_json=str(json_path),
corrected_joints=sorted(plan.patches.joints), zero_offsets_rad=offsets,
retained_zero_assumptions=dict(selected.zero.cad_zero_assumptions),
final_file_fk_maximum_difference=maximum, standard_loader_passed=True,
independent_holdout_passed=validation.get('passed', False))
state['urdf_linear_holdout_passed'] = linear_validation.get('passed', False)
atomic_write_json(directory/'draft_result.json', state)
print(f'已生成修正草稿:{urdf_path};零位及行程见 draft_calibration.json。精度尚未认证。', flush=True)
scope = state['correction_scope']
print(f"零位 {len(scope['estimated_zero_joints'])}/{len(scope['zero_target_joints'])}、"
f"另有用户定义竖直零位 {len(scope['defined_zero_joints'])} 个;"
f"运动范围 {len(scope['estimated_range_joints'])}/{len(scope['range_target_joints'])};"
f"五指末端零位保留 CAD。全手参数已生成:{state['full_target_generated']};"
f"独立验证通过:{state['independent_holdout_passed']};草稿不允许正式发布。", flush=True)
return state
except Exception as error:
state['reason'] = str(error)
atomic_write_json(directory/'draft_result.json', state)
raise
@@ -29,7 +29,8 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur
from .finalization import CorrectedArtifact
directory, source = Path(session_dir).resolve(), Path(source_urdf).resolve()
directory.mkdir(parents=True, exist_ok=True)
state = dict(protocol='raw_joint_images_v1', raw_capture_complete=False,
state = dict(protocol=next((r['capture_plan']['observation_protocol']['version']
for r in records if r.get('kind') == 'session_start'), 'unknown'), raw_capture_complete=False,
fit_complete=False, accuracy_passed=False, publication_allowed=False)
def save():
atomic_write_json(directory/'raw_joint_result.json', state)
@@ -48,8 +49,9 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur
raise ValueError('raw_joint_source_urdf_changed')
from ..motion_provenance import validate_source_geometry
validate_source_geometry(profile, source, records)
evidence = partition_raw_capture(profile, records, require_complete=False)
evidence = partition_raw_capture(profile, records, require_complete=False, reassess_quality=True)
state['missing_units'] = evidence.missing_units
state['acquisition_reassessment'] = evidence.reassessed_units
if evidence.missing_units:
raise ValueError('raw_joint_capture_incomplete:see_missing_units')
state.update(raw_capture_complete=True, capture_sha256=evidence.sha256)
@@ -63,10 +65,9 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur
matrices[row['view']] = matrix
reference = next(r for r in records if r.get('kind') == 'fixed_base_reference_locked')
from ..raw_resume import RawCheckpoint
from ...core.domain.capture_plan import CapturePlan
for resumed in (r for r in records if r.get('kind') == 'raw_resume_verified'):
header = resumed['source_header']
if (header.get('capture_plan') != CapturePlan.from_profile(profile).as_dict()
if (header.get('capture_plan') != evidence.capture_plan
or header.get('serial_number') != serial_number
or any(header.get(key) != value for key, value in hashes.items())):
raise ValueError('raw_joint_imported_provenance_changed')
@@ -159,7 +160,7 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur
except Exception as error:
state.update(reason=str(error), publication_allowed=False,
automatic_geometry_recapture=False,
next_action=('recapture_only_missing_units' if state.get('missing_units') else
next_action=('inspect_saved_missing_units_without_automatic_motion' if state.get('missing_units') else
'inspect_training_candidate_and_holdout_reports_before_selecting_new_observations'))
save()
raise
@@ -2,25 +2,83 @@
from dataclasses import asdict
from ...core.domain.capture_plan import CapturePlan, task_for_joint, evidence_digest
from ...core.domain.motion_path import task_segment
from ...core.urdf.limits import MotionRangeEvidence
from ...core.urdf.plan import build_standard_correction_plan
from .serializers.measured_v3 import REPORT_FORMAT, ZERO_CONVENTION
from .serializers.unified_v1 import _mapping
from .urdf_from_json import serialize_urdf_correction
from ..raw_evidence import fitting_frames
from ...core.fitting.command_support import TURNAROUND_POLICY, validate_turnaround_mapping
def joint_training_rows(profile, training, name):
"""Only visible, active training observations support a joint's range."""
task = task_for_joint(profile, name)
spec = profile.measurement.measurements[name]
return [row for row in training if row['task_name'] == task.key
and row.get('cycle') in (0, 1) and row['view'] == spec.view
and spec.child_role in row.get('tags', {})
and (not task.segments or name in task_segment(task, row.get('segment_key', '')).joints)]
def prepare_raw_motion_ranges(bundle, parameters, training):
"""Training-supported travel, shared by drafts and precision releases."""
profile = bundle.profile
training = fitting_frames(profile, training)
mappings, offsets = bundle.output_mappings(parameters), bundle.zero_offsets(parameters)
ranges = {}
for name, mapping in mappings.items():
rows = joint_training_rows(profile, training, name)
baseline = profile.command.baseline_values[mapping.motor_index]
baseline_rows = [row for row in rows if row['command_vector'][mapping.motor_index] == baseline]
if not baseline_rows:
raise ValueError('raw_joint_baseline_support_missing:' + name)
policy = profile.urdf_limit_policies[name]
original = bundle.model.joints[bundle.cad_names[name]]
ids = tuple(sorted(f"{row['view']}:{row['image_stamp_ns']}" for row in rows))
baseline_ids = tuple(sorted(f"{r['view']}:{r['image_stamp_ns']}" for r in baseline_rows))
bounds = min(0., mapping.bounds_rad[0]), max(0., mapping.bounds_rad[1])
cycles = tuple(sorted({row['cycle'] for row in rows}))
support = None
if hasattr(bundle, 'turnaround_constraints'):
constraints = [{key: value for key, value in row.items() if key != 'column'}
for row in bundle.turnaround_constraints.get(name, ())]
endpoints = []
for command in (mapping.knots[0], mapping.knots[-1]):
observed = [r for r in rows if r['command_vector'][mapping.motor_index] == command]
if not observed:
raise ValueError('raw_joint_endpoint_support_missing:' + name + ':' + str(command))
for direction, label in enumerate(('increasing', 'decreasing')):
if (command != baseline and not any(r['command_direction_by_index'][mapping.motor_index] == label
for r in observed) and not any(c['command'] == command and c['recipient'] == direction
for c in constraints)):
raise ValueError('raw_joint_directional_endpoint_support_missing:' + name + ':' + label)
endpoints.append(dict(command=command, samples=[dict(
id=f"{r['view']}:{r['image_stamp_ns']}", cycle=r['cycle'],
direction=r['command_direction_by_index'][mapping.motor_index]) for r in observed]))
support = dict(policy=TURNAROUND_POLICY, constraints=constraints,
endpoints=endpoints, independently_measured_outgoing_endpoint=False if constraints else None)
validate_turnaround_mapping(mapping, support)
ranges[name] = MotionRangeEvidence(name, name, True, (original.lower, original.upper),
bounds, None, mapping.bounds_rad, offsets[name], baseline, cycles, ids, baseline_ids,
policy.source_limits, policy.evidence_source, policy.independent_safety_limits_cad_rad,
policy.safety_evidence_source, cycles, ids, 'steady_command', support)
return ranges
def prepare_raw_report(bundle, fit, evidence, validation, *, serial_number, protected_inputs):
profile = bundle.profile
candidate = fit.candidates[fit.representative]
mappings, offsets = bundle.output_mappings(candidate.parameters), bundle.zero_offsets(candidate.parameters)
ranges, references = {}, {}
ranges = prepare_raw_motion_ranges(bundle, candidate.parameters, evidence.training)
references = {}
training_rows = fitting_frames(profile, evidence.training)
training_digest = evidence_digest(evidence.training)
candidate_digest = evidence_digest([dict(seed=list(c.seed), parameters=c.parameters.tolist(),
covariance=c.covariance.tolist()) for c in fit.candidates])
for name, mapping in mappings.items():
task = task_for_joint(profile, name)
rows = [row for row in evidence.training if row['task_name'] == task.key
and row['sample_phase'] == 'steady' and row.get('accepted_sample_window_open')]
rows = joint_training_rows(profile, training_rows, name)
baseline = profile.command.baseline_values[mapping.motor_index]
baseline_rows = [row for row in rows if row['command_vector'][mapping.motor_index] == baseline]
if not baseline_rows:
@@ -29,15 +87,6 @@ def prepare_raw_report(bundle, fit, evidence, validation, *, serial_number, prot
baseline_command=baseline, training_sha256=training_digest, candidate_set_sha256=candidate_digest,
physical_zero_source='joint_urdf_image_graph',
samples=[dict(view=r['view'], image_stamp_ns=r['image_stamp_ns']) for r in baseline_rows])
policy = profile.urdf_limit_policies[name]
original = bundle.model.joints[bundle.cad_names[name]]
ids = tuple(sorted(f"{row['view']}:{row['image_stamp_ns']}" for row in rows))
baseline_ids = tuple(sorted(f"{r['view']}:{r['image_stamp_ns']}" for r in baseline_rows))
bounds = min(0., mapping.bounds_rad[0]), max(0., mapping.bounds_rad[1])
ranges[name] = MotionRangeEvidence(name, name, True, (original.lower, original.upper),
bounds, None, mapping.bounds_rad, offsets[name], baseline, (0, 1), ids, baseline_ids,
policy.source_limits, policy.evidence_source, policy.independent_safety_limits_cad_rad,
policy.safety_evidence_source, (0, 1), ids, 'steady_command')
plan = build_standard_correction_plan(source_urdf=bundle.model.source,
source_sha256=protected_inputs['source_urdf_sha256'], zero_offsets_rad=offsets,
measured_ranges_output_rad={name: value.output_limits_rad for name, value in ranges.items()},
@@ -66,10 +115,12 @@ def prepare_raw_report(bundle, fit, evidence, validation, *, serial_number, prot
feedback_by_index=profile.command.feedback_by_index,
feedback_name_aliases=dict(profile.command.feedback_name_aliases),
protected_inputs=dict(protected_inputs), joints=joints,
capture_plan=CapturePlan.from_profile(profile).as_dict(),
capture_plan=evidence.capture_plan,
urdf_correction=serialize_urdf_correction(plan),
motion_range_evidence={name: asdict(value) for name, value in ranges.items()},
raw_joint_audit=dict(protocol='raw_joint_images_v1', capture_sha256=evidence.sha256,
raw_joint_audit=dict(protocol=evidence.capture_plan['observation_protocol']['version'], capture_sha256=evidence.sha256,
command_support={name: value.command_support for name, value in ranges.items()},
acquisition_reassessment=evidence.reassessed_units,
training_sha256=training_digest, candidate_set_sha256=candidate_digest,
candidate_uncertainty=fit.uncertainty, candidate_count=len(fit.candidates),
training_selection=fit.training_selection,
@@ -12,6 +12,12 @@ def replay_capture(config, raw_path: Path, *, output: Path | None, publish: bool
from ..runner_support import create_session_directory, protected_inputs
records = load_capture(raw_path)
profile = config.calibration_contract.typed_profile
header = next((r for r in records if r.get('kind') == 'session_start'), {})
if header.get('capture_plan', {}).get('observation_protocol', {}).get('version') == 'raw_joint_images_v2':
from ..raw_completion import verify_capture_manifest
manifest = verify_capture_manifest(raw_path)
if manifest['capture_plan'] != header['capture_plan'] or manifest['serial_number'] != config.serial_number:
raise ValueError('raw_capture_manifest_identity_changed')
hashes = protected_inputs(config)
reference = next((row for row in records if row.get("kind") == "fixed_base_reference_locked"), {})
if "intrinsics_sha256" in reference.get("protected_hashes", {}):
@@ -22,6 +28,12 @@ def replay_capture(config, raw_path: Path, *, output: Path | None, publish: bool
else:
output = output.expanduser().resolve()
output.mkdir(parents=True, exist_ok=False)
from ..raw_completion import implementation_digest
from ...storage import atomic_write_json
atomic_write_json(output / 'offline_run.json', dict(
input_raw=str(raw_path.resolve()), input_sha256=hashes['raw_samples_sha256'],
capture_plan=header.get('capture_plan'), implementation_sha256=implementation_digest(),
protected_inputs=hashes, commands_enabled=False, publish_requested=publish))
from ...core.domain.capture_plan import STAGED
if profile.quality.training_policy == STAGED:
import shutil
@@ -117,7 +117,8 @@ def from_report(report):
if raw_audit is not None:
from ....core.domain.capture_plan import RAW_JOINT
if (report.get('capture_plan', {}).get('policy') != RAW_JOINT
or raw_audit.get('protocol') != 'raw_joint_images_v1'
or raw_audit.get('protocol') not in {'raw_joint_images_v1', 'raw_joint_images_v2'}
or raw_audit.get('protocol') != report['capture_plan'].get('observation_protocol', {}).get('version')
or raw_audit.get('candidate_uncertainty', {}).get('passed') is not True
or raw_audit.get('validation_parameters_updated') is not False):
raise ValueError('raw_joint_report_not_certified')
@@ -126,6 +127,15 @@ def from_report(report):
for key in ('capture_sha256', 'training_sha256', 'candidate_set_sha256')):
raise ValueError('raw_joint_report_digest_invalid')
for name, row in report['joints'].items():
if 'command_support' in raw_audit:
from ....core.domain.result import JointMapping
from ....core.fitting.command_support import validate_turnaround_mapping
table = row['command_to_rad']
command = JointMapping(name, table['channel_index'], table['input_domain'],
*(tuple(table[key]) for key in ('knots', 'angle_rad', 'increasing_rad', 'decreasing_rad')))
if name not in raw_audit['command_support']:
raise ValueError('raw_joint_command_support_missing:' + name)
validate_turnaround_mapping(command, raw_audit['command_support'][name])
reference = row.get('zero_reference', {})
if (reference.get('kind') != 'raw_joint_training_baseline'
or reference.get('training_sha256') != raw_audit.get('training_sha256')
@@ -41,6 +41,7 @@ class CaptureFrame:
observed_roles: frozenset[str] = frozenset()
timing: CaptureTiming | None = None
command_skew_ns: int | None = None
detection_quality: dict | None = None
class _FramePoseEvidence:
@@ -331,9 +332,53 @@ class ObservationCapture:
"camera_matrix": frame.camera_matrix.tolist(), "camera_matrix_source": "CameraInfo.P[:3,:3]",
"input_is_rectified": True, "roles": observations}
@staticmethod
def _observation_record(frame, motion, evidence, allow_recording, raw_joint, command_unit):
return {"kind": "raw_observation_frame" if raw_joint else "diagnostic_observation_frame", "view": frame.view,
"image_stamp_ns": frame.stamp_ns, "task_name": motion.task_key,
"cycle": motion.cycle, "direction": motion.direction, "attempt": motion.attempt,
**segment_metadata(motion.segment_key),
"sample_phase": motion.phase, "command_unit": command_unit,
**({"zero_joints": list(motion.zero_joints)} if motion.phase == "joint_zero" else {}),
**({"steady_index": motion.steady_index,
"steady_target": None if motion.command_index is None else motion.target[motion.command_index],
"minimum_hold_seconds": motion.minimum_hold_seconds,
"target_command_vector": list(motion.target),
"accepted_sample_window_open": bool(allow_recording)} if motion.phase == "steady" else {}),
"command_vector": None if frame.command is None else list(frame.command),
"feedback_vector": None if frame.feedback is None else list(frame.feedback),
"state_image_sync_error_ms": None if frame.feedback is None else abs(frame.skew_ns)/1e6,
"command_image_skew_ns": frame.command_skew_ns,
"command_direction_by_index": list(frame.command_directions),
**({"timing": asdict(frame.timing)} if frame.timing is not None else {}),
"camera_matrix": frame.camera_matrix.tolist(), "input_is_rectified": True,
"tags": evidence,
**({"observation_protocol": "raw_joint_images_v2"} if raw_joint else {}), "is_accepted_joint_sample": False}
def _consume_raw(self, frame, motion, allow_recording):
# Only the fixed reference runs through pose tracking. Moving-tag
# evidence is independent of any PnP branch or online joint model.
with self._metadata_lock:
if frame.stamp_ns <= self._latest_stamp_by_view.get(frame.view, 0):
return (), None
events, movement = self.consume(frame, fixed_reference_only=True)
if motion is None or motion.phase not in {"sweep", "steady"}:
return events, movement
view = next(v for v in self.profile.vision.views if v.name == frame.view)
evidence = {tag.role: dict(tag_role=tag.role, tag_id=tag.tag_id,
tag_size_m=float(tag.size_m), corners_xy=np.asarray(frame.corners[tag.role]).tolist(),
image_stamp_ns=frame.stamp_ns, pose_source="raw_corners",
detection_quality=(frame.detection_quality or {}).get(tag.role, {}))
for tag in view.tags if tag.role in frame.corners}
record = self._observation_record(frame, motion, evidence, allow_recording,
raw_joint=True, command_unit=self.profile.command.unit)
return (*events, record), movement
def consume(self, frame: CaptureFrame, motion=None, *, collect_installations=False,
diagnostic=False, allow_recording=True, accepted_joints: frozenset[str] | None = None,
fixed_reference_only=False, raw_joint=False):
if raw_joint:
return self._consume_raw(frame, motion, allow_recording)
if fixed_reference_only and (motion is not None or collect_installations):
raise ValueError("fixed reference monitoring cannot acquire motion or installations")
if frame.view not in self.reference_lock.fixed_tag_by_view:
@@ -347,7 +392,7 @@ class ObservationCapture:
image_reference = self._scheduled_image_reference(frame.view, models)
view = next(v for v in self.profile.vision.views if v.name == frame.view)
tags = tuple(tag for tag in view.tags if not fixed_reference_only or tag.fixed_reference)
if not raw_joint and self.profile.acquisition.motion_model_scope == "task" and (motion is not None or self._motion_models):
if self.profile.acquisition.motion_model_scope == "task" and (motion is not None or self._motion_models):
relevant = self._observed_roles(motion)
if motion is not None:
from .observer_reference import observer_roles
@@ -479,30 +524,13 @@ class ObservationCapture:
"state_image_sync_error_ms": None if frame.feedback is None else abs(frame.skew_ns)/1e6,
"camera_matrix": frame.camera_matrix.tolist(), "input_is_rectified": True,
"tags": evidence, "is_accepted_joint_sample": False})
diagnostic_raw = ((diagnostic or raw_joint) and motion is not None and (
diagnostic_raw = (diagnostic and motion is not None and (
motion.phase == "steady" or (motion.phase in {"sweep", "joint_zero"} and allow_recording)))
if diagnostic_raw:
# Raw candidate capture has its own record type. It cannot be
# mistaken for an accepted joint angle or enter formal fitting.
events.append({"kind": "raw_observation_frame" if raw_joint else "diagnostic_observation_frame", "view": frame.view,
"image_stamp_ns": frame.stamp_ns, "task_name": motion.task_key,
"cycle": motion.cycle, "direction": motion.direction, "attempt": motion.attempt,
**segment_metadata(motion.segment_key),
"sample_phase": motion.phase, "command_unit": self.profile.command.unit,
**({"zero_joints": list(motion.zero_joints)} if motion.phase == "joint_zero" else {}),
**({"steady_index": motion.steady_index,
"steady_target": None if motion.command_index is None else motion.target[motion.command_index],
"minimum_hold_seconds": motion.minimum_hold_seconds,
"target_command_vector": list(motion.target),
"accepted_sample_window_open": bool(allow_recording)} if motion.phase == "steady" else {}),
"command_vector": None if frame.command is None else list(frame.command),
"feedback_vector": None if frame.feedback is None else list(frame.feedback),
"state_image_sync_error_ms": None if frame.feedback is None else abs(frame.skew_ns)/1e6,
"command_image_skew_ns": frame.command_skew_ns,
"command_direction_by_index": list(frame.command_directions),
**({"timing": asdict(frame.timing)} if frame.timing is not None else {}),
"camera_matrix": frame.camera_matrix.tolist(), "input_is_rectified": True,
"tags": evidence, "is_accepted_joint_sample": False})
events.append(self._observation_record(frame, motion, evidence,
allow_recording, raw_joint=False, command_unit=self.profile.command.unit))
if movement is not None or motion is None or not motion.recording or not allow_recording:
return tuple(events), movement
task = next(t for t in self.profile.motion.tasks if t.key == motion.task_key)
@@ -515,8 +543,6 @@ class ObservationCapture:
streams = [item for item in streams if item[1] in motion.zero_joints]
elif motion.observed_joints:
streams = [item for item in streams if item[1] in motion.observed_joints]
if raw_joint:
return tuple(events), movement
if diagnostic:
streams = [item for item in streams if item[1] in (accepted_joints or ())]
if not streams or (frame.feedback is None and not self.profile.vision_motion) or frame.command is None:
@@ -46,7 +46,7 @@ from ..core.domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED,
TASKWISE_STAGED, STAGED_POLICIES, raw_joint_capture)
_TERMINAL_STATES = frozenset({"PASSED", "DIAGNOSTIC_COMPLETE", "PAUSED", "ABORTED", "FAILED"})
_TERMINAL_STATES = frozenset({"PASSED", "CAPTURE_COMPLETE", "DIAGNOSTIC_COMPLETE", "PAUSED", "ABORTED", "FAILED"})
class CalibrationCoordinator:
@@ -86,7 +86,7 @@ class CalibrationCoordinator:
self.training_worker = TrainingWorker()
self.safety = SafetyPolicy(profile)
from .stall_retry import StallRetry
self.stall_retry = StallRetry() if profile.key.model.upper() == 'O30' else None
self.stall_retry = StallRetry(maximum_failures=2 if self.raw_joint else 3) if profile.key.model.upper() == 'O30' else None
self.reference_lock = self._new_reference_lock()
self.trackers = parameters.new_trackers(self.required_observation_views)
self.capture = ObservationCapture(profile, reference_lock=self.reference_lock,
@@ -119,6 +119,8 @@ class CalibrationCoordinator:
self._observation_epoch = 0
self._pause_effects_applied = False
self.segment = None
self._direction_progress = None
self._raw_capture_closed = False
self._settled_pose = None
self._segment_number = 0
self._motion = None
@@ -291,6 +293,8 @@ class CalibrationCoordinator:
with self.step_data_lock:
if self.state in {"FAILED", "ABORTED"}:
return ServiceResult(False, self.reason)
if self.state == "CAPTURE_COMPLETE":
return ServiceResult(False, "raw_capture_already_complete")
if self.state == "DIAGNOSTIC_COMPLETE":
return ServiceResult(False, "diagnostic_capture_already_complete")
if self.state == "PASSED":
@@ -302,6 +306,8 @@ class CalibrationCoordinator:
def receive_feedback(self, names, positions, stamp_ns: int, *, channel_stamps_ns=()) -> StateSample | None:
with self.step_data_lock:
if self._raw_capture_closed:
return None
return self._accept_feedback(names, positions, stamp_ns, channel_stamps_ns=channel_stamps_ns)
def receive_camera_info(self, view: str, message: CameraModelInput) -> None:
@@ -433,12 +439,15 @@ class CalibrationCoordinator:
frame = CaptureFrame(view, stamp, matrix, corners,
None if matched is None else matched[0], command, 0 if matched is None else matched[1],
next((d for t, d in reversed(direction_history) if t <= stamp), ()), observed_roles, timing,
None if command_sample is None else stamp-command_sample.stamp_ns)
None if command_sample is None else stamp-command_sample.stamp_ns,
{tag.role: dict(hamming=d.hamming, decision_margin=d.decision_margin,
border_quality=d.border_quality) for tag in self._view(view).tags
for d in observation.detections if d.tag_id == tag.tag_id})
record_motion = motion
if motion is not None and motion.phase in {"steady", "joint_zero"} and (steady_after is None or stamp < steady_after):
record_motion = None
from .image_capture import image_observation_record
raw_image = image_observation_record(self.profile, frame, record_motion)
raw_image = None if self.raw_joint else image_observation_record(self.profile, frame, record_motion)
# Pose estimation is deliberately outside the state lock. Start swaps
# capture objects; pause/abort invalidates the epoch; movement changes
# the motion identity. None of their late results may enter the session.
@@ -497,6 +506,7 @@ class CalibrationCoordinator:
return
sample_rows = [row for row in rows if row.get("kind") != "pnp_branch_event"
and (record_motion is not None
or (self.raw_joint and row.get("kind") == "raw_observation_frame")
or (self.diagnostic_capture is not None and motion is not None
and motion.phase == "steady" and motion.minimum_hold_seconds > 0
and row.get("kind") == "diagnostic_observation_frame")
@@ -633,6 +643,26 @@ class CalibrationCoordinator:
self.training_worker.close()
self._save_stage_timing()
def _handle_motion_failure(self, decision, now):
if decision.code == 'mechanical_stall' and self.stall_retry is not None and self.segment is not None:
identity = (str(self._direction_progress.identity) if self._direction_progress is not None
else self.segment.identity)
retry = self.stall_retry.failed(identity, now)
limit = self.stall_retry.maximum_failures
append_jsonl(self.raw_path, dict(kind='motion_stall_retry',
stamp_ns=self.ports.clock_ns(), motion_id=identity,
failure_count=self.stall_retry.failures, maximum_failures=limit,
delay_seconds=self.stall_retry.delay_seconds if retry else 0.,
retry_scheduled=retry, target=list(self.segment.command.target), reason=decision.details))
if retry:
self._steady_capture_after_ns = None
self._steady_rows.clear()
self.reason = f'motion_stall_retry_wait:{self.stall_retry.failures}/{limit}:delay_seconds=3'
return
self._pause(f'{decision.code}:{decision.reason}:{decision.details},consecutive_failures={limit}')
return
self._pause(f"{decision.code}:{decision.reason}:{decision.details}")
def _advance(self):
if self.state in _TERMINAL_STATES:
return
@@ -667,33 +697,27 @@ class CalibrationCoordinator:
motion_id=self.segment.identity if self.segment else "",
motion_goals=self.segment.goals if self.segment else ()))
if not decision.safe:
if (decision.code == 'mechanical_stall' and self.stall_retry is not None
and self.segment is not None):
retry = self.stall_retry.failed(self.segment.identity, now)
append_jsonl(self.raw_path, dict(kind='motion_stall_retry',
stamp_ns=self.ports.clock_ns(), motion_id=self.segment.identity,
failure_count=self.stall_retry.failures, maximum_failures=3,
delay_seconds=3.0 if retry else 0., retry_scheduled=retry,
target=list(self.segment.command.target), reason=decision.details))
if retry:
self._steady_capture_after_ns = None
self.reason = f'motion_stall_retry_wait:{self.stall_retry.failures}/3:delay_seconds=3'
return
self._pause(f'{decision.code}:{decision.reason}:{decision.details},consecutive_failures=3')
return
self._pause(f"{decision.code}:{decision.reason}:{decision.details}")
self._handle_motion_failure(decision, now)
return
if self.stall_retry is not None and self.stall_retry.waiting:
elapsed = self.stall_retry.resume(now)
if elapsed is None:
self.reason = f'motion_stall_retry_wait:{self.stall_retry.failures}/3:remaining={self.stall_retry.remaining(now):.1f}s'
self.reason = f'motion_stall_retry_wait:{self.stall_retry.failures}/{self.stall_retry.maximum_failures}:remaining={self.stall_retry.remaining(now):.1f}s'
return
self.segment.suspend_elapsed(elapsed)
if self._direction_progress is not None:
self._direction_progress.retry(now, self.latest_feedback)
self._steady_capture_after_ns = None
self._steady_rows.clear()
append_jsonl(self.raw_path, dict(kind='motion_stall_retry_resumed',
stamp_ns=self.ports.clock_ns(), motion_id=self.segment.identity,
stamp_ns=self.ports.clock_ns(), motion_id=self.stall_retry.motion_id,
attempt=self.stall_retry.failures+1, waited_seconds=elapsed))
if self._direction_progress is not None:
progress = self._direction_progress.observe(self._motion, self.segment, current,
self.latest_feedback, self.state_history[-1].stamp_ns, now)
if not progress.safe:
self._handle_motion_failure(progress, now)
return
if self.visual_motion is not None and self.segment is not None:
reason = self.visual_motion.check(self.segment, now)
if reason:
@@ -712,6 +736,7 @@ class CalibrationCoordinator:
self.capture_index.extend(interrupted)
self.execution.completed_units.update(self._resume_checkpoint.completed_units)
self._resumed_count = len(self._resume_checkpoint.completed_units)
self._resume_message = '原始证据与补采预算已恢复,继续剩余方向'
self._raw_resume_import = None
return
if self._passed_reference_import is not None:
@@ -755,6 +780,12 @@ class CalibrationCoordinator:
self._verify_resume()
return
if phase == Phase.EVALUATE:
if self._direction_progress is not None:
progress = self._direction_progress.finish()
self._direction_progress = None
if not progress.safe:
self._pause(f"{progress.code}:{progress.reason}:{progress.details}")
return
action = self.execution.action
training_decision = None
if self.execution.training_check_due():
@@ -902,11 +933,21 @@ class CalibrationCoordinator:
if self.visual_motion is not None:
self.visual_motion.begin(motion, current, now=now, version=self._segment_number)
self._flush_visual_evidence()
if self.raw_joint and motion.phase in {"sweep", "steady"} and motion.direction is not None:
unit = session.current_unit
identity = (*unit.identity, motion.attempt)
if self._direction_progress is None or self._direction_progress.identity != identity:
from .direction_progress import DirectionProgress
self._direction_progress = DirectionProgress(self.profile, unit, motion.attempt,
self.latest_feedback, now)
self.segment = MotionExecution(self.profile, motion, initial_command=current,
initial_feedback=self.latest_feedback, now=now, identity=str(self._segment_number),
visual_observer=self.visual_motion)
visual_observer=self.visual_motion, raw_scan=self.raw_joint)
shaped = self.segment.sample(now)
self._publish_command(list(shaped))
if self._direction_progress is not None:
self._direction_progress.observe(motion, self.segment, shaped,
self.latest_feedback, self.state_history[-1].stamp_ns, now)
self.segment.observe(self.latest_feedback, stamp=self.state_history[-1].stamp_ns if self.state_history else None, now=now)
if self.visual_motion is not None:
self.visual_motion.settle_record(self.segment.finished_at, now, epoch=self._observation_epoch)
@@ -920,25 +961,16 @@ class CalibrationCoordinator:
self._steady_capture_after_ns = self.ports.clock_ns()
from .scan_quality import observation_streams
task = next(t for t in self.profile.motion.tasks if t.key == motion.task_key)
steady_complete = all(len({r["image_stamp_ns"] for r in self._steady_rows
if r.get(field) == joint and r.get("view") == spec.view
and r.get("sample_phase") == "steady"}) >= self.profile.acquisition.steady_minimum_samples
for field, joint, spec in observation_streams(self.profile, task)
if not motion.observed_joints or joint in motion.observed_joints)
rows = self._steady_rows
if self.raw_joint:
from .raw_quality import coverage_records
rows = tuple(coverage_records(self.profile, session.current_unit,
motion.attempt, self._steady_rows))
steady_complete = all(len({r['image_stamp_ns'] for r in rows
if r.get(field) == joint and r['view'] == spec.view})
>= self.profile.acquisition.steady_minimum_samples
for field, joint, spec in observation_streams(self.profile, task)
if not motion.observed_joints or joint in motion.observed_joints)
# Missing images are a coverage failure after this bounded
# hold; they must not turn an acquisition visit into an
# unbounded wait for a geometry model.
if self.segment.arrived(now):
steady_complete = True
steady_complete = all(len({r["image_stamp_ns"] for r in rows
if r.get(field) == joint and r.get("view") == spec.view
and r.get("sample_phase") == "steady"}) >= self.profile.acquisition.steady_minimum_samples
for field, joint, spec in observation_streams(self.profile, task)
if not motion.observed_joints or joint in motion.observed_joints)
steady_complete = steady_complete and self.segment.minimum_hold_complete(now)
if self.chain_capture is not None:
try:
@@ -1047,6 +1079,9 @@ class CalibrationCoordinator:
return
self._pause("joint_zero_unavailable:"+unavailable)
return
# All ordinary holds share this bounded exit, even without stable
# images. Unit evaluation owns missing-data retries; motion arrival
# still requires physical feedback progress.
if (self.segment.arrived(now) and motion.phase != "joint_zero") or steady_complete:
if motion.phase in {'observer_preflight', 'observer_probe'}:
self._advance_observer_preparation(motion, now)
@@ -1075,6 +1110,8 @@ class CalibrationCoordinator:
if phase == Phase.REFERENCE_POSES and session.phase != phase:
self._save_reference()
self.segment, self._motion = None, None
if session.phase == Phase.CAPTURE_COMPLETE:
self._finish_raw_capture()
if session.phase == Phase.DIAGNOSTIC_COMPLETE:
self._finish_diagnostic_capture()
@@ -1092,6 +1129,27 @@ class CalibrationCoordinator:
self._steady_capture_after_ns = None
self._steady_rows.clear()
def _finish_raw_capture(self):
self._raw_capture_closed = True
self._observation_epoch += 1
self._direction_progress = None
self.journal_durability.close()
session = self.execution.session
try:
append_jsonl(self.raw_path, dict(kind="raw_capture_finished",
stamp_ns=self.ports.clock_ns(), returned_to_baseline=True))
sync_jsonl(self.raw_path)
from .raw_completion import save_capture_manifest
save_capture_manifest(self.raw_path, profile=self.profile,
serial_number=self.parameters.serial_number,
protected_inputs=self.parameters.protected_inputs,
completed_units=self.execution.completed_units,
incomplete_units=session.incomplete_units, retries=session._retry_by_unit)
self.reason = "raw_capture_finished_offline_only"
except Exception as error:
session.fail("raw_capture_manifest_failed", str(error))
self.reason = "raw_capture_manifest_failed:" + str(error)
def _finish_diagnostic_capture(self):
"""Save diagnostics under the state lock before exposing completion."""
from .diagnostic_report import build_diagnostic_report
@@ -1729,11 +1787,11 @@ class CalibrationCoordinator:
return self.state_history[-1]
def _publish_command(self, values):
if self.parameters.commands_enabled:
if self.parameters.commands_enabled and not self._raw_capture_closed:
self.sdk_adapter.publish_position(values)
def _write_position(self, values):
if not self.parameters.commands_enabled:
if not self.parameters.commands_enabled or self._raw_capture_closed:
return
bounded = self.sdk_adapter.validate_command(values)
self.ports.publish_position(bounded)
@@ -1755,7 +1813,11 @@ class CalibrationCoordinator:
command_vector=list(bounded), direction_by_index=list(self._command_directions),
phase=motion.phase if motion else self.execution.session.phase.value,
task_name=motion.task_key if motion else None,
cycle=motion.cycle if motion else None),), durable=False)
cycle=motion.cycle if motion else None,
segment_key=motion.segment_key if motion else '',
direction=motion.direction if motion else None,
attempt=motion.attempt if motion else None,
command_index=motion.command_index if motion else None),), durable=False)
def _publish_speed(self, speed):
indices = self.profile.command.command_index_by_joint.values()
@@ -2010,13 +2072,17 @@ class CalibrationCoordinator:
try:
if self._resume_checkpoint is None:
raise ValueError(self._resume_checkpoint_error or 'raw_checkpoint_missing')
self._resume_checkpoint.verify_reference(self.profile, self._current_fingerprint)
verification = self._resume_checkpoint.verify_reference(self.profile, self._current_fingerprint,
require_installation=False)
from .joint_resume import ResumeJournalImport
self._raw_resume_import = ResumeJournalImport(self._resume_checkpoint.import_rows())
self._resume_message = '新协议参考与安装检查通过,正在导入原始证据和补采预算'
self._resume_message = ('固定基准检查通过,活动标签差异已记录待离线复核;正在导入原始证据和补采预算'
if verification['requires_offline_review'] else
'固定基准与活动标签基准一致,正在导入原始证据和补采预算')
append_jsonl(self.raw_path, dict(kind='raw_resume_verified',
source_header=self._resume_checkpoint.header,
source_reference=self._resume_checkpoint.fixed_reference))
source_reference=self._resume_checkpoint.fixed_reference,
installation_verification=verification))
except (ValueError, KeyError, TypeError) as error:
self._pause('raw_resume_rejected:' + str(error))
return
@@ -0,0 +1,83 @@
"""Feedback watchdog for one raw scan direction, across its bounded holds.
No command/encoder scale is inferred. Only real signed encoder progress
refreshes the motion-time budget; a waypoint transition never refreshes it.
"""
import math
from .motion_execution import feedback_tolerances
from .safety import SafetyDecision
from .trajectory import build_calibration_motion_command
class DirectionProgress:
def __init__(self, profile, unit, attempt, feedback, now):
self.profile = profile
self.identity = (*unit.identity, attempt)
task = next(t for t in profile.motion.tasks if t.key == unit.task_key)
self.start = build_calibration_motion_command(task, unit.start,
profile=profile, segment_key=unit.segment_key)
end = build_calibration_motion_command(task, unit.end,
profile=profile, segment_key=unit.segment_key)
self.thresholds = [max(2., .05*(hi-lo)) for lo, hi in
zip(profile.command.minimum_values, profile.command.maximum_values)]
self.directions = {i: math.copysign(1., b-a) for i, (a, b) in
enumerate(zip(self.start, end)) if abs(b-a) > self.thresholds[i]}
self.mark = {i: direction*feedback[i] for i, direction in self.directions.items()}
self.turning = dict(self.mark)
self.advancing = set()
self.travel = {i: 0. for i in self.directions}
self.elapsed = dict(self.travel)
self.active = set()
self.now = now
self.stamp = None
self.tolerances = feedback_tolerances(profile.command.unit)
def observe(self, motion, segment, command, feedback, stamp, now):
dt = max(0., now-self.now)
self.now = now
for i in self.active:
self.elapsed[i] += dt
if stamp != self.stamp:
self.stamp = stamp
for i, direction in self.directions.items():
value = direction*feedback[i]
self.turning[i] = min(self.turning[i], value)
self.travel[i] = max(self.travel[i], value-self.turning[i])
if value >= self.mark[i]+self.tolerances.resolution:
self.mark[i] = value
self.elapsed[i] = 0.
self.advancing.add(i)
elif i not in self.advancing and value < self.mark[i]:
# Follow the initial reversal only. Once advancing, a
# rebound inside the observed range is noise, not progress.
self.mark[i] = value
self.active = {i for i in self.directions
if motion is not None and motion.phase == 'sweep'
and segment is not None and segment.phase < 1.
and abs(command[i]-self.start[i]) > self.thresholds[i]
and abs(segment.deltas[i]) > 0.}
for i, elapsed in self.elapsed.items():
if elapsed >= self.profile.acquisition.stall_timeout_seconds:
return self._stop(i, feedback[i], elapsed)
return SafetyDecision(True)
def retry(self, now, feedback):
"""One explicitly budgeted retry; retain direction and actual travel."""
self.now = now
self.active.clear()
for i, direction in self.directions.items():
self.elapsed[i] = 0.
self.mark[i] = direction*feedback[i]
def finish(self):
for i, travel in self.travel.items():
if travel+self.tolerances.resolution < self.tolerances.minimum_travel:
return self._stop(i, None, self.elapsed[i])
return SafetyDecision(True)
def _stop(self, channel, feedback, elapsed):
return SafetyDecision(False, 'mechanical_stall', '扫描方向缺少真实同向反馈推进',
f'direction={self.identity},channel={channel},feedback={feedback},'
f'timeout_seconds={self.profile.acquisition.stall_timeout_seconds:g},'
f'active_no_progress_seconds={elapsed:.6f}')
@@ -30,7 +30,8 @@ ADAPTIVE_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v12_adaptive_training"
STAGED_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v13_staged_training"
TASKWISE_STAGED_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v14_taskwise_staged_training"
TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v16_taskwise_bounded_recovery"
RAW_JOINT_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v17_raw_joint"
LEGACY_RAW_JOINT_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v17_raw_joint"
RAW_JOINT_CAPTURE_SCHEDULE_VERSION = "unified_schedule_v18_raw_corners_immediate"
SUPPORTED_CAPTURE_SCHEDULE_VERSIONS = frozenset((CAPTURE_SCHEDULE_VERSION,
STAGED_CAPTURE_SCHEDULE_VERSION, TASKWISE_STAGED_CAPTURE_SCHEDULE_VERSION,
TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION, RAW_JOINT_CAPTURE_SCHEDULE_VERSION,
@@ -311,6 +312,7 @@ class CalibrationEngine:
and session_start.get("acquisition_policy_version")
== ACQUISITION_POLICY_VERSION
and session_start.get("capture_schedule_version") in (
(capture_schedule_version(self.profile), LEGACY_RAW_JOINT_CAPTURE_SCHEDULE_VERSION) if self.profile.quality.training_policy == "raw_joint_2_plus_1" else
(capture_schedule_version(self.profile),) if (self.profile.quality.training_policy in {ADAPTIVE, *STAGED_POLICIES} or self.profile.acquisition.fixed_reference_mode == "stationary_image"
or self.profile.vision_motion or any(task.segments for task in self.profile.motion.tasks)
or any(task.parent_reference is not None for task in self.profile.motion.tasks)
@@ -71,8 +71,11 @@ class MotionCommand:
class MotionExecution:
def __init__(self, profile, command: MotionCommand, *, initial_command,
initial_feedback, now: float, identity: str, visual_observer=None):
initial_feedback, now: float, identity: str, visual_observer=None, raw_scan=False):
self.profile, self.command, self.identity = profile, command, str(identity)
from ..core.domain.capture_plan import raw_joint_capture
self.raw_scan = (raw_scan and raw_joint_capture(profile) and command.phase in {"sweep", "steady"}
and command.direction is not None and not command.feedback_targets)
self.visual = visual_observer
if profile.vision_motion and visual_observer is None:
raise ValueError("visual motion requires its independent image observer")
@@ -204,7 +207,7 @@ class MotionExecution:
@property
def goals(self):
goals = {} if self.profile.vision_motion else {
goals = {} if self.profile.vision_motion or self.raw_scan else {
i: MotionGoal(i, self._travel_origins[i], self._travel_origins[i]
+ math.copysign(self.required_feedback_travel[i], self.deltas[i]),
self.resolution, self.resolution)
@@ -231,6 +234,10 @@ class MotionExecution:
def feedback_motion_complete(self):
"""Physical completion is shared by capture and transition decisions."""
if self.raw_scan:
# Raw waypoints are sample locations, not calibrated encoder goals.
# The coordinator checks physical progress over the whole direction.
return True
if self.visual is not None:
return self.visual.motion_complete()
if not self.history:
@@ -257,6 +264,11 @@ class MotionExecution:
if (self.phase < 1 or self.finished_at is None or len(self.history) < 3
or not self.feedback_motion_complete()):
return False
if self.raw_scan and self.command.phase == "sweep":
# Every raw waypoint is followed by a bounded steady effect.
# Waiting for stability here would duplicate that effect and
# create an unbounded wait before its timeout can even start.
return True
if self.command.phase in {"steady", "joint_zero"}:
# Data sufficiency is evaluated after the direction; never wait
# indefinitely for a Tag. Missing physical motion is instead
@@ -17,10 +17,15 @@ def visible_roles(row: Mapping, tags: Mapping) -> set[str]:
return visible
for role, item in observations.items():
tag = tags.get(role)
raw = row.get("observation_protocol") == "raw_joint_images_v2"
diagnostics = item.get("candidate_diagnostics", {}) if isinstance(item, Mapping) else {}
stamp = (item.get("image_stamp_ns") if raw else
diagnostics.get("observation_stamp_ns") if isinstance(diagnostics, Mapping) else None
) if isinstance(item, Mapping) else None
if (tag is None or not isinstance(item, Mapping)
or not isinstance(item.get("candidate_diagnostics"), Mapping)
or item.get("tag_role") != role or item.get("tag_id") != tag.tag_id
or item.get("candidate_diagnostics", {}).get("observation_stamp_ns") != row.get("image_stamp_ns")):
or stamp != row.get("image_stamp_ns")
or (raw and item.get("pose_source") != "raw_corners")):
continue
try:
points = np.asarray(item.get("corners_xy"), dtype=float)
@@ -0,0 +1,119 @@
"""Seal acquisition independently from solving or publishing a calibration."""
import hashlib
import json
from pathlib import Path
from ..core.domain.capture_plan import CapturePlan, RAW_PROTOCOL
from ..storage import atomic_write_json
from .engine import CalibrationEngine
def file_digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def implementation_digest():
root = Path(__file__).resolve().parent.parent
digest = hashlib.sha256()
for path in sorted(root.rglob('*.py')):
digest.update(str(path.relative_to(root)).encode())
digest.update(path.read_bytes())
return digest.hexdigest()
def snapshot_capture_inputs(config, directory):
"""Persist the effective product and protected files before any motion."""
import yaml
directory = Path(directory)
product = yaml.safe_load(config.path.read_text())
files = {}
for field, filename in (('source_urdf', 'capture_source.urdf'),
('camera_extrinsics', 'capture_extrinsics.yaml'),
('calibration_config', 'capture_calibration.yaml'), ('tag_config', 'capture_tags.yaml')):
source = Path(getattr(config, field))
contents = source.read_bytes()
if hashlib.sha256(contents).hexdigest() != getattr(config, field+'_sha256'):
raise ValueError('capture_input_changed_before_start:' + field)
target = directory/filename
target.write_bytes(contents)
product['artifacts'][field] = str(target.resolve())
files[filename] = file_digest(target)
from ..core.urdf.patch import materialize_relative_mesh_assets
import xml.etree.ElementTree as ET
for asset in materialize_relative_mesh_assets(source=config.source_urdf, output=directory.resolve(),
urdf_root=ET.parse(config.source_urdf).getroot()):
files[str(asset.relative_to(directory.resolve()))] = file_digest(asset)
profile = directory/'capture_profile.yaml'
if file_digest(profile) != config.profile_config_sha256:
raise ValueError('capture_profile_snapshot_changed')
product.update(profile_config=str(profile.resolve()), profile_config_sha256=config.profile_config_sha256,
output_root=str(config.output_root), resume_mode=config.resume_mode)
files[profile.name] = file_digest(profile)
for view, settings in config.cameras.items():
target = directory/f'capture_intrinsics_{view}.yaml'
target.write_bytes(Path(settings['camera_info']).read_bytes())
product['cameras'][view]['camera_info'] = str(target.resolve())
files[target.name] = file_digest(target)
target = directory/'capture_product.yaml'
target.write_text(yaml.safe_dump(product, sort_keys=False, allow_unicode=True))
files[target.name] = file_digest(target)
atomic_write_json(directory/'capture_inputs.json', dict(files=files, original_product=str(config.path)))
def _sealed_path(directory, name):
relative = Path(name)
path = (directory/relative).resolve()
if relative.is_absolute() or '..' in relative.parts or not path.is_relative_to(directory.resolve()):
raise ValueError('raw_capture_invalid_sealed_path:' + name)
return path
def save_capture_manifest(raw_path, *, profile, serial_number, protected_inputs,
completed_units, incomplete_units, retries):
directory = raw_path.parent
files = {raw_path, *directory.glob(raw_path.stem + '.*'),
*directory.glob('imported_evidence_*.zlib'), *directory.glob('capture_*.yaml'),
*directory.glob('capture_source.urdf'), *directory.glob('capture_inputs.json')}
snapshot = directory/'capture_inputs.json'
if snapshot.exists():
files.update(_sealed_path(directory, name) for name in json.loads(snapshot.read_text())['files'])
units = CalibrationEngine(profile).scan_units()
manifest = dict(protocol=RAW_PROTOCOL, capture_finished=True, returned_to_baseline=True,
raw_capture_complete=(len(completed_units) == len(units) and not incomplete_units),
accuracy_passed=False, publication_allowed=False, automatic_geometry_recapture=False,
serial_number=serial_number, capture_plan=CapturePlan.from_profile(profile).as_dict(),
protected_inputs=dict(protected_inputs), implementation_sha256=implementation_digest(),
units=[dict(identity=list(unit.identity), attempts=1+retries.get(unit.identity, 0),
passed=unit.identity in completed_units,
failures=list(incomplete_units.get(unit.identity, {}).get('failures', ()))) for unit in units],
files={str(p.resolve().relative_to(directory.resolve())): file_digest(p)
for p in sorted(files) if p.is_file()})
atomic_write_json(directory / 'raw_capture_manifest.json', manifest)
return manifest
def verify_capture_manifest(raw_path):
raw_path = Path(raw_path)
manifest = json.loads((raw_path.parent / 'raw_capture_manifest.json').read_text())
if (manifest.get('protocol') != RAW_PROTOCOL or manifest.get('capture_finished') is not True
or manifest.get('returned_to_baseline') is not True
or raw_path.name not in manifest.get('files', {})):
raise ValueError('raw_capture_manifest_invalid')
for name, digest in manifest['files'].items():
if file_digest(_sealed_path(raw_path.parent, name)) != digest:
raise ValueError('raw_capture_sealed_file_changed:' + name)
return manifest
def finish_capture(session):
"""Report sealed acquisition after shutdown; never import a solver."""
session = Path(session)
manifest = verify_capture_manifest(session/'raw_samples.jsonl')
result = dict(capture_finished=True, raw_capture_complete=manifest['raw_capture_complete'],
control_closed=True, fit_started=False, accuracy_passed=False, publication_allowed=False,
automatic_recapture=False, raw_samples=str(session/'raw_samples.jsonl'))
atomic_write_json(session/'capture_result.json', result)
print(f"采集结束,控制栈已关闭;数据完整:{result['raw_capture_complete']}。"
f"原始数据:{result['raw_samples']};请使用 --offline-raw 单独拟合。", flush=True)
return 0 if result['raw_capture_complete'] else 3
@@ -2,7 +2,7 @@
from collections import defaultdict
from dataclasses import dataclass
from ..core.domain.capture_plan import CapturePlan, raw_joint_capture, evidence_digest
from ..core.domain.capture_plan import CapturePlan, RAW_PROTOCOL, raw_joint_capture, evidence_digest
from ..core.domain.motion_path import record_scan_identity
from ..core.geometry.frozen_evidence import freeze_evidence
from .engine import CalibrationEngine
@@ -16,15 +16,21 @@ class RawCalibrationEvidence:
completed_units: tuple
sha256: str
missing_units: tuple = ()
capture_plan: dict | None = None
reassessed_units: tuple = ()
def partition_raw_capture(profile, records, *, require_complete=True):
def partition_raw_capture(profile, records, *, require_complete=True, reassess_quality=False):
"""Recompute image coverage; a persisted passed flag is never sufficient."""
if not raw_joint_capture(profile):
raise ValueError('raw_capture_protocol_required')
headers = [r for r in records if r.get('kind') == 'session_start']
if len(headers) != 1 or headers[0].get('capture_plan') != CapturePlan.from_profile(profile).as_dict():
plans = [CapturePlan.from_profile(profile).as_dict(legacy_raw=legacy) for legacy in (False, True)]
if len(headers) != 1 or headers[0].get('capture_plan') not in plans:
raise ValueError('raw_capture_plan_mismatch')
plan = headers[0]['capture_plan']
protocol = plan['observation_protocol']['version']
reassess_quality = reassess_quality and protocol == RAW_PROTOCOL
units = {u.identity: u for u in CalibrationEngine(profile).scan_units()}
completions, groups, identities = {}, defaultdict(list), set()
latest_attempts = {}
@@ -48,6 +54,8 @@ def partition_raw_capture(profile, records, *, require_complete=True):
raise ValueError('raw_capture_completion_order_invalid')
completions[unit] = row
continue
if row.get('observation_protocol', 'raw_joint_images_v1') != protocol:
raise ValueError('raw_capture_mixed_observation_protocol')
identity = row.get('view'), row.get('image_stamp_ns')
if (identity in identities or type(identity[1]) is not int or identity[1] <= 0
or identity[0] not in profile.vision.view_names):
@@ -56,10 +64,11 @@ def partition_raw_capture(profile, records, *, require_complete=True):
if row.get('is_accepted_joint_sample') is not False or row.get('input_is_rectified') is not True:
raise ValueError('raw_capture_image_contract_changed')
groups[unit, attempt].append(row)
accepted, selected, failures, spans = [], [], {}, {}
accepted, selected, failures, spans, reassessed = [], [], {}, {}, []
for identity, unit in units.items():
completion = completions.get(identity)
if (completion is None or completion.get('passed') is not True
if (completion is None or (completion.get('passed') is not True and not reassess_quality)
or 'interrupted_attempt' in completion.get('failures', ())
or completion['attempt'] != latest_attempts[identity]):
failures[str(identity)] = ('scan_incomplete',)
continue
@@ -68,6 +77,11 @@ def partition_raw_capture(profile, records, *, require_complete=True):
if not quality.passed:
failures[str(identity)] = quality.failures
continue
if completion.get('passed') is not True:
reassessed.append(dict(unit=list(identity), attempt=completion['attempt'],
recorded_failures=list(completion.get('failures', ())),
policy='sent_command_endpoints_and_declared_segment_feedback_density',
recomputed_metrics=dict(quality.metrics)))
accepted.append(identity)
selected.extend(rows)
if failures and require_complete:
@@ -77,8 +91,8 @@ def partition_raw_capture(profile, records, *, require_complete=True):
training = tuple(r for r in frozen if r['cycle'] in (0, 1))
validation = tuple(r for r in frozen if r['cycle'] == profile.quality.holdout_cycle)
return RawCalibrationEvidence(training, validation, tuple(accepted), evidence_digest({
'plan': CapturePlan.from_profile(profile).sha256, 'rows': frozen}),
tuple(dict(unit=key, failures=list(value)) for key, value in failures.items()))
'plan': evidence_digest(plan), 'rows': frozen}),
tuple(dict(unit=key, failures=list(value)) for key, value in failures.items()), plan, tuple(reassessed))
def fitting_frames(profile, rows):
@@ -57,6 +57,11 @@ def coverage_records(profile, unit, attempt, records):
def evaluate_raw_unit(profile, unit, attempt, records, *, first_cycle_spans):
from ..core.domain.capture_plan import RAW_PROTOCOL
records = tuple(records)
current_protocol = any(r.get('observation_protocol') == RAW_PROTOCOL for r in records)
return evaluate_capture_unit(profile, unit, attempt,
tuple(coverage_records(profile, unit, attempt, records)),
first_cycle_spans=first_cycle_spans)
first_cycle_spans=first_cycle_spans,
include_steady_endpoints=current_protocol,
scale_segment_feedback_coverage=current_protocol)
@@ -0,0 +1,143 @@
"""Explicit, offline restart boundary after a moving tag is remounted.
The source journal remains untouched. Only an already completed prefix can be
reused, and every old observation of the remounted tag is excluded. This is a
draft workflow: unseen baseline installations cannot certify a passed release.
"""
import json
import os
from pathlib import Path
from ..core.domain.motion_path import record_scan_identity
from ..storage import append_jsonl_many, atomic_write_json, sync_jsonl
from .engine import CalibrationEngine
from .raw_completion import file_digest
from .raw_resume import RawCheckpoint
from .scan_quality import observation_streams
REMOUNT_RECORD = 'raw_tag_remount_checkpoint'
def remount_prefix(profile, checkpoint, tag_ids, model):
"""Keep the finite schedule prefix, including its original retry budgets."""
requested = frozenset(tag_ids)
tags = [tag for view in profile.vision.views for tag in view.tags
if tag.tag_id in requested]
if (not requested or {tag.tag_id for tag in tags} != requested
or any(tag.fixed_reference for tag in tags)):
raise ValueError('remount_requires_known_moving_tags')
from ..profiles.observations import compile_tag_links
links = compile_tag_links(profile, model)
def root(role):
link = links[role]
if link == model.root_link:
return None
return next((joint.name for joint in model._chain(model.parent_joint_by_child[link])
if joint.kind != 'fixed'), None)
roots = {root(tag.role) for tag in tags}
if None in roots:
raise ValueError('remount_requires_a_movable_finger_branch')
# The affected finger is recaptured as one installation. Incidental old
# views of its other tags must not bind the new chain to an old rest pose.
roles = {role for role in links if root(role) in roots}
declarations = [(i, row) for i, row in enumerate(checkpoint.rows) if row.get('kind') == REMOUNT_RECORD]
previous = None
if declarations:
index, previous = declarations[-1]
if (len(declarations) != 1 or set(previous['remounted_tag_ids']) != requested
or not set(previous['excluded_old_roles']) < roles
or any(row.get('kind') in {'raw_observation_frame', 'scan_unit_complete'}
for row in checkpoint.rows[index+1:])):
raise ValueError('remount_already_declared_use_normal_resume')
# A prepared checkpoint can narrow its retained auxiliary evidence
# before scanning starts. No attempted direction may be rewound.
tasks = {task.key for task in profile.motion.tasks
if any(roles & {spec.parent_role, spec.child_role}
for _, _, spec in observation_streams(profile, task))}
units = CalibrationEngine(profile).scan_units()
boundary = next((i for i, unit in enumerate(units) if unit.task_key in tasks), None)
if boundary is None or boundary == 0:
raise ValueError('remount_has_no_unaffected_prefix')
prefix = {unit.identity for unit in units[:boundary]}
if checkpoint.completed_units != prefix:
# Do not silently discard good work after the boundary, or rewind a
# failed unrelated task and thereby grant it more attempts.
raise ValueError('remount_requires_exact_completed_prefix')
completions = [i for i, row in enumerate(checkpoint.rows)
if row.get('kind') == 'scan_unit_complete' and record_scan_identity(row) in prefix]
end = max(completions) + 1
retained, excluded = [], previous['excluded_old_tag_observations'] if previous else 0
for row in checkpoint.rows[:end]:
if row.get('kind') == 'raw_observation_frame':
old_tags = row.get('tags', {})
removed = roles & old_tags.keys()
if removed:
excluded += len(removed)
row = {**row, 'tags': {key: value for key, value in old_tags.items() if key not in roles},
'excluded_remounted_tag_roles': sorted(removed | set(row.get('excluded_remounted_tag_roles', ())))}
elif row.get('kind') == 'session_start':
row = {**row, 'resume_checkpoint_requested': True, 'derived_remount_checkpoint': True}
retained.append(row)
report = dict(kind=REMOUNT_RECORD, version=1, remounted_tag_ids=sorted(requested),
excluded_old_roles=sorted(roles), affected_finger_roots=sorted(roots),
retained_units=[list(unit.identity) for unit in units[:boundary]],
restart_unit=list(units[boundary].identity), remaining_units=len(units)-boundary,
excluded_old_tag_observations=excluded, new_installation_attempt_limit=2,
replaced_attempts=[{key: row[key] for key in ('task_name', 'cycle', 'direction', 'attempt', 'passed', 'failures')
if key in row} for row in checkpoint.rows[end:] if row.get('kind') == 'scan_unit_complete'],
installation_verification='available_baseline_corners_and_fixed_reference',
publication_allowed=False)
if previous:
report['replaced_attempts'] = previous['replaced_attempts']
report['refined_unstarted_checkpoint'] = True
return tuple(retained), report
def prepare_remount_checkpoint(config, source, output, tag_ids):
"""Prepare a reviewable checkpoint without opening ROS or an SDK."""
from .artifacts.capture_validation import validate_capture_provenance
from .runner_support import protected_inputs
source, output = Path(source).resolve(), Path(output).resolve()
source = source/'raw_samples.jsonl' if source.is_dir() else source
if output == source.parent or output.exists():
raise ValueError('remount_output_must_be_new_directory')
receipt = source.parent/'remount_successor.json'
if receipt.exists():
raise ValueError('remount_already_prepared_use_normal_resume:' + str(receipt))
profile = config.calibration_contract.typed_profile
checkpoint = RawCheckpoint.load(profile, CalibrationEngine(profile), source, config.serial_number)
validate_capture_provenance(profile, config.serial_number, protected_inputs(config), checkpoint.rows,
allow_installation_draft=True)
from ..core.urdf.kinematics import UrdfKinematicModel
rows, report = remount_prefix(profile, checkpoint, tag_ids, UrdfKinematicModel(config.source_urdf))
files = sorted({source, *source.parent.glob(source.stem+'.*'),
*source.parent.glob('imported_evidence_*.zlib'), source.parent/'capture_profile.yaml'})
report['source_files'] = {str(path): file_digest(path) for path in files if path.is_file()}
report['source_session'] = str(source.parent)
output.mkdir(parents=True)
path = output/'raw_samples.jsonl'
append_jsonl_many(path, (*rows, report), durable=True)
sync_jsonl(path)
snapshot = source.parent/'capture_profile.yaml'
if snapshot.is_file():
(output/snapshot.name).write_bytes(snapshot.read_bytes())
# Recompute quality after filtering; the derived checkpoint must still
# restore exactly the same good prefix before it can be offered to motion.
verified = RawCheckpoint.load(profile, CalibrationEngine(profile), path, config.serial_number)
if verified.completed_units != checkpoint.completed_units:
raise ValueError('remount_changed_unaffected_quality')
for name, digest in report['source_files'].items():
if file_digest(name) != digest:
raise ValueError('remount_source_changed_during_preparation')
report['checkpoint_files'] = {p.name: file_digest(p) for p in output.iterdir() if p.is_file()}
atomic_write_json(output/'remount_checkpoint.json', report)
# Exclusive receipt prevents repeated invocation on this old installation
# from resetting the new installation's attempts. Ordinary restarts resume
# the newest journal and keep the existing attempt counters.
with receipt.open('x', encoding='utf-8') as stream:
json.dump(dict(checkpoint=str(output), tag_ids=sorted(set(tag_ids))), stream, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
return report
@@ -64,13 +64,22 @@ class RawCheckpoint:
or headers[0].get('serial_number') != serial_number
or not engine.resume_compatible(headers[0])):
raise ValueError('raw_resume_identity_or_protocol_changed')
from ..core.domain.capture_plan import CapturePlan
if headers[0].get('capture_plan') != CapturePlan.from_profile(profile).as_dict():
raise ValueError('raw_resume_identity_or_protocol_changed')
evidence = partition_raw_capture(profile, rows, require_complete=False)
checkpoint = cls(headers[0], references[0], rows, frozenset(evidence.completed_units))
# Validate order and retry budgets before any live motor command.
checkpoint.restore(CalibrationSession(profile))
return checkpoint
def verify_reference(self, profile, current):
def verify_reference(self, profile, current, *, require_installation=True):
"""Check fixed references; audit moving baselines separately.
Equal SDK commands do not prove equal physical poses after a restart.
Raw acquisition records that ambiguity without authorizing a measured
zero or a shared tag mount. Offline release keeps the strict default.
"""
previous = {**self.fixed_reference,
'acquisition_policy_version': current['acquisition_policy_version']}
checker = ResumeVerifier(required_fixed_views=profile.vision.view_names,
@@ -85,19 +94,40 @@ class RawCheckpoint:
raise ValueError('raw_resume_reference_changed:' + str(decision.incompatible_fields))
old = self.fixed_reference.get('raw_installation_reference', {})
new = current.get('raw_installation_reference', {})
fixed_roles = {f'{view.name}:{tag.role}' for view in profile.vision.views
for tag in view.tags if tag.fixed_reference}
roles = {f"{r['view']}:{role}" for r in self.rows
if r.get('kind') == 'raw_observation_frame'
for role in r.get('tags', {})}
for role in r.get('tags', {})} - fixed_roles
from .raw_remount import REMOUNT_RECORD
draft_remount = any(r.get('kind') == REMOUNT_RECORD for r in self.rows)
checked, unavailable, changed, diagnostics = [], [], [], {}
for role in sorted(roles):
if role not in old or role not in new:
if draft_remount or not require_installation:
unavailable.append(role)
continue
raise ValueError('raw_resume_installation_observation_missing:' + role)
drift = np.linalg.norm(np.asarray(old[role]['corners_xy'])
-np.asarray(new[role]['corners_xy']), axis=-1)
feedback = np.abs(np.asarray(old[role]['feedback'])-np.asarray(new[role]['feedback']))
if (not np.all(np.isfinite(drift)) or np.max(drift)
> profile.acquisition.fixed_reference_maximum_drift_px
or not np.all(np.isfinite(feedback)) or np.max(feedback) > 10):
raise ValueError('raw_resume_installation_changed:' + role)
if not np.all(np.isfinite(drift)) or not np.all(np.isfinite(feedback)):
raise ValueError('raw_resume_installation_invalid:' + role)
diagnostics[role] = dict(maximum_corner_difference_px=float(np.max(drift)),
maximum_feedback_difference=float(np.max(feedback)))
if (np.max(drift) > profile.acquisition.fixed_reference_maximum_drift_px
or np.max(feedback) > 10):
if require_installation:
raise ValueError('raw_resume_installation_changed:' + role)
changed.append(role)
continue
checked.append(role)
verified = not (changed or unavailable)
return dict(checked_roles=checked, unavailable_roles=unavailable,
changed_roles=changed, baseline_diagnostics=diagnostics,
installation_verified=verified, requires_offline_review=not verified,
policy='moving_baseline_audit_v1',
draft_only=draft_remount or not verified, fixed_reference_verified=True)
def restore(self, session):
"""Replay journal transitions, including failed and interrupted attempts."""
@@ -138,4 +168,5 @@ class RawCheckpoint:
def import_rows(self):
return tuple(r for r in self.rows if r.get('kind') not in {
'session_start', 'fixed_base_reference_locked', 'rectified_camera_model'})
'session_start', 'fixed_base_reference_locked', 'rectified_camera_model',
'raw_capture_finished'})
@@ -11,6 +11,9 @@ def reason_zh(
) -> tuple[str, str, str]:
"""Map a common runtime reason to a stable code, explanation and action."""
reason = str(status.get("reason", "unknown"))
if 'o30_sdk_identity_mismatch' in reason or 'o30_sdk_device_changed' in reason:
return ('DEVICE-IDENTITY-203', 'SDK 报告的设备身份与当前 O30 配置不一致。',
'核对诊断中保存的型号、左右手、设备 UID 和通道名称,以及 SDK 信息话题是否存在其他发布者。')
if 'shared_pose_handoff_' in reason:
return ('OBS-HANDOFF-120', f'共享 Tag 的任务交接证据不完整或已发生变化:{reason}',
'核对上游回基准的完整原始图像窗口、反馈及时间顺序;交接不能借用验证轮、跨会话旧图或挑选低误差帧。原零位和已通过数据保持冻结。')
@@ -170,6 +173,14 @@ def reason_zh(
if reason.startswith("mechanical_stall:"):
timeout = re.search(r"(?:[:,])timeout_seconds=([0-9]+(?:\.[0-9]+)?)(?:,|$)", reason)
duration = f" {timeout.group(1)} 秒" if timeout else "两秒"
if 'consecutive_failures=2' in reason:
return ('MOTION-STALL-303',
f'停滞后已等待 3 秒并重试一次,仍未获得有效反馈推进(预算{duration}),已停止。',
'本次硬件重试已用尽;检查执行器、反馈和机械阻挡。')
if 'active_no_progress_seconds=' in reason:
return ('MOTION-STALL-303',
f'当前扫描方向缺少有效反馈推进,运动时间预算为{duration}(不计计划内静止采样),已保持当前位置。',
'检查机械阻挡、执行器和反馈;硬件重试预算由采集记录独立保存。')
if 'consecutive_failures=3' in reason:
return ('MOTION-STALL-303',
f'同一运动连续 3 次失败,每次反馈{duration}未推进;两次重发前均等待 3 秒,已停止。',
@@ -23,7 +23,7 @@ from ..storage import atomic_write_json
from .status import normalize_status, render_status_zh
TERMINAL_STATES = frozenset({"COMPLETE", "DIAGNOSTIC_COMPLETE", "PAUSED", "ABORTED", "FAILED"})
TERMINAL_STATES = frozenset({"COMPLETE", "CAPTURE_COMPLETE", "DIAGNOSTIC_COMPLETE", "PAUSED", "ABORTED", "FAILED"})
def drive_session(
@@ -154,6 +154,15 @@ def run_online(
from dataclasses import replace
config = replace(config, profile_config=effective_path,
profile_config_sha256=effective_digest)
if config.profile_config is not None:
captured_profile = Path(config.profile_config).read_bytes()
if hashlib.sha256(captured_profile).hexdigest() != config.profile_config_sha256:
raise ValueError('capture_profile_changed_before_start')
(session/'capture_profile.yaml').write_bytes(captured_profile)
from ..core.domain.capture_plan import raw_joint_capture
if raw_joint_capture(profile):
from .raw_completion import snapshot_capture_inputs
snapshot_capture_inputs(config, session)
from ..profiles.validator import validate_executable_profile, require_release_geometry
contract_report = validate_executable_profile(profile, config.source_urdf)
atomic_write_json(session/"measurement_contract.json", contract_report)
@@ -171,6 +180,10 @@ def run_online(
atomic_write_json(session/"resume_selection.json", dict(
selected_session=resume.name if resume else None,
preparation_contract=preparation, rejected_candidates=rejected_checkpoints))
if resume is not None:
installation_note = resume/'operator_installation_clarification.json'
if installation_note.is_file():
(session/installation_note.name).write_bytes(installation_note.read_bytes())
from ..core.domain.capture_plan import raw_joint_capture
if raw_joint_capture(profile) and allow_resume and rejected_checkpoints and resume is None:
raise ValueError("raw_resume_incompatible:no_automatic_recapture; 首次使用新协议请明确指定 --no-resume")
@@ -259,6 +272,18 @@ def run_online(
startup_timeout=startup_timeout,
)
atomic_write_json(session / "node_status.json", status)
if normalize_status(status)["state"] == "CAPTURE_COMPLETE":
capture_complete = True
stop_stack(process)
process = None
monitor.destroy_node()
monitor = None
if owns_context and rclpy.ok():
rclpy.shutdown()
log_stream.flush()
os.fsync(log_stream.fileno())
from .raw_completion import finish_capture
return finish_capture(session)
if diagnostic is not None:
if normalize_status(status)["state"] != "DIAGNOSTIC_COMPLETE":
_write_failure(config, session, status)
@@ -305,7 +330,7 @@ def run_online(
deadline = time.monotonic() + 1.0
while rclpy.ok() and not aborted.done() and time.monotonic() < deadline:
rclpy.spin_once(monitor, timeout_sec=0.1)
message = "诊断采集已完成;已取消后续分析" if capture_complete else "已请求中止"
message = "采集已完成;已取消后续分析" if capture_complete else "已请求中止"
print(f"{message};本次数据保留在 {session}。", flush=True)
return 130
except Exception as error:
@@ -335,6 +360,10 @@ def main(args: list[str] | None = None) -> None:
parser.add_argument("--record-bag", action="store_true")
parser.add_argument("--commands-disabled", action="store_true")
parser.add_argument("--no-resume", action="store_true")
parser.add_argument("--prepare-remount-from", type=Path,
help="仅离线:保留未受影响的已完成前缀,为调整标签后的低精度草稿采集建立新断点")
parser.add_argument("--remounted-tag-id", type=int, action="append", default=[],
help="与 --prepare-remount-from 一起显式声明本次重新安装的移动标签,可重复指定")
parser.add_argument("--training-policy", choices=("fixed", "adaptive_2_to_3", "staged_2_to_3",
"taskwise_staged_2_to_3", "taskwise_2_plus_1", "raw_joint_2_plus_1"), default=None,
help="taskwise_2_plus_1:每任务连续两轮训练加一轮验证、不补轮;其他策略保留原语义,默认沿用配置")
@@ -355,11 +384,34 @@ def main(args: list[str] | None = None) -> None:
help="当前安装下的多姿态同步标定板角点 JSON;新 O30 协议启动前重新计算光学核验")
parser.add_argument("--offline-output", default="")
parser.add_argument("--publish-offline", action="store_true")
parser.add_argument("--offline-draft", action="store_true",
help="仅离线:用已完成任务生成未认证的零位/行程修正 URDF,报告误差而不要求精度通过")
parser.add_argument("--capture-profile", type=Path,
help="草稿读取旧配置数据时提供原 Profile;仅允许离线修正标签安装连杆")
parser.add_argument("--draft-initialization-from", type=Path,
help="仅离线草稿:使用同一批数据的已有训练草稿作为初值,全部参数仍重新联合拟合")
parser.add_argument("--draft-upright-splay-zero", action="store_true",
help="仅离线草稿:按用户确认,将四指基准指令下的侧摆零位定义为 CAD 竖直;不作为独立实测零位")
parser.add_argument("--preparation-policy", nargs='?', const='occlusion_aware_trajectory_v3',
choices=("occlusion_aware_witness_v1", "occlusion_aware_witness_v2",
"occlusion_aware_trajectory_v3"),
help="O30 staged 准备策略;省略取值使用 v3 整段轨迹消歧和有界自动观察;v1/v2 仅保留历史兼容")
selected = parser.parse_args(args)
if bool(selected.prepare_remount_from) != bool(selected.remounted_tag_id):
parser.error("--prepare-remount-from and --remounted-tag-id must be specified together")
if selected.prepare_remount_from and (selected.offline_raw or selected.offline_draft
or selected.publish_offline or selected.commands_disabled or selected.no_resume
or selected.validate_only or selected.diagnostic_capture or selected.diagnostic_thumb
or selected.diagnostic_yaw_hold):
parser.error("--prepare-remount-from is a separate offline operation")
if selected.offline_draft and (not selected.offline_raw or selected.publish_offline):
parser.error("--offline-draft requires --offline-raw and cannot publish a passed release")
if selected.capture_profile and not selected.offline_draft:
parser.error("--capture-profile requires --offline-draft")
if selected.draft_initialization_from and not selected.offline_draft:
parser.error("--draft-initialization-from requires --offline-draft")
if selected.draft_upright_splay_zero and not selected.offline_draft:
parser.error("--draft-upright-splay-zero requires --offline-draft")
if selected.startup_timeout_seconds is not None and (
not math.isfinite(selected.startup_timeout_seconds) or selected.startup_timeout_seconds <= 0
):
@@ -372,7 +424,8 @@ def main(args: list[str] | None = None) -> None:
parser.error(f"{diagnostic_flag} cannot be combined with --offline-raw, --offline-output, --publish-offline or --commands-disabled")
config = load_product_config(
selected.config or str(default_product_config_path()), workspace=selected.workspace,
check_can=not (selected.validate_only or selected.offline_raw or selected.commands_disabled),
check_can=not (selected.validate_only or selected.offline_raw or selected.commands_disabled
or selected.prepare_remount_from),
)
if selected.training_policy is not None:
from dataclasses import replace
@@ -413,12 +466,32 @@ def main(args: list[str] | None = None) -> None:
report = validate_executable_profile(config.calibration_contract.typed_profile, config.source_urdf)
from ..core.domain.capture_plan import CapturePlan
report["capture_plan"] = CapturePlan.from_profile(config.calibration_contract.typed_profile).as_dict()
if config.profile_key.model == 'O30':
from ..core.fitting.raw_joint_draft import draft_targets
report['draft_correction_targets'] = draft_targets(config.calibration_contract.typed_profile)
if diagnostic is not None:
report["diagnostic_capture"] = {**diagnostic.as_dict(), "publication_allowed": False}
print(json.dumps(report, ensure_ascii=False, indent=2))
print(f"配置与受保护输入一致:{config.profile_key.profile_id}。此检查不代表 URDF 精度或硬件验收通过。")
return
if selected.prepare_remount_from:
from datetime import datetime
from .raw_remount import prepare_remount_checkpoint
output = (Path(selected.offline_output) if selected.offline_output else
config.session_root/(datetime.now().strftime('%Y%m%d_%H%M%S')+'_remount'))
report = prepare_remount_checkpoint(config, selected.prepare_remount_from, output,
selected.remounted_tag_id)
print(f"标签调整后的草稿断点已保存:{output};复用 {len(report['retained_units'])} 个方向,"
f"剩余 {report['remaining_units']} 个方向。原始记录未修改;后续使用普通断点恢复。")
return
if selected.offline_raw:
if selected.offline_draft:
from .artifacts.raw_joint_draft import replay_draft
replay_draft(config, Path(selected.offline_raw),
output=Path(selected.offline_output) if selected.offline_output else None,
capture_profile=selected.capture_profile, initialization_from=selected.draft_initialization_from,
upright_splay_zero=selected.draft_upright_splay_zero)
return
from .artifacts.replay import replay_capture
replay_capture(config, Path(selected.offline_raw),
output=Path(selected.offline_output) if selected.offline_output else None,
@@ -75,7 +75,7 @@ class SafetyPolicy:
)
health = sample.health
if not health.connected:
return self._stop("sdk_disconnected", "SDK 通信已失联")
return self._stop("sdk_disconnected", "SDK 连接或身份检查未通过", health.diagnostic)
if not health.position_mode:
return self._stop("wrong_control_mode", "机械手不在位置控制模式")
if health.active_faults:
@@ -17,6 +17,8 @@ from ..core.domain.profile import observation_streams as observation_streams
def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
records: Sequence[Mapping], *, first_cycle_spans: dict[str, float],
include_steady: bool = True,
include_steady_endpoints: bool = False,
scale_segment_feedback_coverage: bool = False,
) -> SweepQuality:
"""No rates, coupling, endpoint tracking error or Tag percentages stop a scan.
@@ -66,7 +68,8 @@ def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
samples, command_samples, feedback_samples = {}, {}, {}
channel = channel_for_joint(profile, name)
for row in records:
if row.get("sample_phase", "sweep") != "sweep":
sample_phase = row.get("sample_phase", "sweep")
if sample_phase != "sweep" and not (include_steady_endpoints and sample_phase == "steady"):
continue
if (row.get(field) != name or row.get("view") != spec.view
or row.get("task_name") != unit.task_key
@@ -82,7 +85,8 @@ def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
profile.command.minimum_feedback_values[channel] <= float(feedback)
<= profile.command.maximum_feedback_values[channel]):
continue
feedback_samples[(spec.view, stamp)] = float(feedback)
if sample_phase == "sweep":
feedback_samples[(spec.view, stamp)] = float(feedback)
value = row.get(f"{'command' if command_coverage else 'feedback'}_{native}")
if value is None or not math.isfinite(float(value)):
continue
@@ -95,6 +99,10 @@ def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
command_progress = (float(commanded) - lower) / span
if 0 <= command_progress <= 1:
command_samples[(spec.view, stamp)] = command_progress
if sample_phase == "steady":
# A synchronized held image proves the actual sent endpoint
# command. It does not become a moving coverage sample.
continue
progress = (float(value) - lower) / span
# Physical encoder bounds are distinct from the requested sweep.
# Samples outside it are not clipped into fake endpoint bins.
@@ -117,7 +125,10 @@ def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
if command_coverage:
metrics[identity] = _command_coverage(metrics[identity], native)
if not profile.vision_motion:
motion_quality, motion_metrics = _feedback_motion_coverage(profile, feedback_samples.values())
fraction = (span/(profile.command.maximum_values[channel]-profile.command.minimum_values[channel])
if segment is not None and scale_segment_feedback_coverage else 1.)
motion_quality, motion_metrics = _feedback_motion_coverage(profile, feedback_samples.values(),
command_span_fraction=fraction)
failures.extend(f"{identity}:feedback_motion_{reason}" for reason in motion_quality.failures)
metrics[identity]["feedback_motion"] = motion_metrics
references[identity] = float(decision.metrics["feedback_span"])
@@ -129,7 +140,7 @@ def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int,
return SweepQuality(not failures, tuple(failures), tuple(warnings), metrics)
def _feedback_motion_coverage(profile, values):
def _feedback_motion_coverage(profile, values, *, command_span_fraction=1.):
"""Check actual encoder travel without interpreting commands as encoders.
Runtime checks direction, progress, and stability. The recorded direction
@@ -142,15 +153,24 @@ def _feedback_motion_coverage(profile, values):
span = upper-lower if values else 0.
normalized = tuple((value-lower)/span if span > 0 else 0. for value in values)
policy = profile.acquisition
if not 0 < command_span_fraction <= 1:
raise ValueError('feedback_coverage_invalid_declared_segment_span')
# A short declared segment cannot resolve as many native encoder bins as
# a full sweep. Scaling its sample-density check avoids amplifying byte
# quantization when the observed travel is normalized back to 256 bins.
minimum_bins = max(2, math.ceil(policy.minimum_bins*command_span_fraction))
maximum_gap_fraction = min(1., policy.maximum_unobserved_fraction/command_span_fraction)
quality = evaluate_observability(normalized, minimum_span=1.,
minimum_valid_samples=policy.minimum_valid_samples, minimum_bins=policy.minimum_bins,
maximum_unobserved_fraction=policy.maximum_unobserved_fraction,
minimum_valid_samples=policy.minimum_valid_samples, minimum_bins=minimum_bins,
maximum_unobserved_fraction=maximum_gap_fraction,
total_frames=len(values), joint_frame_rate=1., feedback_hz=0., detection_rate=1.)
if span < feedback_tolerances(profile.command.unit).minimum_travel:
quality = SweepQuality(False, (*quality.failures, "below_motion_resolution"),
quality.warnings, quality.metrics)
return quality, dict(observed_minimum=lower, observed_maximum=upper, observed_span=span,
unit=profile.command.unit, normalized_to="observed_feedback_interval",
declared_command_span_fraction=command_span_fraction, required_bins=minimum_bins,
allowed_maximum_bin_gap=quality.metrics['allowed_maximum_bin_gap'],
valid_frames=quality.metrics["valid_frames"], bins=quality.metrics["feedback_bins"],
maximum_bin_gap=quality.metrics["maximum_bin_gap"])
@@ -38,6 +38,7 @@ class CalibrationPhase(str, Enum):
PUBLISH = "PUBLISH"
COMPLETE = "COMPLETE"
DIAGNOSTIC_COMPLETE = "DIAGNOSTIC_COMPLETE"
CAPTURE_COMPLETE = "CAPTURE_COMPLETE"
PAUSED = "PAUSED"
ABORTED = "ABORTED"
FAILED = "FAILED"
@@ -217,7 +218,6 @@ class CalibrationSession:
if raw_joint_capture(self.profile) and not quality.passed:
if self.engine.permits_retry("sweep_acquisition", retries):
self._retry_by_unit[key] = retries + 1
self._units.append(self._units.pop(self._unit_index))
self.phase = CalibrationPhase.PREPARE
else:
self.incomplete_units[key] = {"failures": quality.failures,
@@ -350,6 +350,10 @@ class CalibrationSession:
def return_complete(self) -> None:
self._require(CalibrationPhase.RETURN_BASELINE)
from ..core.domain.capture_plan import raw_joint_capture
if raw_joint_capture(self.profile) and self.diagnostic_capture is None:
self.phase = CalibrationPhase.CAPTURE_COMPLETE
return
if self.incomplete_units:
self.fail("raw_capture_incomplete", "原始采集已归位,部分扫描补采后仍不完整",
{str(key): value for key, value in self.incomplete_units.items()},
@@ -433,9 +437,10 @@ class CalibrationSession:
),
None,
)
segment = task_segment(task, unit.segment_key) if task is not None and unit.segment_key else None
progress = (
1.0
if self.phase in {CalibrationPhase.COMPLETE, CalibrationPhase.DIAGNOSTIC_COMPLETE}
if self.phase in {CalibrationPhase.COMPLETE, CalibrationPhase.DIAGNOSTIC_COMPLETE, CalibrationPhase.CAPTURE_COMPLETE}
else 0.90*self._unit_index / max(1, len(self._units))
)
required: dict[str, tuple[int, ...]] = {}
@@ -443,7 +448,7 @@ class CalibrationSession:
required = {view.name: tuple(tag.tag_id for tag in view.tags if tag.fixed_reference)
for view in self.profile.vision.views if view.name in self.required_observation_views}
if task is not None:
measurement_names = set(task_segment(task, unit.segment_key).joints if unit.segment_key else task.joints)
measurement_names = set(segment.joints if segment else task.joints)
measurement_names.update(
self.profile.measurement.cross_view_sources[name]
for name in tuple(measurement_names)
@@ -489,12 +494,16 @@ class CalibrationSession:
),
task=TaskStatus(
key="" if task is None else task.key,
label="" if task is None else " / ".join(task.joints),
label="" if task is None else " / ".join(segment.joints if segment else task.joints),
cycle=displayed_cycle,
cycle_count=displayed_cycle_count,
direction="" if unit is None else unit.direction,
capture_stage=capture_stage,
required_tag_ids_by_view=required,
segment_key="" if segment is None else segment.key,
segment_commands={} if segment is None else {
self.profile.command.names[index]: (start, dict(segment.end_commands)[index])
for index, start in sorted(segment.start_commands)},
),
resume={"message": self._resume_message,
**({"pending_unit_recaptures": [list(candidate.identity)
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
_LEGACY_STATES = {
Phase.WAIT_DEVICE: "WAIT_DEVICES", Phase.READY: "READY",
Phase.COMPLETE: "PASSED", Phase.PAUSED: "PAUSED",
Phase.CAPTURE_COMPLETE: "CAPTURE_COMPLETE",
Phase.DIAGNOSTIC_COMPLETE: "DIAGNOSTIC_COMPLETE",
Phase.ABORTED: "ABORTED", Phase.FAILED: "FAILED",
**{phase: "FINALIZING" for phase in (
@@ -193,7 +194,7 @@ def build_snapshot(host: CalibrationCoordinator) -> RuntimeSnapshot:
parameters.serial_number, host.reason, str(parameters.session_dir),
dict(host.reference_lock.latest_drift_px),
devices, steady, steady_node, preflight,
diagnostic, (dict(protocol='raw_joint_images_v1', formal_rounds_per_task=3,
diagnostic, (dict(protocol='raw_joint_images_v2', capture_finished=session.phase == Phase.CAPTURE_COMPLETE, formal_rounds_per_task=3,
actual_commands_sent=host._actual_command_count,
actual_direction_changes_by_channel=list(host._actual_direction_changes),
command_count_scope='all_sent_commands_including_approach_avoidance_return_and_retries',
@@ -11,6 +11,10 @@ from .reporting.reasons_zh import reason_zh
_STATES = {"WAIT_START": "READY", "WAIT_DEVICES": "WAIT_DEVICE", "PASSED": "COMPLETE"}
_SEGMENTS = {
"linked_increasing": "四指正向联动", "linked_decreasing": "四指反向联动",
"middle_lower_increasing": "中指向上补段", "middle_lower_decreasing": "中指向下补段",
}
_PHASES = {
"PREPARING_CHECKPOINT": "加载并校验断点证据(未开始运动)",
"WAIT_DEVICE": "等待设备", "READY": "设备就绪", "BASELINE": "安全恢复基准",
@@ -22,6 +26,7 @@ _PHASES = {
"FIT": "训练数据拟合", "HOLDOUT_VALIDATE": "独立验证",
"BUILD_ARTIFACTS": "生成产物", "VALIDATE_URDF": "校验导出 URDF",
"PUBLISH": "发布", "COMPLETE": "会话完成", "PAUSED": "已暂停",
"CAPTURE_COMPLETE": "采集已结束(URDF 尚未验收)",
"DIAGNOSTIC_COMPLETE": "诊断采集完成(未进行精度验收)",
"ABORTED": "已中止", "FAILED": "失败", "RUNNING": "标定中",
"FINALIZING": "拟合与产物校验", "FITTING": "拟合与验证",
@@ -130,6 +135,8 @@ def render_status_zh(value: Mapping[str, Any], estimator=None) -> str:
return "/".join(f"ID{int(item)}" for item in ids) or "无"
phase = str(value.get("phase"))
phase_label = _PHASES.get(phase, value.get("phase"))
if value.get("raw_protocol_progress") and phase == "steady":
phase_label = "稳态角点采集"
diagnostic_info = value.get("diagnostic_capture") or {}
diagnostic = bool(diagnostic_info)
sample_label = "原始观测" if diagnostic_info.get("coverage_scope") == "raw_observations" else "有效样本"
@@ -150,11 +157,17 @@ def render_status_zh(value: Mapping[str, Any], estimator=None) -> str:
f"[{value.get('model', '未知型号')} / {value.get('side', '?')} / {value.get('serial_number', '?')}] {progress:6.1%} 预计剩余 {eta_text}",
stage_line,
f"任务:{task.get('label') or task.get('key') or '无'};方向:{task.get('direction') or '-'}",
f"命令/反馈:{angle(motion.get('command'))}/{angle(motion.get('feedback'))}",
f"命令/反馈{'('+motion['channel']+')' if motion.get('channel') else ''}:"
f"{angle(motion.get('command'))}/{angle(motion.get('feedback'))}",
"任务所需 Tag:" + (";".join(f"{view}={tags(ids)}" for view, ids in task.get("required_tag_ids_by_view", {}).items()) or "无"),
f"Tag:已识别 {tags(value.get('recognized_tag_ids', ()))};缺失 {tags(value.get('missing_tag_ids', ()))};过滤 {tags(value.get('rejected_tag_ids', ()))}",
f"{sample_label} {acquisition.get('valid_samples', 0)};覆盖 {float(acquisition.get('coverage_01', 0)):.1%};分箱 {acquisition.get('bins', 0)};内部空白 {acquisition.get('maximum_gap', 0)};重扫 {acquisition.get('retry_count', 0)} 次",
]
if task.get('segment_key'):
key = task['segment_key']
path = ';'.join(f'{channel} {start:g}→{end:g}'
for channel, (start, end) in task.get('segment_commands', {}).items())
lines.insert(3, f"子段:{_SEGMENTS.get(key, key)};计划指令:{path} {unit}")
if diagnostic:
label = diagnostic_capture_label(diagnostic_info.get("mode", "o6_thumb"))
lines.insert(1, f"模式:{label};仅采集观测,不发布标定 JSON/URDF")
@@ -46,7 +46,7 @@ def synthetic_capture(*, scope='full', retained_offset_rad=0.0, camera_aligned_r
'ring_mcp_pitch', 'pinky_mcp_pitch', 'index_pip', 'middle_pip', 'ring_pip', 'pinky_pip',
'thumb_ip', 'index_dip', 'middle_dip', 'ring_dip', 'pinky_dip']
base = [0, 0, 255, 196, 127, 52] + [0]*14
signs = [1, 1, -1, -1, -1, -1] + [1]*14
signs = [1]*20 # Confirmed native directions; independent XML FK truth.
mount = rigid((.12, -.09, .2), (.009, -.025, .016))
hand = rigid((.11, -.08, .17), (.11, -.04, .72))
base_tag = rigid((.2, -.1, .3), (.003, .008, .055))
@@ -1,432 +0,0 @@
from __future__ import annotations
from dataclasses import replace
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.acquisition import (
ContinuousSweepCollector,
Observation,
PointCollector,
StateSample,
TagQuality,
aggregate_observations,
aggregate_sweep_observations,
interpolate_state_u8,
required_resume_views,
tag_quality_is_valid,
update_pnp_reset_watchdog,
)
from linkerhand_calibration.acquisition import PAIR_NAMES
def test_pnp_watchdog_resets_after_one_continuous_invalid_second() -> None:
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=10.0,
invalid_since=None,
reset_after_seconds=1.0,
)
assert since == 10.0
assert reset is False
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=11.01,
invalid_since=since,
reset_after_seconds=1.0,
)
assert since == 11.01
assert reset is True
def test_pnp_watchdog_clears_on_valid_pose_or_bad_detection() -> None:
assert update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=True,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
assert update_pnp_reset_watchdog(
detection_good=False,
pnp_valid=False,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
def test_resume_requires_only_the_active_view() -> None:
assert required_resume_views("top") == ("top",)
assert required_resume_views("front") == ("front",)
assert required_resume_views(None) == ("front", "side", "top")
with pytest.raises(ValueError, match="unknown"):
required_resume_views("rear")
def _observation(index: int, angle_rad: float = 0.0) -> Observation:
quaternion = tuple(
float(value)
for value in Rotation.from_rotvec([0.0, angle_rad, 0.0]).as_quat()
)
return Observation(
stamp_ns=index,
received_at=index / 30.0,
relative_quaternion_xyzw={pair: quaternion for pair in PAIR_NAMES},
tag_quality={
role: TagQuality(hamming=0, decision_margin=50.0, edge_pixels=45.0)
for role in ("t0", "t3", "t4", "t5")
},
state_u8=tuple(float(value) for value in range(20)),
)
def _point_observation(
index: int,
*,
angle_rad: float = 0.0,
motor_value: float = 100.0,
) -> Observation:
state = [255.0] * 20
state[0] = float(motor_value)
return replace(
_observation(index, angle_rad),
state_u8=tuple(state),
state_stamp_ns=index,
state_sync_error_ns=5_000_000,
)
def test_stable_window_then_thirty_frame_capture() -> None:
collector = PointCollector(
stable_frames=15,
capture_frames=30,
minimum_settle_seconds=0.4,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(0.0)
result = None
for index in range(15):
result = collector.add(_observation(index), index / 30.0)
assert result is None
assert collector.state == "capturing"
assert collector.stable_frames_seen == 15
assert collector.capture_frames_seen == 0
for index in range(15, 45):
result = collector.add(_observation(index), index / 30.0)
assert result is not None
assert collector.capture_frames_seen == 30
assert result["valid_frames"] == 30
assert len(result["state_u8_median"]) == 20
def test_translation_stability_ignores_planar_orientation_jitter() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
stability_mode="translation",
maximum_stable_translation_spread_m=0.001,
)
collector.start(0.0)
result = None
for index in range(6):
observation = replace(
_observation(index, angle_rad=np.deg2rad(10.0 * index)),
tag_translation_xyz_m={
"t0": (0.00, 0.00, 0.50),
"t3": (0.03, 0.00, 0.50),
"t4": (0.06, 0.00, 0.50),
"t5": (0.09, 0.00, 0.50),
},
)
result = collector.add(observation, index / 30.0)
assert result is not None
assert collector.state == "complete"
assert collector.stable_spread_rad == {}
assert max(collector.stable_spread_m.values()) == 0.0
def test_point_capture_waits_for_synchronised_target_state() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(
0.0,
required_state_index=0,
required_state_u8=100.0,
maximum_state_error_u8=2.0,
)
for index in range(3):
collector.add(
_point_observation(index, motor_value=108.0),
index / 30.0,
)
assert collector.state == "settling"
assert collector.stable_frames_seen == 0
assert collector.reason == "motor_position_out_of_tolerance"
result = None
for index in range(3, 9):
result = collector.add(
_point_observation(index, motor_value=101.0),
index / 30.0,
)
assert result is not None
assert collector.state == "complete"
assert result["state_u8_median"][0] == 101.0
def test_unstable_capture_frames_are_not_aggregated() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(0.0)
for index in range(3):
collector.add(_observation(index), index / 30.0)
assert collector.state == "capturing"
result = None
# The capture block is internally stable but belongs to a different
# planar-PnP branch than the preceding stable window.
for index, angle_deg in enumerate((25.0, 25.0, 25.0), start=3):
result = collector.add(
_observation(index, np.deg2rad(angle_deg)),
index / 30.0,
)
assert result is None
assert collector.state == "settling"
assert collector.capture_frames_seen == 0
assert collector.reason.endswith("capture_not_stable")
def test_position_drift_during_capture_restarts_settling() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
)
collector.start(
0.0,
required_state_index=0,
required_state_u8=100.0,
maximum_state_error_u8=2.0,
)
for index in range(3):
collector.add(
_point_observation(index, motor_value=100.0),
index / 30.0,
)
assert collector.state == "capturing"
collector.add(
_point_observation(3, motor_value=108.0),
0.1,
)
assert collector.state == "settling"
assert collector.capture_frames_seen == 0
assert collector.reason == "motor_position_out_of_tolerance"
def test_missing_tags_eventually_pauses_point_collector() -> None:
collector = PointCollector(settle_timeout_seconds=5.0)
collector.start(10.0)
collector.poll(15.01)
assert collector.state == "failed"
assert collector.reason == "settle_timeout"
def test_unstable_window_does_not_enter_capture() -> None:
collector = PointCollector(maximum_stable_spread_rad=np.deg2rad(0.3))
collector.start(0.0)
for index in range(15):
angle = np.deg2rad(1.0 if index % 2 else -1.0)
collector.add(_observation(index, angle), index / 30.0)
assert collector.state == "settling"
assert collector.reason.endswith("not_stable")
assert max(collector.stable_spread_rad.values()) > np.deg2rad(0.3)
def test_isolated_invalid_frame_is_skipped_without_losing_valid_window() -> None:
collector = PointCollector()
collector.start(0.0)
for index in range(14):
collector.add(_observation(index), index / 30.0)
collector.mark_invalid_frame()
assert collector.stable_frames_seen == 14
collector.add(_observation(15), 0.5)
assert collector.state == "capturing"
assert collector.reason == ""
def test_three_consecutive_invalid_frames_clear_stability_window() -> None:
collector = PointCollector()
collector.start(0.0)
for index in range(14):
collector.add(_observation(index), index / 30.0)
for _ in range(3):
collector.mark_invalid_frame()
assert collector.stable_frames_seen == 0
collector.add(_observation(15), 0.5)
assert collector.state == "settling"
def test_bad_tag_quality_is_filtered() -> None:
good = TagQuality(hamming=0, decision_margin=31.0, edge_pixels=40.0)
bad_hamming = TagQuality(hamming=1, decision_margin=50.0, edge_pixels=50.0)
thresholds = {
"maximum_hamming": 0,
"minimum_decision_margin": 30.0,
"minimum_edge_pixels": 40.0,
}
assert tag_quality_is_valid(good, **thresholds)
assert not tag_quality_is_valid(bad_hamming, **thresholds)
def test_aggregate_keeps_worst_tag_quality() -> None:
observations = [_observation(0), _observation(1)]
aggregate = aggregate_observations(observations)
assert aggregate["tag_quality"]["t0"] == {
"minimum_decision_margin": 50.0,
"minimum_edge_pixels": 45.0,
"maximum_hamming": 0,
}
def test_aggregate_keeps_median_tag_centres() -> None:
observations = [
replace(
_observation(index),
tag_translation_xyz_m={
role: (0.01 * index, 0.02, 0.50)
for role in ("t0", "t3", "t4", "t5")
},
)
for index in range(3)
]
aggregate = aggregate_observations(observations)
assert aggregate["tag_translation_xyz_m"]["t4"] == pytest.approx(
[0.01, 0.02, 0.50]
)
def test_pnp_reprojection_error_is_filtered_and_aggregated() -> None:
good = TagQuality(
hamming=0,
decision_margin=50.0,
edge_pixels=40.0,
reprojection_error_px=0.4,
)
bad = replace(good, reprojection_error_px=1.6)
thresholds = {
"maximum_hamming": 0,
"minimum_decision_margin": 30.0,
"minimum_edge_pixels": 30.0,
"maximum_reprojection_error_px": 1.5,
}
assert tag_quality_is_valid(good, **thresholds)
assert not tag_quality_is_valid(bad, **thresholds)
observations = [
replace(
_observation(index),
tag_quality={
role: replace(good, reprojection_error_px=error)
for role in ("t0", "t3", "t4", "t5")
},
)
for index, error in enumerate((0.2, 0.7))
]
aggregate = aggregate_observations(observations)
assert (
aggregate["tag_quality"]["t0"][
"maximum_reprojection_error_px"
]
== 0.7
)
def test_state_is_interpolated_at_camera_timestamp() -> None:
before = tuple([255.0] + [0.0] * 19)
after = tuple([235.0] + [0.0] * 19)
samples = [
StateSample(stamp_ns=1_000_000_000, position_u8=before),
StateSample(stamp_ns=1_100_000_000, position_u8=after),
]
matched = interpolate_state_u8(
samples,
1_025_000_000,
maximum_skew_ns=60_000_000,
)
assert matched is not None
state, skew = matched
assert state[0] == 250.0
assert skew == 25_000_000
assert (
interpolate_state_u8(
samples,
1_300_000_000,
maximum_skew_ns=60_000_000,
)
is None
)
def _synchronised_observation(
index: int, motor_value: float
) -> Observation:
state = [255.0] * 20
state[0] = motor_value
return replace(
_observation(index),
state_u8=tuple(state),
state_stamp_ns=index,
state_sync_error_ns=5_000_000,
)
def test_continuous_sweep_completes_after_full_span_and_endpoint_hold() -> None:
collector = ContinuousSweepCollector(
endpoint_hold_seconds=0.2,
minimum_valid_frames=20,
minimum_state_span_u8=240.0,
)
collector.start(0.0, motor_index=0, start_u8=255, target_u8=0)
result = None
for index, value in enumerate(np.linspace(255.0, 0.0, 60)):
result = collector.add(
_synchronised_observation(index, float(value)),
index * 0.05,
)
assert result is None
for offset in range(1, 6):
result = collector.add(
_synchronised_observation(60 + offset, 0.0),
3.0 + offset * 0.05,
)
if result is not None:
break
assert result is not None
assert collector.state == "complete"
assert collector.state_span_u8 == 255.0
bins = aggregate_sweep_observations(
result,
motor_index=0,
start_u8=255,
target_u8=0,
endpoint_tolerance_u8=2.0,
)
assert 0 in bins
assert 255 in bins
assert len(bins) >= 50
@@ -1,109 +0,0 @@
"""A training plan saves stops without reading the independent validation set."""
from copy import deepcopy
from pathlib import Path
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.domain.profile import observation_streams
from linkerhand_calibration.core.domain.sampling import command_nodes
from linkerhand_calibration.core.fitting.command_sampling import (
build_sampling_plan, declared_training_nodes, reduced_knots, validate_sampling_plans,
MAXIMUM_REMOVAL_ERROR_RAD,
)
from linkerhand_calibration.product import load_product_config
def source_rows(profile, task, *, curved=False):
rows = []
for cycle in range(4):
for direction in ("increasing", "decreasing"):
for i, x in enumerate(np.linspace(task.start_value, task.end_value, 81)):
phase = (x-min(task.start_value, task.end_value))/abs(task.end_value-task.start_value)
angle = .8*phase + (.35*phase**3 if curved else 0)
for field, name, spec in observation_streams(profile, task):
rows.append({"kind": "joint_sample", field: name, "view": spec.view,
"task_name": task.key, "image_stamp_ns": 1000000+len(rows), "cycle": cycle,
"direction": direction, "sample_phase": "sweep", "attempt": 1,
f"feedback_{profile.command.unit}": float(x),
"relative_quaternion_xyzw": Rotation.from_rotvec([0, 0, angle]).as_quat().tolist()})
return rows
@pytest.mark.parametrize("name", ["g20", "l6", "o6", "o12"])
def test_all_models_reduce_linear_training_and_keep_validation_unchanged(name):
profile = load_product_config(Path(__file__).resolve().parents[1]/f"config/{name}_right_product.yaml",
check_can=False).calibration_contract.typed_profile
task = profile.motion.tasks[0]
rows = source_rows(profile, task)
plan = build_sampling_plan(profile, task, rows)
assert 3 <= len(plan["training_nodes"]) <= 4
assert len(plan["training_nodes"]) < len(command_nodes(profile, task))
assert tuple(plan["validation_nodes"]) == command_nodes(profile, task, holdout=True)
assert validate_sampling_plans(profile, [*rows, plan]) == {task.key: plan}
changed = deepcopy(rows)
for row in changed:
if row["cycle"] == 3:
row["relative_quaternion_xyzw"] = [1, 0, 0, 0]
row[f"feedback_{profile.command.unit}"] += 100
assert build_sampling_plan(profile, task, changed) == plan
assert len(build_sampling_plan(profile, task, source_rows(profile, task, curved=True))["training_nodes"]) > 3
missing = [row for row in rows if row["cycle"] != 1]
assert tuple(build_sampling_plan(profile, task, missing)["training_nodes"]) == command_nodes(profile, task)
changed[0]["relative_quaternion_xyzw"] = [0, 0, .1, .99498743710662]
with pytest.raises(ValueError, match="sampling_training_evidence_changed"):
validate_sampling_plans(profile, [*changed, plan])
damaged = {**plan, "training_nodes": [0, 255]}
with pytest.raises(ValueError, match="sampling_plan_hash_changed"):
declared_training_nodes(profile, task, [damaged])
def test_command_release_selects_knots_without_a_feedback_angle_fit():
from linkerhand_calibration.profiles import load_bundled_hand_profile
profile = load_bundled_hand_profile("o30_right_18", scope="full")
assert not profile.vision_motion and profile.command_based_release
task = profile.motion.tasks[0]
rows = source_rows(profile, task, curved=True)
for row in rows:
row["command_u8"] = row["feedback_u8"]
row["feedback_u8"] = min(255., row["feedback_u8"]+10.)
plan = build_sampling_plan(profile, task, rows)
assert plan["source_count"] > 0
changed = [dict(row, feedback_u8=0.) for row in rows]
assert build_sampling_plan(profile, task, changed) == plan
def test_shortest_plan_respects_each_branch_and_mandatory_nodes():
grid = np.linspace(0, 1, 9)
curves = np.array([grid, grid + .03*np.sin(6*grid), grid**2])
selected = reduced_knots(grid, curves, {0, 4, 8})
assert {0, .5, 1} <= set(selected)
for curve in curves:
actual = np.interp(grid, selected, np.interp(selected, grid, curve))
assert max(abs(actual-curve)) <= MAXIMUM_REMOVAL_ERROR_RAD
@pytest.mark.parametrize("layout", ["l6_right_8", "o12_right_16"])
def test_reduced_static_training_still_rejects_wrong_independent_validation(layout):
from dataclasses import replace
from linkerhand_calibration.core.fitting.command_mapping import fit_command_mappings
from test_dual_mapping import independent_steady_fixture
profile, fit, name, rows, _ = independent_steady_fixture(layout)
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="separate"))
task = profile.motion.tasks[0]
plan = build_sampling_plan(profile, task, source_rows(profile, task))
assert len(plan["training_nodes"]) < len(command_nodes(profile, task))
selected = [dict(row, cycle={0: 4, 3: 5}[row["cycle"]],
steady_training_nodes=plan["training_nodes"], sampling_plan_sha256=plan["plan_sha256"])
for row in rows if row["cycle"] == 3
or (row["cycle"] == 0 and row["steady_target"] in plan["training_nodes"])]
assert fit_command_mappings(profile, fit, {name: selected}).command_mappings
for row in selected:
if row["cycle"] == 5:
row["relative_quaternion_xyzw"] = (Rotation.from_rotvec([0, 0, .09])
* Rotation.from_quat(row["relative_quaternion_xyzw"])).as_quat()
with pytest.raises(ValueError, match="steady_command_holdout_failed"):
fit_command_mappings(profile, fit, {name: selected})
assert not fit.command_mappings
@@ -1,159 +0,0 @@
"""Training decisions use real independent cycles and never held-out images."""
import math
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.domain.capture_plan import ADAPTIVE, configure_training, select_task_training
from linkerhand_calibration.core.domain.reference import read_joint_zero_references
from linkerhand_calibration.core.fitting.command_motion import fit_command_training
from linkerhand_calibration.core.fitting.training_quality import assess_training, training_snapshot
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
@pytest.fixture(scope="module")
def training(o30_capture):
profile, source, records, _ = o30_capture
profile = configure_training(profile, ADAPTIVE)
return profile, source, records, read_joint_zero_references(profile, records)
def local_records(training):
profile, _, records, references = training
task = profile.motion.tasks[0]
return task, [r for r in records if r.get("task_name") == task.key], {
name: references[name] for name in task.joints}
def test_missing_future_geometry_retains_third_round_and_freezes_once(training):
profile, source, _, _ = training
task, rows, references = local_records(training)
for cycles, expected in [((0, 1), "add_training"), ((0, 1, 2), "freeze")]:
snapshot = training_snapshot(profile, rows, task.key, cycles)
result = assess_training(profile, task.key, cycles, snapshot, references, source)
assert result["decision"] == expected, result
assert result["geometry"]["status"] == "deferred"
assert not result["failures"]
def test_persistent_cycle_disagreement_fails_after_third_round(training):
profile, source, _, _ = training
task, rows, references = local_records(training)
snapshot = training_snapshot(profile, rows, task.key, (0, 1, 2))
# Rotate a whole independent cycle, keeping coverage and image count fixed.
axis = (Rotation.from_quat(snapshot[20]["relative_quaternion_xyzw"]).inv()
* Rotation.from_quat(snapshot[50]["relative_quaternion_xyzw"])).as_rotvec()
axis /= np.linalg.norm(axis)
changed = [dict(row, relative_quaternion_xyzw=(Rotation.from_quat(row["relative_quaternion_xyzw"])
* Rotation.from_rotvec(axis * (.06 if row["cycle"] == 1 else 0.))).as_quat().tolist()) for row in snapshot]
for cycles, expected in [((0, 1), "add_training"), ((0, 1, 2), "fail")]:
result = assess_training(profile, task.key, cycles,
tuple(r for r in changed if r["cycle"] in cycles), references, source)
assert result["decision"] == expected
assert result["failures"]
def test_training_snapshot_excludes_poisoned_validation_and_rejected_attempts(training):
profile, _, _, _ = training
task, rows, _ = local_records(training)
first = training_snapshot(profile, rows, task.key, (0, 1))
poisoned = [dict(r, relative_quaternion_xyzw=[float("nan")]*4) if r.get("cycle") == 3 else r for r in rows]
assert training_snapshot(profile, poisoned, task.key, (0, 1)) == first
rejected = dict(first[0], kind="pnp_candidate_frame", attempt=2)
latest = training_snapshot(profile, [*rows, rejected], task.key, (0, 1))
assert len(latest) < len(first)
assert all(r["cycle"] != 3 for r in latest)
def test_training_interfaces_reject_holdout_before_fitting(training):
profile, source, _, _ = training
task, rows, references = local_records(training)
sweep = {name: [r for r in rows if r.get("joint") == name and r.get("sample_phase") == "sweep"]
for name in task.joints}
steady = {name: [r for r in rows if r.get("joint") == name and r.get("sample_phase") == "steady"]
for name in task.joints}
with pytest.raises(ValueError, match="motion_training_received_nontraining_data"):
fit_command_training(profile, UrdfKinematicModel(source), sweep, steady, references)
def test_training_worker_timeout_and_close_leave_no_process(training):
import multiprocessing
import time
from linkerhand_calibration.runtime.training import TrainingWorker
profile, source, _, _ = training
task, rows, references = local_records(training)
worker = TrainingWorker(timeout_seconds=0.)
request = dict(profile=profile, source_urdf=source, task_key=task.key, cycles=(0, 1),
rows=training_snapshot(profile, rows, task.key, (0, 1)), references=references)
try:
deadline = time.monotonic()+10.
with pytest.raises(TimeoutError, match="training_assessment_timeout"):
while time.monotonic() < deadline:
assert worker.poll(**request) is None
time.sleep(.01)
assert not multiprocessing.active_children()
finally:
worker.close()
with pytest.raises(RuntimeError, match="cancelled"):
worker.poll(**request)
@pytest.mark.integration
@pytest.mark.parametrize("cancel", [False, True])
def test_coordinator_keeps_feedback_live_while_training_and_honors_abort(training, tmp_path, cancel):
import json
import time
from runtime_host_fixture import coordinator_fixture, ready
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
task, rows, references = local_records(training)
host, clock = coordinator_fixture(tmp_path, model="o30",
profile_transform=lambda p: configure_training(p, ADAPTIVE))
try:
ready(host, clock)
assert host.start().success
session = host.execution.session
session._unit_index = max(i for i, unit in enumerate(session._units)
if unit.task_key == task.key and unit.cycle == 1)
session.phase = Phase.EVALUATE
host.execution.completed_units.update(u.identity for u in session._units[:session._unit_index])
host.execution.zero_references.update(references)
host.capture_index.extend(r for r in rows if r.get("cycle") in {0, 1})
host.last_command = list(host.profile.command.baseline_values)
deadline = time.monotonic()+10.
while session.phase == Phase.EVALUATE and time.monotonic() < deadline:
clock.now += .02
host.receive_feedback(host.profile.command.names, host.last_command, host.ports.clock_ns())
host.tick()
assert host.feedback_fresh()
if cancel:
assert host.abort().success
time.sleep(.01)
saved = [json.loads(line) for line in host.raw_path.read_text().splitlines()]
decisions = [row for row in saved if row.get("kind") == "training_decision"]
if cancel:
assert session.phase == Phase.ABORTED
assert not decisions
else:
assert session.phase == Phase.PREPARE, host.reason
assert session.current_unit.cycle == 2
assert len(decisions) == 1 and decisions[0]["decision"] == "add_training"
finally:
host.close()
@pytest.mark.integration
def test_mixed_cycles_have_real_zero_confidence_and_no_fabricated_third_observation(training):
profile, source, rows, references = training
profile = select_task_training(profile, "pinky_dip_side", (0, 1))
task = profile.motion.tasks[-1]
result = assess_training(profile, task.key, (0, 1),
training_snapshot(profile, rows, task.key, (0, 1)), references, source)
assert result["decision"] == "freeze", result
assert result["geometry"]["status"] == "checked"
counts = result["geometry"]["independent_cycles_by_joint"]
assert counts["pinky_pip"] == 2
assert counts["index_pip"] == 3
assert all(width is not None and width <= math.radians(1.)
for width in result["geometry"]["zero_confidence_half_width_rad"].values())
@@ -1,103 +0,0 @@
"""Keep standalone URDF coupling while JSON retains the measured nonlinear path."""
from copy import deepcopy
import json
import numpy as np
import pytest
from test_independent_urdf_export import nonlinear_release, assert_robot_state_publisher_angles
from linkerhand_calibration.core.fitting.coupling import fit_range_bounded_mimic
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan
from linkerhand_calibration.runtime.artifacts.urdf_from_json import (
rebuild_urdf_from_json, serialize_urdf_correction, validate_json_urdf_correspondence,
)
@pytest.fixture
def affine_release(nonlinear_release):
source, rights, original_plan, payload, path = nonlinear_release
model = UrdfKinematicModel(source)
parent, child = model.joints["pitch"], model.joints["ip"]
x = np.tile(payload["joints"]["pitch"]["angle_rad"], 3)
y = np.tile(payload["joints"]["ip"]["angle_rad"], 3)
fitted = fit_range_bounded_mimic("pitch", "ip", x, y, cycles=np.repeat([0, 1, 2], 256),
parent_range=(parent.lower, parent.upper), child_range=(child.lower, child.upper))
plan = build_standard_correction_plan(source_urdf=source,
source_sha256=original_plan.source_sha256, zero_offsets_rad={}, authorized_fields=rights,
fitted_mimics_output={"ip": fitted})
payload = deepcopy(payload)
payload["urdf_correction"] = serialize_urdf_correction(plan)
path.write_text(json.dumps(payload))
return source, rights, plan, payload, path
def test_near_zero_limits_do_not_flatten_affine_motion_and_json_stays_nonlinear(affine_release, tmp_path):
source, rights, plan, payload, path = affine_release
output = rebuild_urdf_from_json(path, source, tmp_path/"affine.urdf",
authorized_fields=rights, expected_profile_id=payload["profile_id"])
validate_json_urdf_correspondence(payload, source, output, expected_profile_id=payload["profile_id"])
assert output.read_bytes() == plan.write(source, tmp_path/"direct.urdf").read_bytes()
assert payload["urdf_correction"]["schema_version"] == 3
original, model = UrdfKinematicModel(source), UrdfKinematicModel(output)
passive = model.joints["ip"]
assert passive.mimic_joint == "pitch"
assert passive.mimic_multiplier > 1.75
assert 0 < passive.mimic_offset < .002
for name, joint in original.joints.items():
assert (model.joints[name].lower, model.joints[name].upper) == (joint.lower, joint.upper)
np.testing.assert_array_equal(model.joints[name].origin, joint.origin)
for command in (0, 32, 128, 255):
explicit = {name: row["angle_rad"][command] for name, row in payload["joints"].items()}
assert model.resolve_angles(explicit, independent_mimic_angles=True) == explicit
preview = model.resolve_angles({"pitch": explicit["pitch"]})
assert preview["ip"] == pytest.approx(passive.mimic_multiplier*explicit["pitch"]+passive.mimic_offset)
assert abs(preview["ip"]-explicit["ip"]) < np.deg2rad(4)
for angle in np.linspace(model.joints["pitch"].lower, model.joints["pitch"].upper, 257):
value = model.resolve_angles({"pitch": angle})["ip"]
assert passive.lower-1e-12 <= value <= passive.upper+1e-12
@pytest.mark.parametrize("policy", ["missing", "unknown", "legacy"])
def test_nonzero_approximation_requires_explicit_serialized_policy(affine_release, tmp_path, policy):
source, rights, _, payload, path = affine_release
payload = deepcopy(payload)
correction = payload["urdf_correction"]
if policy == "missing":
del correction["mimic_policy"]
elif policy == "unknown":
correction["mimic_policy"] = "assumed_physical_zero"
else:
correction["schema_version"] = 1
del correction["mimic_policy"], correction["joint_angle_source"]
path.write_text(json.dumps(payload))
output = tmp_path/"invalid.urdf"
with pytest.raises(ValueError):
rebuild_urdf_from_json(path, source, output, authorized_fields=rights,
expected_profile_id=payload["profile_id"])
assert not output.exists()
def test_standard_ros_parent_input_drives_passive_joint(affine_release, tmp_path):
assert_robot_state_publisher_angles(affine_release, tmp_path, use_mimic=True)
@pytest.mark.parametrize("parent_range,child_range", [((-.002, .6), (-.003, 1.2)), ((-.6, .002), (-.003, 1.2))])
def test_range_bounded_fit_reports_real_residuals_without_rewriting_training_data(parent_range, child_range):
sign = 1 if parent_range[1] == .6 else -1
x = sign*np.tile(np.linspace(0, .6, 80), 3)
y = 1.71*np.abs(x)+.39*x*x
original = y.copy()
cycles = np.repeat([0, 1, 2], 80)
fit = fit_range_bounded_mimic("parent", "child", x, y, cycles=cycles,
parent_range=parent_range, child_range=child_range)
assert sign*fit.multiplier > 1.7
assert fit.offset_observed is False # The approximation intercept is not a physical datum.
np.testing.assert_array_equal(y, original)
np.testing.assert_allclose(fit.residuals_rad, fit.multiplier*x+fit.offset_rad-y)
for endpoint in parent_range:
assert child_range[0]-1e-10 <= fit.multiplier*endpoint+fit.offset_rad <= child_range[1]+1e-10
with pytest.raises(ValueError, match="holdout is forbidden"):
fit_range_bounded_mimic("parent", "child", x, y, cycles=np.repeat([0, 1, 3], 80),
parent_range=parent_range, child_range=child_range)
@@ -1,39 +0,0 @@
"""Tests for the independent physical-line alignment overlay."""
import pytest
from linkerhand_calibration.alignment_view import (
summarize_alignment_measurements,
)
def _measurement(angle: float, offset: float, y: float) -> dict:
return {
"line_xyxy_px": [0.0, y, 100.0, y - angle * 100.0],
"angle_rad": angle,
"vertical_offset_px": offset,
}
def test_line_summary_smooths_only_physical_line_measurements() -> None:
"""The overlay smooths scene lines without any Tag orientation input."""
result = summarize_alignment_measurements(
[
_measurement(-0.02, -4.0, 80.0),
None,
_measurement(0.00, 0.0, 82.0),
_measurement(0.02, 4.0, 84.0),
]
)
assert result is not None
assert result["angle_rad"] == pytest.approx(0.0)
assert result["vertical_offset_px"] == pytest.approx(0.0)
assert result["line_xyxy_px"] == pytest.approx([0.0, 82.0, 100.0, 82.0])
assert result["detected_frames"] == 3
assert result["window_frames"] == 4
def test_line_summary_returns_none_without_scene_line() -> None:
"""No blue line is fabricated when the scene has no valid long edge."""
assert summarize_alignment_measurements([None, None]) is None
@@ -1,99 +0,0 @@
"""Shared v3 generation for every product using independent synthetic poses.
Synthetic geometry/limits are explicit fixture truth, not hardware evidence.
"""
from dataclasses import replace
import hashlib
import json
import pytest
from measured_capture_fixture import measured_capture
from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session
from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper
from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json
@pytest.mark.parametrize("layout", ["l6_right_8", "o6_right_8", "o12_right_16", "g20_right_19"])
def test_separate_motion_mapping_json_urdf_and_reader(layout, tmp_path, monkeypatch):
from linkerhand_calibration.runtime.artifacts import finalization
from linkerhand_calibration.core.fitting import mimic_approximation
from linkerhand_calibration.core.urdf.source_mimic import validate_source_mimic_elements
def forbidden_fit(*args, **kwargs):
pytest.fail("formal measured calibration must not optimize mimic parameters")
monkeypatch.setattr(mimic_approximation, "fit_mimic_approximations", forbidden_fit)
monkeypatch.setattr(mimic_approximation, "fit_bounded_mimic_approximations", forbidden_fit)
validators = []
original_validator = finalization.FrozenTagArtifactValidator
def record_validator(*args, **kwargs):
from test_final_image_holdout import independent_pixels
validator = original_validator(*args, **kwargs)
validator = replace(validator, require_image_holdout=True,
image_observations=independent_pixels(validator.observations),
command_image_observations=independent_pixels(validator.command_observations))
validators.append(validator)
return validator
monkeypatch.setattr(finalization, "FrozenTagArtifactValidator", record_validator)
profile, source, records, _ = measured_capture(tmp_path, layout=layout,
nonlinear=layout != "o12_right_16")
if layout == "o12_right_16":
from linkerhand_calibration.profiles import load_bundled_hand_profile
assumptions = load_bundled_hand_profile(layout).zero.cad_zero_assumptions
profile = replace(profile, zero=replace(profile.zero, cad_zero_assumptions=assumptions,
known_baseline_geometry={name: value for name, value in profile.zero.known_baseline_geometry.items()
if name not in assumptions}))
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="separate"))
records = [dict(row, cycle={0: 4, 3: 5}[row["cycle"]]) if row.get("sample_phase") == "steady" else row
for row in records if row.get("sample_phase") != "steady" or row["cycle"] in {0, 3}]
from linkerhand_calibration.core.fitting.command_sampling import build_sampling_plan
plans = {task.key: build_sampling_plan(profile, task, records) for task in profile.motion.tasks}
records = [dict(row, steady_training_nodes=plans[row["task_name"]]["training_nodes"],
sampling_plan_sha256=plans[row["task_name"]]["plan_sha256"])
if row.get("sample_phase") == "steady" else row for row in records
if row.get("sample_phase") != "steady" or row["cycle"] != 4
or row["steady_target"] in plans[row["task_name"]]["training_nodes"]]
records.extend(plans.values())
hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields}
hashes["source_urdf_sha256"] = hashlib.sha256(source.read_bytes()).hexdigest()
phases = []
payload, fit, result = finalize_profile_session(profile=profile, source_urdf=source,
records=records, protected_inputs=hashes, serial_number="SYNTHETIC_ONLY", session_dir=tmp_path/"session",
standard_loader=lambda _: None, phase_changed=phases.append)
assert phases == ["fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"]
assert payload["schema_version"] == 3
assert payload["urdf_correction"]["schema_version"] == 4
assert payload["urdf_correction"]["mimic_policy"] == "preserve_source_mimic"
assert not fit.mimic_approximations
validate_source_mimic_elements(source, result.path)
assert all(row["training_cycles"] == [4] and row["holdout_cycle"] == 5
for row in fit.command_holdout_metrics.values())
assert payload["urdf_correction"]["source_urdf_sha256"] == hashes["source_urdf_sha256"]
accepted = json.loads(result.staged_release.validation_json)
assert accepted["command_holdout_cycle"] == 5
assert accepted["final_file_image_holdout_verified"] is True
assert accepted["final_file_image_holdout_policy"] == "independent_measurement_and_physical_consistency_v2"
assert set(accepted["final_file_image_holdout"]) == {"feedback", "command"}
assert accepted["urdf_rebuilt_from_json"] is True
assert accepted["cad_zero_assumptions"] == dict(profile.zero.cad_zero_assumptions)
for name in profile.zero.cad_zero_assumptions:
assert fit.zero_offsets_rad[name] == 0
assert fit.zero_method_by_joint[name] == "assumed_source_cad_zero"
mapper = load_unified_mapper(result.staged_release.calibration_json)
assert max(abs(v) for v in mapper.map_positions(profile.command.baseline_values)) < 1e-10
rebuilt = rebuild_urdf_from_json(result.staged_release.calibration_json, source, tmp_path/"rebuilt.urdf",
authorized_fields=profile.urdf_authorized_fields, expected_profile_id=profile.key.profile_id)
assert rebuilt.read_bytes() == result.path.read_bytes()
assert hashlib.sha256(source.read_bytes()).hexdigest() == hashes["source_urdf_sha256"]
# Editing JSON and its report together cannot claim different corrections
# for an unchanged URDF, even when the command tables still agree.
report_path = result.path.parent/"calibration_report.json"
report = json.loads(report_path.read_text())
edits = payload["urdf_correction"]["joint_patches"]
name = next(iter(edits))
edits[name]["origin_rpy"] = "0 0 0.123"
report["urdf_correction"] = payload["urdf_correction"]
result.staged_release.calibration_json.write_text(json.dumps(payload))
report_path.write_text(json.dumps(report))
with pytest.raises(ValueError, match="calibration_json_correction_differs_from_urdf"):
validators[0](result.staged_release.calibration_json, result.path)
@@ -1,103 +0,0 @@
"""Final image evidence includes other motions, with no invented 3-D poses."""
from copy import deepcopy
from dataclasses import replace
from types import SimpleNamespace
import pytest
from linkerhand_calibration.runtime.artifacts.evidence import _replay_directions
from linkerhand_calibration.runtime.artifacts.image_evidence import ImageEvidence
from linkerhand_calibration.runtime.image_capture import IMAGE_OBSERVATION_POLICY
from test_final_image_sources import scene
def source_scene(input_kind="feedback"):
config, records, observations = scene()
profile = config.calibration_contract.typed_profile
tasks = {task.key: task for task in profile.motion.tasks}
mappings = {joint: SimpleNamespace(motor_index=task.command_index)
for task in tasks.values() for joint in task.joints}
cycle = 3 if input_kind == "feedback" else 5
phase = "sweep" if input_kind == "feedback" else "steady"
frames = {f"{r['view']}:{r['image_stamp_ns']}": r for r in records if r['kind'] == 'pnp_candidate_frame'}
raw, primary = [], []
for observation in observations:
frame = frames[observation.sample_id]
frame['cycle'] = cycle
history = ['increasing'] * profile.command.command_count
history[tasks[frame['task_name']].command_index] = frame['direction']
values = list(observation.sdk_values)
values[0] = 120.25 # Exposure-time feedback is not an integer command.
image = dict(kind='image_observation_frame', policy=IMAGE_OBSERVATION_POLICY,
view=frame['view'], image_stamp_ns=frame['image_stamp_ns'], task_name=frame['task_name'],
cycle=cycle, direction=frame['direction'], attempt=1, sample_phase=phase,
tags=deepcopy(frame['roles']), command_unit=profile.command.unit,
command_vector=[120] * profile.command.command_count, feedback_vector=values,
command_direction_by_index=history, state_image_sync_error_ns=10_000_000,
camera_matrix=frame['camera_matrix'], camera_matrix_source='CameraInfo.P[:3,:3]',
input_is_rectified=True)
sdk = image['feedback_vector' if input_kind == 'feedback' else 'command_vector']
directions = _replay_directions(mappings, image, tasks[image['task_name']])
primary.append(replace(observation, cycle=cycle, sdk_values=tuple(sdk), sdk_directions_by_joint=directions))
raw.append(image)
records.append(dict(kind='scan_unit_complete', task_name=frame['task_name'], cycle=cycle,
direction=frame['direction'], attempt=1, passed=True))
# ID7 is on the distal link. Its pixels during the flexion task must be
# checked even though that task's primary camera is front.
extra = deepcopy(next(row for row in raw if row['view'] == 'top'))
extra.update(image_stamp_ns=extra['image_stamp_ns']+1, task_name='thumb_pitch_ip_front')
for point in extra['tags']['thumb_yaw']['corners_xy']:
point[0] += 8
records.extend(raw + [extra])
records.append(dict(kind='scan_unit_complete', task_name=extra['task_name'], cycle=cycle,
direction=extra['direction'], attempt=1, passed=True))
return config, records, primary, mappings, extra
def prepare(config, records, primary, mappings, input_kind):
evidence = ImageEvidence(config.calibration_contract.typed_profile, records,
camera_extrinsics_file=config.camera_extrinsics,
protected_inputs={'camera_extrinsics_sha256': config.camera_extrinsics_sha256})
return evidence.all_view_holdout(primary, mappings=mappings, input_kind=input_kind,
holdout_cycle=3 if input_kind == 'feedback' else 5)
@pytest.mark.parametrize('input_kind', ['feedback', 'command'])
def test_other_motion_pixels_reach_the_final_check_without_pnp(input_kind):
config, records, primary, mappings, extra = source_scene(input_kind)
images = prepare(config, records, primary, mappings, input_kind)
assert len(images) == 3
added = next(row for row in images if row.sample_id == f"top:{extra['image_stamp_ns']}")
assert added.role == 'thumb_yaw'
assert added.task_name == 'thumb_pitch_ip_front'
assert added.corners_xy == tuple(map(tuple, extra['tags']['thumb_yaw']['corners_xy']))
assert added.sdk_values[0] == (120.25 if input_kind == 'feedback' else 120)
assert dict(added.directions)['rh_thumb_cmc_yaw'] == extra['command_direction_by_index'][1]
assert not any(r['kind'] == 'pnp_candidate_frame' and r['image_stamp_ns'] == extra['image_stamp_ns'] for r in records)
@pytest.mark.parametrize('mutation', ['pixels', 'sdk', 'policy', 'unit', 'direction', 'missing', 'duplicate', 'skew', 'nonfinite'])
def test_conflicting_or_missing_primary_raw_evidence_is_rejected(mutation):
config, records, primary, mappings, extra = source_scene()
row = next(r for r in records if r['kind'] == 'image_observation_frame' and r['view'] == 'top' and r is not extra)
if mutation == 'pixels': row['tags']['thumb_yaw']['corners_xy'][0][0] += 5
elif mutation == 'sdk': row['feedback_vector'][0] += .5
elif mutation == 'policy': row['policy'] = 'unknown'
elif mutation == 'unit': row['command_unit'] = 'rad'
elif mutation == 'direction': row['command_direction_by_index'] = ['increasing']
elif mutation == 'missing': records.remove(row)
elif mutation == 'skew': row['state_image_sync_error_ns'] = -1
elif mutation == 'nonfinite': row['feedback_vector'][0] = float('nan')
else: records.append({**row, 'feedback_vector': [1] * 6})
with pytest.raises(ValueError, match='final_image'):
prepare(config, records, primary, mappings, 'feedback')
@pytest.mark.parametrize('mutation', ['training', 'failed_attempt', 'missing_feedback'])
def test_unavailable_or_non_holdout_auxiliary_images_do_not_become_measurements(mutation):
config, records, primary, mappings, extra = source_scene()
if mutation == 'training': extra['cycle'] = 2
elif mutation == 'failed_attempt': extra['attempt'] = 2
else: extra['feedback_vector'] = None
assert len(prepare(config, records, primary, mappings, 'feedback')) == 2
@@ -1,74 +0,0 @@
"""Other cameras retain raw pixels without posing as primary joint samples."""
from dataclasses import replace
import numpy as np
import pytest
from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection
from linkerhand_calibration.runtime.motion_execution import MotionCommand
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from runtime_host_fixture import coordinator_fixture, ready
@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"])
@pytest.mark.parametrize("phase", ["sweep", "zero_approach"])
def test_non_task_camera_records_measured_corners_without_a_pose(tmp_path, monkeypatch, model, phase):
host, clock = coordinator_fixture(tmp_path, model=model)
try:
ready(host, clock)
assert host.start().success
host.execution.session.phase = Phase.SWEEP
task = host.profile.motion.tasks[0]
direction = "decreasing" if phase == "sweep" else None
host._motion = MotionCommand(phase, host.profile.command.baseline_values, 1.,
task_key=task.key, command_index=task.command_index, cycle=2, direction=direction,
reference_joints=task.joints if phase == "zero_approach" else ())
view = next(view for view in host.profile.vision.views if view.name != task.view)
tag = next(tag for tag in view.tags if not tag.fixed_reference)
points = ((200., 200.), (240., 200.), (240., 240.), (200., 240.))
# Camera-derived geometry may be unresolved in this other motion.
# Raw measured pixels must survive without inventing a Tag pose.
monkeypatch.setattr(host.capture, "consume", lambda *a, **k: ((), None))
host.receive_detections(DetectionInput(view.name, host.ports.clock_ns(), (
TagDetection(tag.tag_id, points, 0, 100.),)))
host.receive_detections(DetectionInput(view.name, host.ports.clock_ns(), (
TagDetection(tag.tag_id, points, 0, 100.),)))
rows = [r for r in host.raw_records if r["kind"] == "image_observation_frame"]
assert len(rows) == 1
row = rows[0]
assert row["task_name"] == task.key and row["view"] == view.name
assert (row["cycle"], row["direction"], row["sample_phase"]) == (2, direction, phase)
np.testing.assert_array_equal(row["tags"][tag.role]["corners_xy"], points)
assert "pose" not in row["tags"][tag.role]
assert not any(r["kind"] == "joint_sample" for r in host.raw_records)
assert host.execution.session.phase == Phase.SWEEP
assert not clock.positions # Observing an additional view schedules no motion.
finally:
host.close()
def test_raw_images_keep_detection_quality_and_stable_window_boundaries(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
assert host.start().success
host.execution.session.phase = Phase.SWEEP
task = host.profile.motion.tasks[0]
host._motion = MotionCommand("steady", host.profile.command.baseline_values, 1.,
task_key=task.key, command_index=task.command_index, cycle=5, direction="increasing")
view = next(view for view in host.profile.vision.views if view.name == task.view)
tag = next(tag for tag in view.tags if not tag.fixed_reference)
points = ((200., 200.), (240., 200.), (240., 240.), (200., 240.))
monkeypatch.setattr(host.capture, "consume", lambda *a, **k: ((), None))
observation = DetectionInput(view.name, host.ports.clock_ns(), (TagDetection(tag.tag_id, points, 0, 100.),))
host.receive_detections(observation)
assert not any(r["kind"] == "image_observation_frame" for r in host.raw_records)
host._steady_capture_after_ns = host.ports.clock_ns()
rejected = replace(observation.detections[0], hamming=100)
host.receive_detections(replace(observation, detections=(rejected,)))
row = next(r for r in host.raw_records if r["kind"] == "image_observation_frame")
assert not row["tags"]
assert row["cycle"] == 5 and row["session_epoch"] == host._observation_epoch
finally:
host.close()
@@ -1,307 +0,0 @@
from pathlib import Path
from types import SimpleNamespace
import ast
from linkerhand_calibration.compat import product_profile_key
from linkerhand_calibration.core import (
ArtifactPolicy,
CalibrationProfile,
CommandLayout,
MeasurementPolicy,
MeasurementSpec,
MotionPolicy,
ProfileKey,
QualityPolicy,
ScopePolicy,
TagSpec,
TaskSpec,
ViewSpec,
VisionRigSpec,
ZeroSolvePolicy,
validate_profile,
)
from linkerhand_calibration.core.urdf import UrdfCorrectionPlan
from linkerhand_calibration.extrinsics import (
validate_camera_extrinsics_payload,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models import (
EngineBindings,
ProfileRegistry,
RegisteredProfile,
get_default_registry,
)
from linkerhand_calibration.product import get_product_calibration_contract
from linkerhand_calibration.runtime import CalibrationPhase, CalibrationSession
from linkerhand_calibration.runtime.ros import entrypoint as calibration_node
PACKAGE = Path(__file__).resolve().parents[1] / "linkerhand_calibration"
def _source_text(directory: Path) -> str:
return "\n".join(
path.read_text(encoding="utf-8")
for path in sorted(directory.rglob("*.py"))
)
def _small_profile() -> CalibrationProfile:
active = frozenset({"axis_a", "axis_b"})
return CalibrationProfile(
key=ProfileKey("TEST", "right", "two_view_three_command", 1),
namespace="/calibration_test",
command=CommandLayout(
names=("axis_a", "unused", "axis_b"),
baseline_u8=(255, 0, 127),
command_index_by_joint={"axis_a": 0, "axis_b": 2},
disabled_indices=frozenset({1}),
),
vision=VisionRigSpec(
views=(
ViewSpec(
"oblique",
(TagSpec("base_a", 40, fixed_reference=True), TagSpec("moving_a", 42)),
),
ViewSpec("wrist", (TagSpec("base_b", 43, fixed_reference=True), TagSpec("moving_b", 41))),
),
common_frame="fixture",
extrinsic_reference_view="oblique",
),
motion=MotionPolicy(
tasks=(
TaskSpec("scan_a", "oblique", 0, ("axis_a",)),
TaskSpec("scan_b", "wrist", 2, ("axis_b",)),
)
),
measurement=MeasurementPolicy(
measurements={
"axis_a": MeasurementSpec(
"axis_a", "rotation", "oblique", "base_a", "moving_a"
),
"axis_b": MeasurementSpec(
"axis_b", "circle", "wrist", "base_b", "moving_b"
),
}
),
zero=ZeroSolvePolicy(
active_joints=active,
passive_joints=frozenset(),
direct_zero_joints=("axis_a", "axis_b"),
axis_joints=("axis_a", "axis_b"),
mechanical_endpoint_joints=frozenset({"axis_b"}),
post_solve_endpoint_joints=frozenset(),
mimic_source_by_joint={},
cad_frozen_joints=frozenset(),
),
quality=QualityPolicy(
training_cycles=(0, 1, 2),
holdout_cycle=3,
hard_threshold_keys=frozenset({"maximum_error"}),
),
scope=ScopePolicy(
calibrate_joints={"full": active, "axis_a": frozenset({"axis_a"})},
frozen_joints={
"full": frozenset(),
"axis_a": frozenset({"axis_b"}),
},
),
artifacts=ArtifactPolicy(
output_schema_version=2,
calibration_filename="calibration.json",
corrected_urdf_filename="corrected.urdf",
protected_input_fields=frozenset({"source_urdf_sha256"}),
),
)
def _bindings() -> EngineBindings:
no_waypoints = lambda *args, **kwargs: ()
return EngineBindings(
hand_profile=SimpleNamespace(),
zero_profile=SimpleNamespace(),
motion_command=lambda *args, **kwargs: [0, 0, 0],
preparation_waypoints=no_waypoints,
return_waypoints=no_waypoints,
cli_main=lambda args=None: None,
node_main=lambda args=None: None,
)
def test_core_has_no_ros_or_model_dependency() -> None:
# Historical provenance in a comment is not a dependency. Check imports
# and executable product-identity branches instead of matching prose.
for path in (PACKAGE / "core").rglob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
assert not {"models", "rclpy", "cv_bridge"} & set((node.module or "").split(".")), path
if isinstance(node, ast.Import):
assert not any({"models", "rclpy", "cv_bridge"} & set(alias.name.split(".")) for alias in node.names), path
if isinstance(node, (ast.If, ast.IfExp)):
assert not any(isinstance(item, ast.Constant) and isinstance(item.value, str)
and item.value.upper() in {"G20", "L6", "O6", "O12"}
for item in ast.walk(node.test)), path
def test_runtime_has_no_concrete_model_or_view_assumption() -> None:
# A JSON "side" field describes handedness, not a hardcoded camera.
# Inspect executable selection rather than rejecting schema keys/comments.
forbidden = {"G20", "L6", "O6", "O12", "RIGHT_19", "front", "side", "top"}
def selection_literals(node):
if isinstance(node, ast.Constant):
yield node.value
elif isinstance(node, (ast.Tuple, ast.List, ast.Set)):
for item in node.elts:
yield from selection_literals(item)
for path in (PACKAGE / "runtime").rglob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, (ast.If, ast.IfExp)):
# A payload.get("side") field lookup is not selection by
# camera identity. Inspect compared literal values instead.
assert not any(isinstance(value, str) and value in forbidden
for comparison in ast.walk(node.test) if isinstance(comparison, ast.Compare)
for operand in comparison.comparators for value in selection_literals(operand)), path
if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant):
if node.slice.value in {"front", "side", "top"}:
assert not any(isinstance(item, ast.Attribute) and item.attr in {
"views", "cameras", "camera_matrices", "trackers"}
for item in ast.walk(node.value)), path
def test_every_registered_profile_passes_static_integrity_checks() -> None:
registry = get_default_registry()
assert len(registry) == 8
for registered in registry:
validate_profile(registered.profile)
assert registered.profile.zero.active_joints
assert registered.profile.motion.tasks
assert registered.profile.vision.tag_ids
def test_registry_accepts_different_view_and_command_counts() -> None:
profile = _small_profile()
registry = ProfileRegistry()
registry.register(RegisteredProfile(profile, _bindings()))
selected = registry.get(profile.key).profile
assert selected.command.command_count == 3
assert selected.vision.view_names == ("oblique", "wrist")
def test_generic_extrinsics_validator_uses_profile_view_names() -> None:
identity = {
"translation_xyz_m": [0.0, 0.0, 0.0],
"quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
}
payload = {
"schema_version": 1,
"reference_view": "oblique",
"cameras": {
"oblique": {
"serial_number": "A",
"width": 640,
"height": 480,
"intrinsics_sha256": "a" * 64,
},
"wrist": {
"serial_number": "B",
"width": 800,
"height": 600,
"intrinsics_sha256": "b" * 64,
},
},
"oblique_from_view": {
"oblique": identity,
"wrist": {
"translation_xyz_m": [0.1, 0.0, 0.0],
"quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
},
},
"quality": {"passed": True, "rms": 0.2, "pair_captures": 4},
}
validate_camera_extrinsics_payload(
payload,
required_views=("oblique", "wrist"),
reference_view="oblique",
quality_limits={"rms": 0.5},
minimum_capture_counts={"pair_captures": 3},
)
def test_legacy_node_binding_handoff_has_no_model_identity_branch(
monkeypatch, tmp_path,
) -> None:
import hashlib
profile = _small_profile()
from linkerhand_calibration import profiles
path = tmp_path/"profile.yaml"
path.write_text(profiles.dump_hand_profile(profile))
forwarded: list[list[str] | None] = []
bindings = _bindings()
bindings = EngineBindings(
hand_profile=bindings.hand_profile,
zero_profile=bindings.zero_profile,
motion_command=bindings.motion_command,
preparation_waypoints=bindings.preparation_waypoints,
return_waypoints=bindings.return_waypoints,
cli_main=bindings.cli_main,
node_main=forwarded.append,
)
registry = ProfileRegistry()
registry.register(RegisteredProfile(profile, bindings))
monkeypatch.setattr(calibration_node, "run_profile_node", lambda declared, args: forwarded.append(args))
calibration_node.main(
[
"--profile-id",
profile.key.profile_id,
"--profile-config", str(path),
"--profile-sha256", hashlib.sha256(path.read_bytes()).hexdigest(),
"--ros-args",
"-p",
"sample_rate:=30",
]
)
assert forwarded == [["--ros-args", "-p", "sample_rate:=30"]]
def test_schema_v2_selects_profile_only_by_typed_profile_id() -> None:
key = product_profile_key(
{
"schema_version": 2,
"profile_id": "TEST/right/two_view_three_command/v3",
}
)
assert key == ProfileKey("TEST", "right", "two_view_three_command", 3)
def test_retired_layout_alias_is_confined_to_compatibility_resolution() -> None:
contract = get_product_calibration_contract(
"G20", "right", "g20_right_15"
)
assert contract.typed_profile.key.layout == "g20_right_19"
def test_public_runtime_exports_the_shared_session_kernel() -> None:
session = CalibrationSession(_small_profile())
assert session.phase == CalibrationPhase.WAIT_DEVICE
session.device_ready()
session.start()
session.baseline_complete()
session.reference_locked()
session.preparation_complete()
assert session.phase == CalibrationPhase.SWEEP
def test_urdf_writer_and_validator_can_share_one_edit_plan() -> None:
plan = UrdfCorrectionPlan(
source_sha256="a" * 64,
allowed_active_joints=frozenset({"axis_a", "axis_b"}),
endpoint_limit_joints=frozenset({"axis_b"}),
mimic_source_by_joint={"passive_b": "axis_b"},
frozen_joints=frozenset({"axis_c"}),
)
plan.authorize_offsets({"axis_a": 0.0, "axis_b": 0.1})
@@ -1,68 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from linkerhand_calibration.core.artifacts.storage import atomic_write_json
from linkerhand_calibration.runtime.artifacts import ArtifactPublisher
def test_pointer_changes_only_after_both_artifacts_validate(tmp_path: Path) -> None:
session = tmp_path / "serial" / "session"
session.mkdir(parents=True)
calibration = session / "calibration.json"
urdf = session / "corrected.urdf"
atomic_write_json(calibration, {"schema_version": 1, "quality": {"passed": True}})
urdf.write_text('<robot name="test"><link name="base"/></robot>', encoding="utf-8")
publisher = ArtifactPublisher(session.parent, "latest_passed")
release = publisher.publish(
session_directory=session,
calibration_json=calibration,
corrected_urdf=urdf,
validate=lambda _json, _urdf: None,
)
assert release.pointer.resolve() == session.resolve()
assert len(release.sha256["corrected_urdf_sha256"]) == 64
def test_failed_pair_validation_never_updates_pointer(tmp_path: Path) -> None:
session = tmp_path / "serial" / "session"
session.mkdir(parents=True)
calibration = session / "calibration.json"
urdf = session / "corrected.urdf"
atomic_write_json(calibration, {"schema_version": 1})
urdf.write_text('<robot name="test"><link name="base"/></robot>', encoding="utf-8")
publisher = ArtifactPublisher(session.parent, "latest_passed")
def reject(_json: Path, _urdf: Path) -> None:
raise ValueError("holdout failed")
with pytest.raises(ValueError, match="holdout"):
publisher.publish(
session_directory=session,
calibration_json=calibration,
corrected_urdf=urdf,
validate=reject,
)
assert not (session.parent / "latest_passed").exists()
def test_staging_does_not_publish_and_changed_or_cancelled_stage_is_rejected(tmp_path):
session = tmp_path / "serial" / "session"
session.mkdir(parents=True)
calibration, urdf = session / "calibration.json", session / "corrected.urdf"
atomic_write_json(calibration, {"schema_version": 1})
urdf.write_text('<robot name="test"><link name="base"/></robot>')
publisher = ArtifactPublisher(session.parent, "latest_passed")
staged = publisher.prepare(session_directory=session, calibration_json=calibration,
corrected_urdf=urdf, validate=lambda *_: {"passed": True})
pointer = session.parent / "latest_passed"
assert not pointer.exists()
with pytest.raises(ValueError, match="abort|cancel"):
publisher.commit(staged, cancelled=lambda: True)
assert not pointer.exists()
atomic_write_json(calibration, {"schema_version": 2})
with pytest.raises(ValueError, match="changed|hash|mutat"):
publisher.commit(staged)
assert not pointer.exists()
@@ -1,60 +0,0 @@
"""Absolute zero must be invariant to reference points on the same axis line."""
from pathlib import Path
import numpy as np
import pytest
from scipy.optimize import brentq
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.fitting.session import compile_spatial_profile
from linkerhand_calibration.core.fitting.spatial_solver.residuals import ObservationGeometry
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.profiles import load_bundled_hand_profile
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "g20_right_19", "o12_right_16"])
@pytest.mark.parametrize("view", ["oblique_end_on", "side"])
def test_physical_zero_is_recovered_for_any_axis_reference_point(layout, view):
package = Path(__file__).resolve().parents[1]
model_name = layout.split("_")[0]
from linkerhand_calibration.product import load_product_config
source = load_product_config(package / "config" / f"{model_name}_right_product.yaml", check_can=False).source_urdf
model = UrdfKinematicModel(source)
spatial = compile_spatial_profile(load_bundled_hand_profile(layout))
geometry = ObservationGeometry(spatial, model, {}, {}, {})
common = Rotation.from_euler("xyz", [.2, -.15, .4])
translation = np.array([.4, -.2, .8])
truth = .07
def line(joint, parent, angle):
axis, point = model.axis_line(joint, zero_offsets={parent: angle},
joint_angles={}, independent_mimic_angles=True)
return common.apply(axis), common.apply(point)+translation
assert spatial.phase_parent_joint
for child, parent in spatial.phase_parent_joint.items():
parent_axis, parent_point = line(parent, parent, truth)
child_axis, child_point = line(child, parent, truth)
assert abs(parent_axis @ child_axis) == pytest.approx(1., abs=1e-8)
transverse = np.cross(parent_axis, np.eye(3)[np.argmin(abs(parent_axis))])
transverse /= np.linalg.norm(transverse)
normal = parent_axis + .3*transverse if view == "oblique_end_on" else transverse
normal /= np.linalg.norm(normal)
recovered = []
for parent_shift, child_shift in [(0., 0.), (.03, -.09), (-.07, .04)]:
observed_parent = parent_point + parent_shift*parent_axis
observed_child = child_point + child_shift*child_axis
def residual(angle):
predicted_axis, predicted_parent = line(parent, parent, angle)
_, predicted_child = line(child, parent, angle)
# The predicted lines also admit arbitrary reference points.
predicted_parent = predicted_parent + .08*predicted_axis
predicted_child = predicted_child - .06*predicted_axis
return geometry.phase_error(predicted_axis, predicted_parent, predicted_child,
parent_axis, observed_parent, observed_child, normal)
assert residual(truth) == pytest.approx(0., abs=1e-9), (layout, parent, child)
recovered.append(brentq(residual, -.25, .25))
assert recovered == pytest.approx([truth]*3, abs=1e-9), (layout, parent, child)
@@ -1,44 +0,0 @@
"""Nonlinear measured motion must not make the URDF fallback exceed limits."""
import numpy as np
import pytest
from linkerhand_calibration.core.fitting.coupling import fit_standard_mimic
from linkerhand_calibration.core.fitting.mimic_approximation import shared_zero_multiplier_bounds
@pytest.mark.parametrize("parent,child,expected", [
((0, 2), (0, 1), (0, .5)),
((-2, 0), (0, 1), (-.5, 0)),
((-2, 3), (-1, 2), (-1/3, .5)),
])
def test_feasible_multiplier_respects_both_endpoints(parent, child, expected):
bounds = shared_zero_multiplier_bounds(parent, child)
assert bounds == pytest.approx(expected)
for slope in bounds:
for endpoint in parent:
assert child[0]-1e-12 <= slope*endpoint <= child[1]+1e-12
def test_nonlinear_fit_is_constrained_without_changing_observations():
x = np.tile(np.linspace(0, 2, 80), 3)
y = x - .25*x*x
original = y.copy()
cycles = np.repeat([0, 1, 2], 80)
args = dict(source_joint="parent", target_joint="child", active_rad=x,
passive_rad=y, cycles=cycles, offset_rad=0)
unconstrained = fit_standard_mimic(**args)
bounded = fit_standard_mimic(**args,
multiplier_bounds=shared_zero_multiplier_bounds((0, 2), (0, 1)))
assert unconstrained.multiplier*2 > 1
assert bounded.multiplier == pytest.approx(.5, abs=1e-8)
assert bounded.offset_rad == 0
np.testing.assert_array_equal(y, original)
np.testing.assert_allclose(bounded.residuals_rad, bounded.multiplier*x-y)
with pytest.raises(ValueError, match="holdout is forbidden"):
fit_standard_mimic(**{**args, "cycles": np.repeat([0, 1, 3], 80)}, multiplier_bounds=(0, .5))
def test_impossible_shared_zero_range_is_rejected():
with pytest.raises(ValueError, match="excludes_shared_zero"):
shared_zero_multiplier_bounds((0, 2), (1, 2))
@@ -1,225 +0,0 @@
"""Declared arrival evidence, source identity and coordinator cancellation."""
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import asdict, replace
from threading import Event
import json
import pytest
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime import coordinator as coordinator_module
from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization
from linkerhand_calibration.runtime.inputs import DetectionInput
from linkerhand_calibration.runtime.motion_execution import MotionCommand
from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256
from test_diagnostic_coordinator import projected_frame, start_diagnostic, TERMINAL_PHASES
from test_motion_branch_evidence import motion_frames
from measured_capture_fixture import reordered_profile
def arrival(profile):
task = profile.motion.tasks[0]
name = task.joints[0]
reference = profile.motion.joint_zero_references[name]
common = dict(task_key=task.key, command_index=task.command_index)
approach = MotionCommand("zero_approach", reference.command, 1.,
reference_joints=(name,), **common)
zero = MotionCommand("joint_zero", reference.command, 1., zero_joints=(name,), **common)
measurement = profile.measurement.measurements[name]
rows = []
samples = motion_frames()
for i, frame in enumerate(samples):
command = list(reference.command)
index = task.command_index
command[index] = reference.approach_commands[-1][index] + i / (len(samples)-1) * (
reference.command[index] - reference.approach_commands[-1][index])
tags = {}
for role, item in zip((measurement.parent_role, measurement.child_role), frame.roles):
tags[role] = {"reprojection_valid_candidates": [asdict(pose) for pose in item.candidates],
"selected_pose": asdict(item.candidates[-1]),
"candidate_diagnostics": {"observation_stamp_ns": frame.stamp_ns,
"branch_frozen": item.frozen}}
rows.append(dict(kind="motion_branch_observation", task_name=task.key,
view=measurement.view, zero_joints=[name], image_stamp_ns=frame.stamp_ns,
command_vector=command, feedback_vector=command.copy(), tags=tags,
session_epoch=1, motion_version=7))
return approach, zero, rows
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19",
"virtual_o6_right_8", "virtual_g20_right_19"])
def test_all_models_bind_motion_before_zero_to_the_actual_source_version(layout):
profile = load_bundled_hand_profile(layout.removeprefix("virtual_"))
if layout.startswith("virtual_"):
profile = reordered_profile(profile)
approach, zero, rows = arrival(profile)
# This legacy fixture contains only 3D candidates, no captured pixels.
# It verifies the historical evidence/version binding separately from
# the production image path exercised by the coordinator tests below.
owner = BranchInitialization(profile, image_geometry=False)
owner.begin(approach, session_epoch=1, motion_version=7)
for row in rows:
owner.observe(row)
owner.begin(zero, session_epoch=1, motion_version=8)
request = owner.request(rows[0]["view"], zero, session_epoch=1, motion_version=8)
assert request.motion_version == 7
result = solve_initialization(request)
assert result.resolved, result
model, report = owner.accept(request, result)
assert owner.ready(zero) and model.evidence_ids
expected = {str(row["image_stamp_ns"]): source_frame_sha256(row) for row in rows}
assert report["source_frame_hashes"]
assert all(expected[key] == value for key, value in report["source_frame_hashes"].items())
assert set(report["source_frame_hashes"]) == {str(s) for s in report["source_image_stamps"]}
assert not set(report["source_image_stamps"]) - {row["image_stamp_ns"] for row in rows}
def test_only_last_arrival_segment_and_complete_holding_state_can_authorize_zero():
profile = load_bundled_hand_profile("o6_right_8")
approach, zero, rows = arrival(profile)
owner = BranchInitialization(profile)
owner.begin(approach, session_epoch=1, motion_version=6)
for row in rows:
owner.observe(dict(row, motion_version=6))
owner.begin(approach, session_epoch=1, motion_version=7)
# Prior, stale-version images and images without SDK state cannot survive
# the final arrival segment or be substituted for its physical evidence.
for row in rows:
owner.observe(dict(row, motion_version=6))
owner.observe(dict(row, feedback_vector=None))
wrong_pose = deepcopy(row)
wrong_pose["command_vector"][0] -= 10
owner.observe(wrong_pose)
owner.begin(zero, session_epoch=1, motion_version=8)
request = owner.request("top", zero, session_epoch=1, motion_version=8)
assert not request.frames and not solve_initialization(request).resolved
assert not owner.ready(zero)
def test_movement_of_a_held_joint_invalidates_motion_evidence():
profile = load_bundled_hand_profile("o6_right_8")
approach, zero, rows = arrival(profile)
owner = BranchInitialization(profile)
owner.begin(approach, session_epoch=1, motion_version=7)
for i, row in enumerate(rows):
if i == len(rows)//2:
row["feedback_vector"][0] -= 5
owner.observe(row)
owner.begin(zero, session_epoch=1, motion_version=8)
request = owner.request("top", zero, session_epoch=1, motion_version=8)
result = solve_initialization(request)
assert result.reason == "motion_held_feedback_changed" and not result.resolved
owner.accept(request, result)
assert not owner.ready(zero)
assert "top:joints=rh_thumb_cmc_yaw" in owner.error()
def before_initialization(host, clock):
"""Run real arrival motion but stop before the first zero image callback."""
for _ in range(8000):
clock.now += .04
feedback = clock.positions[-1] if clock.positions else host.profile.command.baseline_values
host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns())
host.tick()
assert host.execution.session.phase not in TERMINAL_PHASES, host.reason
if host._motion is not None and host._motion.phase == "joint_zero":
return projected_frame(host, "top", feedback)
task = next((t for t in host.profile.motion.tasks
if host._motion is not None and t.key == host._motion.task_key), None)
for view in (task.view,) if task is not None else host.required_observation_views:
host.receive_detections(projected_frame(host, view, feedback))
pytest.fail("arrival never completed")
@pytest.mark.parametrize("change", ["abort", "motion_version"])
def test_geometry_solves_outside_state_lock_and_cannot_commit_after_invalidation(tmp_path, monkeypatch, change):
host, clock = start_diagnostic(tmp_path, monkeypatch)
entered, release = Event(), Event()
original = coordinator_module.solve_initialization
try:
frame = before_initialization(host, clock)
def blocked(request):
entered.set()
assert release.wait(5)
return original(request)
monkeypatch.setattr(coordinator_module, "solve_initialization", blocked)
with ThreadPoolExecutor(1) as pool:
future = pool.submit(host.receive_detections, frame)
assert entered.wait(2)
# These calls need the real state lock while geometry is blocked.
clock.now += .001
feedback = tuple(host.last_command)
host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns())
assert host.state_history[-1].stamp_ns == host.ports.clock_ns()
host.snapshot()
# Solver wall time must not consume the separate two-second
# zero-image window. Feedback and the control timer keep running
# while the immutable preparation geometry is being solved.
for _ in range(6):
clock.now += .5
host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns())
host.tick()
assert host.execution.session.phase not in TERMINAL_PHASES, host.reason
assert host.reason.startswith("joint_zero_geometry_solving:")
assert not host.execution.zero_references
if change == "abort":
assert host.abort().success
else:
with host.step_data_lock:
host._segment_number += 1
before = host.raw_path.read_bytes()
release.set()
future.result(timeout=5)
assert host.raw_path.read_bytes() == before
events = [json.loads(line) for line in
(host.parameters.session_dir / "motion_solver_diagnostic.jsonl").read_text().splitlines()]
assert [event["event"] for event in events] == ["request_created", "solve_finished", "commit_rejected"]
assert len({event["source_hashes_sha256"] for event in events}) == 1
assert events[1]["elapsed_seconds"] >= 3.0
assert not host.branch_initialization.reports
assert not host.execution.zero_references
assert not host.finalization.started
finally:
release.set()
host.close()
def test_late_previous_segment_image_does_not_consume_current_initialization(tmp_path, monkeypatch):
host, clock = start_diagnostic(tmp_path, monkeypatch)
try:
frame = before_initialization(host, clock)
monkeypatch.setattr(coordinator_module, "solve_initialization",
lambda request: pytest.fail("stale frame started initialization"))
before = host.raw_path.read_bytes()
host.receive_detections(DetectionInput("top", host._motion_started_ns-1, frame.detections))
assert host.raw_path.read_bytes() == before
assert not host.branch_initialization._requested
# Existing image confirmation alone cannot bypass task geometry.
assert not host._lock_joint_zeros(host._motion)
assert host._zero_reference_error == "motion_geometry_confirmation_pending"
finally:
host.close()
def test_motion_geometry_confirmation_cannot_open_the_feedback_stability_gate(tmp_path, monkeypatch):
host, clock = start_diagnostic(tmp_path, monkeypatch)
try:
frame = before_initialization(host, clock)
assert host._steady_capture_after_ns is None
host.receive_detections(frame)
assert host.branch_initialization.ready(host._motion)
assert host._steady_capture_after_ns is None
assert not host._steady_rows
# Camera callbacks between control ticks cannot create a stable SDK
# window or contribute a zero, even with a verified geometric model.
for _ in range(15):
clock.now += .001
host.receive_feedback(host.profile.command.names, host.last_command, host.ports.clock_ns())
host.receive_detections(projected_frame(host, "top", host.last_command))
assert not host._steady_rows
assert not host.execution.zero_references
assert not any(row["kind"] == "joint_zero_sample" for row in host.raw_records)
finally:
host.close()
@@ -1,63 +0,0 @@
"""Process transport preserves the pure solver and bounds failed workers."""
from dataclasses import replace
import multiprocessing
import json
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization
from linkerhand_calibration.runtime.branch_worker import IsolatedBranchSolver
from test_branch_initialization import arrival
def request():
profile = load_bundled_hand_profile("o6_right_8")
approach, zero, rows = arrival(profile)
owner = BranchInitialization(profile, image_geometry=False)
owner.begin(approach, session_epoch=1, motion_version=7)
for row in rows:
owner.observe(row)
owner.begin(zero, session_epoch=1, motion_version=8)
return owner.request("top", zero, session_epoch=1, motion_version=8)
def test_process_returns_the_same_resolution_and_source_diagnostics(tmp_path):
item = request()
expected = solve_initialization(item)
path = tmp_path / "solver.jsonl"
worker = IsolatedBranchSolver(path)
try:
actual = worker(item)
assert actual == expected and actual.resolved
event = json.loads(path.read_text())
assert event["event"] == "worker_solve_finished"
assert event["source_frames"] == len(item.frames)
assert event["thread_cpu_seconds"] > 0
invalid = replace(item, invalid_reason="missing_motion_images")
assert worker(invalid).reason == "missing_motion_images"
finally:
worker.close()
assert not multiprocessing.active_children()
def test_timeout_and_closed_worker_never_leave_a_child_running():
item = request()
worker = IsolatedBranchSolver(timeout_seconds=0.)
assert worker(item).reason == "image_motion_solver_timeout"
assert not multiprocessing.active_children()
worker.close()
assert worker(item).reason == "image_motion_solver_cancelled"
def test_training_return_request_uses_the_same_isolated_transport(tmp_path):
from linkerhand_calibration.runtime.chain_reference import ChainReferenceRequest, ChainReferenceResult
item = ChainReferenceRequest('pinky_mcp_pitch_side', 'side', ('pinky_mcp_pitch',),
1, 7, 1, (), (), (), (), invalid_reason='training_reference_arc_missing', image_geometry=True)
worker = IsolatedBranchSolver(tmp_path/'training_reference.jsonl')
try:
actual = worker(item)
assert isinstance(actual, ChainReferenceResult)
assert not actual.resolved and actual.reason == item.invalid_reason
finally:
worker.close()
assert not multiprocessing.active_children()
@@ -1,163 +0,0 @@
"""Source-invariant constraints, recorded ambiguity and independent image truth."""
from dataclasses import asdict, replace
import gzip
import hashlib
import json
from pathlib import Path
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry
from linkerhand_calibration.core.geometry.tag_pose.cad_image_model import CadImageMotionModel
from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points
from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionEvidenceFrame, MotionRelation, RoleCandidates
from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageMotionFrame, ImageRoleObservation
from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import (
resolve_image_motion, select_image_motion_frame, image_motion_model_from_dict,
)
from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.product import load_product_config
from linkerhand_calibration.profiles.observations import compile_parallel_axis_geometry
from linkerhand_calibration.runtime.motion_provenance import validate_source_geometry
from test_production_image_motion import recorded_image_frames
def product(name):
return load_product_config(Path(__file__).resolve().parents[1]/f"config/{name}_right_product.yaml", check_can=False)
@pytest.fixture(scope="module")
def recorded():
raw=json.loads(gzip.decompress((Path(__file__).parent/"fixtures/o6_pinky_preparation_160040.json.gz").read_bytes()))
frames=recorded_image_frames(raw["frames"])
relations=tuple(MotionRelation(**value) for value in raw["relations"])
config=product("o6");profile=config.calibration_contract.typed_profile
constraints=compile_parallel_axis_geometry(profile,UrdfKinematicModel(config.source_urdf),relations)
result=resolve_image_motion(frames,relations,geometry_constraints=constraints,
source_urdf_sha256=raw["source_urdf_sha256"])
return config,frames,relations,constraints,result
def test_recorded_ambiguity_resolves_with_unchanged_cad_and_original_gates(recorded):
config,frames,relations,constraints,result=recorded
assert len(frames)==104
assert constraints[0].distance_m==pytest.approx(.03699959361344391,abs=1e-12)
assert hashlib.sha256(config.source_urdf.read_bytes()).hexdigest()==result.model.source_urdf_sha256
free=resolve_image_motion(frames,relations)
assert not free.resolved and free.reason=="image_motion_families_not_distinguishable"
assert result.resolved,result.reason
assert len(result.hypotheses)==4
assert all(h.converged and h.training_solver_evaluations<=150 for h in result.hypotheses)
assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns)
assert result.model.maximum_reprojection_error_px==1.5 and result.model.reprojection_tie_px==.03
assert image_motion_model_from_dict(asdict(result.model))==result.model
assert isinstance(result.model,CadImageMotionModel)
for frame in frames[1::8]:
measured=select_image_motion_frame(result.model,frame)
assert measured.resolved,measured.reason
assert max(q.maximum_frame_rms_px for q in measured.quality)<1.5
@pytest.mark.parametrize("change", ["distance","axis","source","declared_distance"])
def test_changed_geometry_or_source_cannot_become_frozen_evidence(recorded,change):
config,_,_,_,result=recorded
payload=json.loads(json.dumps(asdict(result.model)))
if change=="distance":payload["geometry"][1]["pivot_parent_xyz_m"][0]+=.01
elif change=="axis":payload["geometry"][1]["axis_parent_xyz"]=payload["geometry"][0]["axis_parent_xyz"]
elif change=="declared_distance":payload["constraints"][0]["distance_m"]+=.01
else:payload["source_urdf_sha256"]="f"*64
if change!="source":
with pytest.raises(ValueError,match="source_axis"):
image_motion_model_from_dict(payload)
else:
from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256
record={"kind":"motion_branch_initialization","status":"resolved","frozen_image_model":payload,
"image_model_sha256":image_model_sha256(payload)}
with pytest.raises(ValueError,match="source_geometry_changed"):
validate_source_geometry(config.calibration_contract.typed_profile,config.source_urdf,[record])
@pytest.mark.parametrize("name", ["g20","l6","o6","o12"])
def test_compiled_constraints_remain_invariant_under_independent_joint_zeros(name):
config=product(name);profile=config.calibration_contract.typed_profile
model=UrdfKinematicModel(config.source_urdf)
for task in profile.motion.tasks:
relations=tuple(MotionRelation(joint,profile.measurement.measurements[joint].parent_role,
profile.measurement.measurements[joint].child_role) for joint in task.joints)
for constraint in compile_parallel_axis_geometry(profile,model,relations):
angles={joint:.11+.013*i for i,joint in enumerate(model.joints)}
transforms=[model.link_transform(joint,zero_offsets=angles,joint_angles=angles,
independent_mimic_angles=True) for joint in (constraint.parent_joint,constraint.child_joint)]
a,b=transforms
axis=a[:3,:3]@model.joints[constraint.parent_joint].axis
other=b[:3,:3]@model.joints[constraint.child_joint].axis
np.testing.assert_allclose(other,constraint.axis_sign*axis,atol=1e-8)
delta=b[:3,3]-a[:3,3]
assert np.linalg.norm(delta-axis*(axis@delta))==pytest.approx(constraint.distance_m,abs=1e-10)
def synthetic_frames(sign, hinges=2):
frames=[];truth=[]
camera=np.array([[2000.,0,800.],[0,2000.,600.],[0,0,1.]])
root_r=Rotation.from_euler("xyz",(.2,-.15,.1));root_t=np.array([.0,.0,.8])
mount_r=[Rotation.from_euler("xyz",(.13,.22,-.1)),Rotation.from_euler("xyz",(-.2,.1,.3))]
mount_t=[np.array([.01,.015,.004]),np.array([.012,-.017,.006])]
for i,q in enumerate(np.linspace(0,1.4,49)):
first_r=root_r*Rotation.from_rotvec([0,0,q])
first_t=root_t+root_r.apply([.005,.012,.008])
second_r=first_r*Rotation.from_rotvec([0,0,sign*(.6*q+.15*q*q)])
second_t=first_t+first_r.apply([.037,0,.015])
actual=[("root",root_r,root_t),("moving",first_r*mount_r[0],first_t+first_r.apply(mount_t[0])),
("terminal",second_r*mount_r[1],second_t+second_r.apply(mount_t[1]))]
if hinges == 3:
third_r = second_r * Rotation.from_rotvec([0, 0, -sign * (.4*q + .12*q*q)])
third_t = second_t + second_r.apply([.028, 0, -.005])
actual.append(("tip", third_r * Rotation.from_euler("xyz", (.08, -.25, .19)),
third_t + third_r.apply([.012, .008, -.002])))
observations=[];candidates=[];poses={}
for role,r,t in actual:
points=(r.apply(square_object_points(.016))+t)@camera.T
pixels=points[:,:2]/points[:,2:]
observations.append(ImageRoleObservation(role,.016,tuple(map(tuple,pixels))))
poses[role]=SquareTagPose(tuple(r.as_quat()),tuple(t),0.)
noisy=t+([0,0,.003*np.sin(4*q)] if role!="root" else np.zeros(3))
candidates.append(RoleCandidates(role,(SquareTagPose(tuple(r.as_quat()),tuple(noisy),.1),),role=="root"))
frames.append(ImageMotionFrame(MotionEvidenceFrame(1000000000+i*50000000,tuple(candidates)),
tuple(map(tuple,camera)),tuple(observations)))
truth.append(poses)
return tuple(frames),truth
@pytest.mark.parametrize("sign", [-1,1])
@pytest.mark.parametrize("hinges", [2, 3])
def test_independent_images_recover_known_motion_with_parallel_or_opposite_axes(sign, hinges):
frames,truth=synthetic_frames(sign, hinges)
relations=(MotionRelation("first","root","moving"),MotionRelation("second","moving","terminal"))
constraints=(ParallelAxisGeometry("first","second",sign,.037),)
if hinges == 3:
relations += (MotionRelation("third", "terminal", "tip"),)
constraints += (ParallelAxisGeometry("second", "third", -1, .028),)
result=resolve_image_motion(frames,relations,geometry_constraints=constraints,
source_urdf_sha256="a"*64)
assert result.resolved,result.reason
for i in range(1,len(frames),8):
selected=select_image_motion_frame(result.model,frames[i])
assert selected.resolved,selected.reason
for value in selected.selections:
actual=truth[i][value.role]
np.testing.assert_allclose(value.pose.translation_xyz_m,actual.translation_xyz_m,atol=1e-6)
assert (Rotation.from_quat(actual.quaternion_xyzw).inv()*Rotation.from_quat(value.pose.quaternion_xyzw)).magnitude()<1e-5
def test_geometry_incompatible_with_images_cannot_be_forced_through_acceptance():
frames, _ = synthetic_frames(1)
relations = (MotionRelation("first", "root", "moving"), MotionRelation("second", "moving", "terminal"))
result = resolve_image_motion(frames, relations,
geometry_constraints=(ParallelAxisGeometry("first", "second", 1, .08),),
source_urdf_sha256="a"*64)
assert not result.resolved
assert all(hypothesis.reason == "image_motion_reprojection_failed" for hypothesis in result.hypotheses)
@@ -1,177 +0,0 @@
import copy
import pytest
from linkerhand_calibration.calibrated_joint_state_bridge import (
G20_COMMAND_NAMES,
G20_URDF_JOINT_NAMES,
CalibratedCommandMapper,
default_input_topic,
)
from linkerhand_calibration.full_hand import (
JointCurveFit,
build_compact_payload,
get_hand_calibration_profile,
)
from linkerhand_calibration.urdf_zero import (
get_zero_calibration_profile,
)
def _payload(side: str = "right") -> dict:
profile = get_hand_calibration_profile(side)
curve = [round((255 - command) * 0.001, 8) for command in range(256)]
joints = {}
for name, spec in profile.joint_specs.items():
joint = {
"motor_index": spec.motor_index,
"angle_rad": list(curve),
}
if spec.active:
joint["zero_command_u8"] = 255
joint["zero_angles"] = {"urdf_zero_offset_rad": 0.0}
else:
joint["passive"] = True
if spec.source_joint is not None:
joint["source_joint"] = spec.source_joint
joints[name] = joint
return {
"schema_version": 4,
"model": "G20",
"side": side,
"serial_number": "TEST_RIGHT" if side == "right" else "TEST_LEFT",
"angle_unit": "rad",
"command_range": [0, 255],
"baseline_command_u8": [255] * 20,
"joints": joints,
"quality": {
"passed": True,
"validation_mae_rad": 0.01,
"validation_p95_rad": 0.02,
},
}
def test_mapper_uses_each_joint_motor_and_includes_passive_joints() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
result = dict(
zip(G20_URDF_JOINT_NAMES, mapper.map_positions(command))
)
assert result["thumb_cmc_pitch"] == pytest.approx(0.255)
assert result["thumb_cmc_roll"] == pytest.approx(0.250)
assert result["thumb_cmc_yaw"] == pytest.approx(0.245)
assert result["thumb_mcp"] == pytest.approx(0.240)
assert result["thumb_ip"] == pytest.approx(0.240)
assert result["pinky_pip"] == pytest.approx(0.236)
assert result["pinky_dip"] == pytest.approx(0.236)
def test_mapper_uses_names_instead_of_message_order() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
names = list(reversed(G20_COMMAND_NAMES))
positions = list(reversed(command))
assert mapper.map_positions(positions, names) == mapper.map_positions(command)
def test_mapper_rejects_wrong_side_and_unapproved_payload() -> None:
with pytest.raises(ValueError, match="does not match"):
CalibratedCommandMapper(_payload("left"), expected_side="right")
payload = copy.deepcopy(_payload())
payload["quality"]["passed"] = False
with pytest.raises(ValueError, match="quality.passed"):
CalibratedCommandMapper(payload, expected_side="right")
def test_mapper_rejects_incomplete_named_command() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
with pytest.raises(ValueError, match="missing named channels"):
mapper.map_positions([255.0], ["thumb_cmc_pitch"])
def test_right_19_schema_v4_mapper_uses_requested_command_midpoint_curve() -> None:
profile = get_hand_calibration_profile("right", "g20_right_15")
zero = get_zero_calibration_profile("right", "g20_right_15")
baseline = [255] * 20
baseline[6:10] = [127] * 4
fits = {}
diagnostics = {}
for name, spec in profile.joint_specs.items():
zero_command = baseline[spec.motor_index]
average = tuple(
0.001 * (zero_command - value) for value in range(256)
)
decreasing = tuple(1.1 * value for value in average)
increasing = tuple(0.9 * value for value in average)
fits[name] = JointCurveFit(
average,
decreasing,
increasing,
{},
0.0,
0.0,
{"rotation_orthogonal_rms_rad": 0.0, "arc_rad": 0.5},
)
diagnostics[name] = {
"cycle_travel_rad": [0.25] * 4,
"cycle_travel_range_rad": 0.0,
"baseline_hysteresis_by_cycle_rad": [0.0] * 4,
"holdout_cycle": 3,
"holdout_cycle_mae_rad": 0.0,
"holdout_cycle_max_rad": 0.0,
}
payload = build_compact_payload(
serial_number="TEST_RIGHT_V5",
measured_fits=fits,
urdf_zero_offsets_rad={name: 0.0 for name in profile.active_joints},
validation_errors_rad=[0.0],
passed=True,
baseline=baseline,
side="right",
layout_id="g20_right_15",
zero_uncertainty_rad={name: 0.0 for name in zero.direct_zero_joints},
zero_cycle_offsets_rad={
name: (0.0, 0.0, 0.0) for name in zero.direct_zero_joints
},
zero_observers=zero.offset_observer_joint,
artifact_hashes={
"source_urdf_sha256": "0" * 64,
"camera_extrinsics_sha256": "1" * 64,
"corrected_urdf_sha256": "2" * 64,
},
cross_view_roll_metrics={
f"{finger}_mcp_roll": {"angle_rad_rms_difference_rad": 0.0}
for finger in ("index", "middle", "ring", "pinky")
},
joint_dynamic_diagnostics=diagnostics,
zero_geometry_diagnostics={
"training_cycles": (0, 1, 2),
"validation_cycle": 3,
"axis_line_rms_m": 0.0,
"validation_line_error_by_joint_m": {},
},
)
mapper = CalibratedCommandMapper(payload, expected_side="right")
command = [255.0] * 20
command[0] = 100.0
mapped = dict(zip(G20_URDF_JOINT_NAMES, mapper.map_positions(command)))
assert mapper.input_domain == "command_u8"
assert mapper.layout_id == "g20_right_19"
# midpoint(1.1, 0.9) is the original 0.001-rad/u8 curve.
assert mapped["thumb_cmc_pitch"] == pytest.approx(0.155)
assert payload["joints"]["pinky_dip"]["passive"] is True
assert "source_joint" not in payload["joints"]["pinky_dip"]
def test_default_topics_use_the_g20_sdk_namespace() -> None:
assert default_input_topic("right", "command_u8") == (
"/g20/cb_right_hand_control_cmd"
)
assert default_input_topic("right", "feedback_u8") == (
"/g20/cb_right_hand_state"
)
@@ -1,161 +0,0 @@
"""Measured amplitude may replace nominal CAD limits, never safety evidence."""
from dataclasses import replace
import hashlib
from pathlib import Path
from types import SimpleNamespace
import pytest
from linkerhand_calibration.core.domain.profile import JointLimitPolicy, validate_profile
from linkerhand_calibration.core.domain.result import JointMapping
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.core.urdf.acceptance import validate_artifact_structure
from linkerhand_calibration.core.urdf.limits import MotionRangeEvidence, compile_motion_ranges, validate_range_evidence
from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.profiles.export import write_hand_profile
from linkerhand_calibration.profiles.loader import load_hand_profile
SOURCE = Path(__file__).resolve().parents[1] / "urdf/o6_right/linkerhand_o6_right.urdf"
PITCH, IP = "rh_thumb_cmc_pitch", "rh_thumb_ip"
def evidence(name, upper, *, delta=0., safety=None):
original = UrdfKinematicModel(SOURCE).joints[name]
return MotionRangeEvidence(name, name, True, (original.lower, original.upper),
(0., upper), (0., upper), (0., upper), delta, 255., (0, 1, 2),
("front:100", "front:200"), ("front:10",), "calibrated_motion_envelope",
"fixture nominal CAD amplitude policy", safety, "fixture independent hardware bound" if safety else "")
def plan(evidence_by_joint=None, *, authorized=None):
profile = load_bundled_hand_profile("o6_right_8")
return build_standard_correction_plan(source_urdf=SOURCE,
source_sha256=hashlib.sha256(SOURCE.read_bytes()).hexdigest(),
zero_offsets_rad={PITCH: 0., IP: 0.},
measured_ranges_output_rad={PITCH: (0., .60), IP: (0., 1.16)},
range_evidence=evidence_by_joint,
authorized_fields=profile.urdf_authorized_fields if authorized is None else authorized)
def test_nominal_range_requires_explicit_training_evidence_and_authorization(tmp_path):
with pytest.raises(ValueError, match="exceeds source mechanical range"):
plan()
entries = {PITCH: evidence(PITCH, .60), IP: evidence(IP, 1.16)}
original = SOURCE.read_bytes()
result = plan(entries)
output = tmp_path / "corrected.urdf"
result.write(SOURCE, output)
before, after = UrdfKinematicModel(SOURCE), UrdfKinematicModel(output)
assert SOURCE.read_bytes() == original
assert after.joints[IP].upper == 1.16
assert (after.joints[IP].origin == before.joints[IP].origin).all()
assert result.range_evidence[IP].training_cycles == (0, 1, 2)
rights = dict(load_bundled_hand_profile("o6_right_8").urdf_authorized_fields)
rights[IP] = frozenset({"mimic.multiplier", "mimic.offset"})
with pytest.raises(ValueError, match="absent from Profile authorization"):
plan(entries, authorized=rights)
@pytest.mark.parametrize("mutation,reason", [
({"training_cycles": (0, 1, 2, 3)}, "invalid_training"),
({"training_sample_ids": ()}, "invalid_training"),
({"reference_sample_ids": ()}, "invalid_training"),
({"output_limits_rad": (0., 1.17)}, "differs_from_training"),
({"independent_safety_limits_cad_rad": (0., 1.1), "safety_evidence_source": "known hardware"}, "independent_safety"),
({"independent_safety_limits_cad_rad": (0., 2.), "safety_evidence_source": ""}, "independent_safety"),
])
def test_evidence_cannot_use_holdout_pad_or_override_independent_safety(mutation, reason):
item = replace(evidence(IP, 1.16), **mutation)
with pytest.raises(ValueError, match=reason):
validate_range_evidence(IP, UrdfKinematicModel(SOURCE).joints[IP], 0., (0., 1.16), item)
def test_independent_safety_is_checked_in_cad_coordinates_once():
original = UrdfKinematicModel(SOURCE).joints[PITCH]
item = evidence(PITCH, .6, delta=.1, safety=(.09, .71))
validate_range_evidence(PITCH, original, .1, (0., .6), item)
with pytest.raises(ValueError, match="independent_safety"):
validate_range_evidence(PITCH, original, .1, (0., .6),
replace(item, independent_safety_limits_cad_rad=(.09, .69)))
def test_final_readback_needs_trusted_exact_envelope_not_payload_permission(tmp_path):
import xml.etree.ElementTree as ET
entries = {PITCH: evidence(PITCH, .60), IP: evidence(IP, 1.16)}
result = plan(entries)
output = tmp_path / "corrected.urdf"
result.write(SOURCE, output)
profile = load_bundled_hand_profile("o6_right_8")
from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint
model = UrdfKinematicModel(output)
payload = {"format": "unified_calibration_v3", "schema_version": 3,
"input_unit": "u8", "baseline_command": [255]*6,
"joints": {name: {"sdk_channel": channel_for_joint(profile, name),
"angle_rad": [joint.upper*(255-code)/255 for code in range(256)]}
for name, joint in model.joints.items() if joint.kind != "fixed"}}
args = dict(source_urdf=SOURCE, corrected_urdf=output,
source_sha256=result.source_sha256, authorized_fields=profile.urdf_authorized_fields,
payload=payload, zero_offsets_rad={PITCH: 0., IP: 0.})
assert validate_artifact_structure(**args, range_evidence=entries)
with pytest.raises(ValueError, match="exceeds source mechanical range"):
validate_artifact_structure(**args)
tree = ET.parse(output)
tree.find(f".//joint[@name='{PITCH}']/limit").set("upper", ".601")
tree.write(output)
with pytest.raises(ValueError, match="differs_from_training"):
validate_artifact_structure(**args, range_evidence=entries)
def test_mimic_does_not_expand_measured_envelope_to_make_it_pass():
with pytest.raises(ValueError, match="differs_from_training"):
plan({PITCH: evidence(PITCH, .6), IP: evidence(IP, 1.09)})
# A validly supported 1.09 rad child cannot accommodate the unchanged
# standard mimic's 1.86 * .6 range; the plan must reject, never add padding.
profile = load_bundled_hand_profile("o6_right_8")
with pytest.raises(ValueError, match="mimic reachable range exceeds"):
build_standard_correction_plan(source_urdf=SOURCE,
source_sha256=hashlib.sha256(SOURCE.read_bytes()).hexdigest(),
zero_offsets_rad={PITCH: 0., IP: 0.},
measured_ranges_output_rad={PITCH: (0., .6), IP: (0., 1.09)},
range_evidence={PITCH: evidence(PITCH, .6), IP: evidence(IP, 1.09)},
authorized_fields=profile.urdf_authorized_fields)
def test_profile_roundtrip_keeps_explicit_policy_and_dip_origin_protection(tmp_path):
profile = load_bundled_hand_profile("o6_right_8")
output = tmp_path / "profile.yaml"
write_hand_profile(profile, output)
assert load_hand_profile(output) == profile
assert len(profile.urdf_limit_policies) == 11
assert all("origin.rpy" not in profile.urdf_authorized_fields[name] for name in profile.zero.passive_joints)
policies = dict(profile.urdf_limit_policies)
policies[IP] = JointLimitPolicy("calibrated_motion_envelope", "source policy", (0., 2.), "")
with pytest.raises(ValueError, match="safety bounds"):
validate_profile(replace(profile, urdf_limit_policies=policies))
def test_transferred_envelope_keeps_donor_training_and_ignores_holdout_errors():
donor, target = "rh_pinky_dip", "rh_index_dip"
profile = load_bundled_hand_profile("o6_right_8")
profile = replace(profile, urdf_limit_policies={target: profile.urdf_limit_policies[target]})
def mapping(domain):
return JointMapping(target, 2, domain, (0., 255.), (1.5, 0.), (1.51, 0.), (1.49, 0.), donor)
fit = SimpleNamespace(curves={donor: SimpleNamespace(circle={"training_cycles": [0, 1, 2],
"training_sample_ids": ["side:100", "side:200"]})},
joint_zero_references={donor: SimpleNamespace(baseline_command=255.,
samples=[SimpleNamespace(view="side", image_stamp_ns=10)])},
command_holdout_metrics={donor: {"training_cycles": [4], "holdout_cycle": 5,
"training_sample_ids": ["side:300", "side:400"], "maximum_deg": 2}},
output_mappings={target: mapping("feedback_u8")}, command_mappings={target: mapping("command_u8")},
zero_offsets_rad={target: 0.})
model = UrdfKinematicModel(SOURCE)
before = compile_motion_ranges(profile, model, fit)[target]
fit.command_holdout_metrics[donor]["maximum_deg"] = 100
after = compile_motion_ranges(profile, model, fit)[target]
assert before == after
assert before.source_joint == donor and before.independently_measured is False
assert before.output_limits_rad == (0., 1.51)
assert before.training_sample_ids == ("side:100", "side:200")
@@ -1,294 +0,0 @@
"""Session/observation/publication races exercised through real coordinator ports."""
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
import json
from threading import Event
from types import SimpleNamespace
import pytest
from linkerhand_calibration.runtime.artifacts.controller import FinalizationController
from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher
from linkerhand_calibration.runtime.inputs import DetectionInput
from linkerhand_calibration.runtime.motion_execution import MotionCommand
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from runtime_host_fixture import coordinator_fixture, ready
@pytest.mark.parametrize("change", ["start", "pause", "abort", "motion", "steady", "joint_zero", "fit"])
def test_late_pose_result_cannot_cross_session_or_motion_boundary(tmp_path, monkeypatch, change):
host, clock = coordinator_fixture(tmp_path)
entered, release = Event(), Event()
try:
ready(host, clock)
if change != "start":
assert host.start().success
host.execution.session.phase = Phase.SWEEP
host._motion = MotionCommand(change if change in {"steady", "joint_zero"} else "sweep",
host.profile.command.baseline_values, 1., task_key=host.profile.motion.tasks[0].key,
command_index=0, cycle=0, direction="decreasing") if change != "start" else None
host._steady_capture_after_ns = host.ports.clock_ns()
def consume(*args, **kwargs):
entered.set()
assert release.wait(2)
return [{"kind": "late_sample"}], None
monkeypatch.setattr(host.capture, "consume", consume)
with ThreadPoolExecutor(1) as pool:
future = pool.submit(host.receive_detections, DetectionInput(
host.profile.vision.view_names[0], host.ports.clock_ns(), ()))
assert entered.wait(2)
# These calls must complete while the expensive pose solve is blocked.
if change == "start":
assert host.start().success
elif change == "pause":
host._pause("test_visual_fault")
elif change == "abort":
assert host.abort().success
elif change == "motion":
with host.step_data_lock:
host._motion = replace(host._motion, cycle=1)
elif change == "fit":
monkeypatch.setattr(host.finalization, "start", lambda _: None)
with host.step_data_lock:
host.execution.session.phase = Phase.FIT
host._finalize()
else:
with host.step_data_lock:
host._steady_capture_after_ns = None
release.set()
future.result(timeout=2)
assert [row["kind"] for row in host.raw_records] == ["session_start"]
assert host._unit_rows == []
finally:
release.set()
host.close()
def begin_fit(host, clock):
ready(host, clock)
assert host.start().success
clock.now += .01
host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns())
host.execution.session.phase = Phase.FIT
host.last_command = tuple(host.profile.command.baseline_values)
host.tick()
assert host.finalization.started
def completed_fit(**kwargs):
assert kwargs["publish"] is False
for event in ("fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"):
kwargs["phase_changed"](event)
return ({"format": "unified_calibration_v2", "joints": {}}, None,
SimpleNamespace(path=kwargs["session_dir"]/"corrected.urdf", staged_release="staged"))
def replace_finalizer(host, finalizer):
host.finalization.close()
host.finalization = FinalizationController(host.profile, host.parameters.session_dir, finalizer=finalizer)
@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"])
def test_feedback_and_status_continue_during_fit_failure_never_starts_motion(tmp_path, model):
host, clock = coordinator_fixture(tmp_path, model=model)
entered, release = Event(), Event()
def fit(**kwargs):
entered.set()
assert release.wait(2)
raise ValueError("synthetic_fit_failure")
try:
replace_finalizer(host, fit)
begin_fit(host, clock)
assert entered.wait(2)
for _ in range(10):
clock.now += .01
host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns())
host.tick()
assert host.snapshot().calibration.phase == "FIT"
assert len(host.state_history) == 11
release.set()
with pytest.raises(ValueError, match="synthetic_fit_failure"):
host.finalization.worker._future.result(timeout=2)
host.tick()
assert host.execution.session.phase == Phase.FAILED
assert "fit_or_publication_failed:synthetic_fit_failure" == host.reason
diagnostic = json.loads((host.parameters.session_dir / "failure_diagnostic.json").read_text())
assert diagnostic["stage"] == "FIT"
assert diagnostic["raw_samples"] == str(host.raw_path)
assert diagnostic["reason"] == "synthetic_fit_failure"
assert 'ValueError: synthetic_fit_failure' in diagnostic["traceback"]
rows = [json.loads(line) for line in host.raw_path.read_text().splitlines()]
assert rows[-1]["kind"] == "finalization_failed"
assert not any(row["kind"] == "paused" for row in rows)
assert host.snapshot().calibration.outputs["failure_diagnostic"].endswith("failure_diagnostic.json")
assert not host.final_json and not host.final_urdf
# Terminal failure must survive late sensor events and operator cleanup.
host._pause("late_observation_failure")
assert not host.abort().success
assert host.execution.session.phase == Phase.FAILED
count = len(clock.positions)
host.tick()
assert len(clock.positions) == count
assert all(command == tuple(host.profile.command.baseline_values) for command in clock.positions)
finally:
release.set()
host.close()
def test_finalization_protocol_failure_is_durable_and_cannot_publish(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
commits = []
def invalid_order(**kwargs):
kwargs["phase_changed"]("urdf_validated")
return ({}, None, SimpleNamespace(staged_release="must_not_commit"))
try:
replace_finalizer(host, invalid_order)
monkeypatch.setattr(ArtifactPublisher, "commit", lambda _, staged: commits.append(staged))
begin_fit(host, clock)
host.finalization.worker._future.result(timeout=2)
host.tick()
assert host.execution.session.phase == Phase.FAILED
assert host.reason.startswith("finalization_protocol_error:")
assert not commits
diagnostic = json.loads((host.parameters.session_dir / "failure_diagnostic.json").read_text())
assert diagnostic["state"] == "FAILED" and diagnostic["stage"] == "FIT"
finally:
host.close()
def test_fit_uses_closed_capture_while_feedback_and_abort_remain_live(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
entered, release = Event(), Event()
def fit(**kwargs):
entered.set()
assert release.wait(2)
return completed_fit(**kwargs)
try:
replace_finalizer(host, fit)
begin_fit(host, clock)
assert entered.wait(2)
before = host.raw_path.read_bytes()
view = host.required_observation_views[0]
monkeypatch.setattr(host.capture, "consume", lambda *a, **k: pytest.fail("capture is closed"))
monkeypatch.setattr(host.cameras, "accept", lambda *a, **k: pytest.fail("camera evidence is frozen"))
host.receive_detections(DetectionInput(view, host.ports.clock_ns(), ()))
host.receive_camera_info(view, None)
assert host.raw_path.read_bytes() == before
assert host.execution.session.phase == Phase.FIT
clock.now += .02
host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns())
assert host.state_history[-1].stamp_ns == host.ports.clock_ns()
assert host.abort().success
assert host.execution.session.phase == Phase.ABORTED
finally:
release.set()
host.close()
@pytest.mark.parametrize("abort_first", [True, False])
def test_abort_and_artifact_commit_are_mutually_exclusive(tmp_path, monkeypatch, abort_first):
host, clock = coordinator_fixture(tmp_path)
committing, release, abort_entered, abort_done = Event(), Event(), Event(), Event()
commits = []
def commit(publisher, staged):
committing.set()
assert release.wait(2)
commits.append(staged)
monkeypatch.setattr(ArtifactPublisher, "commit", commit)
try:
replace_finalizer(host, completed_fit)
begin_fit(host, clock)
host.finalization.worker._future.result(timeout=2)
if abort_first:
assert host.abort().success
host.tick()
assert not commits
assert host.execution.session.phase == Phase.ABORTED
else:
with ThreadPoolExecutor(2) as pool:
tick = pool.submit(host.tick)
assert committing.wait(2)
def abort():
abort_entered.set()
result = host.abort()
abort_done.set()
return result
aborted = pool.submit(abort)
assert abort_entered.wait(2)
assert not abort_done.wait(.02)
release.set()
tick.result(timeout=2)
assert not aborted.result(timeout=2).success
assert host.execution.session.phase == Phase.COMPLETE
assert host.final_json.endswith("o6_right_OFFLINE_COORDINATOR_partial_calibration.json")
assert host.final_urdf.endswith("corrected.urdf")
host.tick()
assert commits == ["staged"]
finally:
release.set()
host.close()
def test_finalizer_last_events_are_drained_after_future_is_ready(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
commits = []
try:
replace_finalizer(host, completed_fit)
begin_fit(host, clock)
host.finalization.worker._future.result(timeout=2)
events = tuple(host.finalization._events.get() for _ in range(4))
original = host.finalization.worker.finish_if_ready
def finish(commit):
# Simulate worker completion between poll's first drain and done().
for event in events:
host.finalization._events.put(event)
return original(commit)
monkeypatch.setattr(host.finalization.worker, "finish_if_ready", finish)
monkeypatch.setattr(ArtifactPublisher, "commit", lambda _, staged: commits.append(staged))
host.tick()
assert host.execution.session.phase == Phase.COMPLETE
assert commits == ["staged"]
finally:
host.close()
def test_paused_session_does_not_issue_motion_when_timer_acquires_lock(tmp_path):
host, clock = coordinator_fixture(tmp_path)
tick_entered = Event()
try:
ready(host, clock)
assert host.start().success
with ThreadPoolExecutor(1) as pool:
with host.step_data_lock:
def tick():
tick_entered.set()
host.tick()
future = pool.submit(tick)
assert tick_entered.wait(2)
host._pause("test_visual_fault")
count = len(clock.positions)
future.result(timeout=2)
assert host.execution.session.phase == Phase.PAUSED
assert len(clock.positions) == count
finally:
host.close()
def test_sdk_binding_uses_supplied_clock_for_transient_health_recovery(tmp_path):
host, clock = coordinator_fixture(tmp_path, model="o12")
try:
ready(host, clock)
assert host.sdk_adapter.health().safe
clock.health_receiver("malformed")
assert not host.sdk_adapter.health().active_faults
clock.now += 1.01
assert host.sdk_adapter.health().active_faults == ("health_report_unparseable",)
clock.health_receiver('{"position_mode": true, "active_faults": []}')
host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values,
host.ports.clock_ns())
assert host.sdk_adapter.health().safe
finally:
host.close()
@@ -1,222 +0,0 @@
"""Pre-Start evidence must not confuse protected files with physical calibration."""
import json
from dataclasses import FrozenInstanceError, asdict, replace
import numpy as np
import pytest
from linkerhand_calibration.core.geometry.extrinsics import (
CameraCalibrationIdentity,
CameraExtrinsics,
camera_info_fingerprint,
)
from linkerhand_calibration.runtime.camera_preflight import CameraPreflight
from linkerhand_calibration.runtime.cameras import CameraObservations
from linkerhand_calibration.runtime.inputs import CameraModelInput
from linkerhand_calibration.runtime.snapshot import CameraReadiness
from linkerhand_calibration.runtime.status import render_status_zh
from runtime_host_fixture import coordinator_fixture, ready
def preflight_rows(host):
path = host.parameters.session_dir / "raw_samples.jsonl"
return [row for line in path.read_text().splitlines()
if (row := json.loads(line)).get("kind") == "camera_preflight"]
def saved_preflight(host):
return json.loads((host.parameters.session_dir / "camera_preflight.json").read_text())
def assert_physical_calibration_unverified(report):
for name in ("intrinsics", "extrinsics"):
assert report[name]["status"] == "unverified"
assert report[name]["reason"]
assert report[name]["suggestion"]
@pytest.mark.parametrize("detections_ready", [False, True])
def test_valid_camera_info_only_proves_configuration(detections_ready):
report = CameraPreflight.from_readiness({
"arbitrary_camera_name": CameraReadiness(True, detections_ready, ""),
})
payload = report.as_dict()
assert payload["configuration"]["arbitrary_camera_name"]["status"] == "passed"
assert_physical_calibration_unverified(payload)
with pytest.raises(FrozenInstanceError):
report.intrinsics = None
def test_configuration_failure_takes_precedence_over_old_ready_flags():
payload = CameraPreflight.from_readiness({
"front": CameraReadiness(True, True, "camera_info_does_not_match_extrinsics"),
"side": CameraReadiness(False, True, ""),
"top": CameraReadiness(True, False, ""),
}).as_dict()
assert payload["configuration"]["front"]["status"] == "failed"
assert "camera_info_does_not_match_extrinsics" in payload["configuration"]["front"]["reason"]
assert payload["configuration"]["front"]["suggestion"]
assert payload["configuration"]["side"]["status"] == "unverified"
assert payload["configuration"]["top"]["status"] == "passed"
assert_physical_calibration_unverified(payload)
@pytest.mark.parametrize("model", ["o6", "l6", "o12", "g20"])
def test_all_models_record_start_evidence_without_requiring_visible_finger_tags(tmp_path, model):
host, clock = coordinator_fixture(tmp_path, model=model)
try:
# The detector stream is alive, but no hand Tag or historical pose is supplied.
ready(host, clock)
report = host.snapshot().as_dict()["camera_preflight"]
assert all(item["status"] == "passed" for item in report["configuration"].values())
assert_physical_calibration_unverified(report)
before_start = saved_preflight(host)
assert before_start["kind"] == "camera_preflight"
assert before_start["stage"] == "before_start"
assert before_start["stamp_ns"] == host.ports.clock_ns()
assert before_start["protected_inputs"] == dict(host.parameters.protected_inputs)
assert before_start["expected_cameras"] == {
view: asdict(identity) for view, identity in host.parameters.extrinsics.cameras.items()
}
for key, value in report.items():
assert before_start[key] == value
assert not clock.positions
assert host.start().success
at_start = saved_preflight(host)
assert at_start["stage"] == "at_start"
assert_physical_calibration_unverified(at_start)
assert preflight_rows(host)[-1] == at_start
# Later freshness changes cannot rewrite the historical Start evidence.
clock.now += 3.0
assert host.snapshot().as_dict()["camera_preflight"] == report
assert saved_preflight(host) == at_start
finally:
host.close()
def test_unchanged_preflight_does_not_fill_the_sample_journal(tmp_path):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
original = preflight_rows(host)
for _ in range(5):
clock.now += 0.05
host.tick()
host.snapshot()
assert preflight_rows(host) == original
view = host.profile.vision.view_names[0]
host.cameras.errors[view] = "camera_info_does_not_match_extrinsics"
host.tick()
assert len(preflight_rows(host)) == len(original) + 1
assert saved_preflight(host)["configuration"][view]["status"] == "failed"
host.tick()
assert len(preflight_rows(host)) == len(original) + 1
finally:
host.close()
@pytest.mark.parametrize("fault", ["camera_info_stale", "camera_info_mismatch", "detector_stale"])
def test_start_rechecks_live_devices_and_records_rejection_before_motion(tmp_path, fault):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
view = host.profile.vision.view_names[0]
if fault == "camera_info_stale":
host.cameras.info_received_at[view] = clock.now - 2.01
expected = "unverified"
elif fault == "camera_info_mismatch":
host.cameras.errors[view] = "camera_info_does_not_match_extrinsics"
expected = "failed"
else:
host.cameras.detections_received_at[view] = clock.now - 2.01
expected = "passed"
positions, settings = list(clock.positions), list(clock.settings)
assert not host.start().success
assert not host.started
report = saved_preflight(host)
assert report["stage"] == "before_start"
assert report["configuration"][view]["status"] == expected
assert_physical_calibration_unverified(report)
assert preflight_rows(host)[-1] == report
assert clock.positions == positions
assert clock.settings == settings
finally:
host.close()
@pytest.mark.parametrize("state", ["READY", "WAIT_DEVICE", "BASELINE"])
def test_progress_distinguishes_configuration_from_unverified_physical_calibration(state):
report = CameraPreflight.from_readiness({"side": CameraReadiness(True, True, "")}).as_dict()
rendered = render_status_zh({"state": state, "phase": state, "camera_preflight": report})
assert "启动前相机配置:side=通过" in rendered
assert "启动前实测:光学内参=未验证;相对外参=未验证" in rendered
if state in {"READY", "WAIT_DEVICE"}:
assert "设备就绪不代表内外参实测通过" in rendered
assert "缺少依据时无法判断是否需重新标定" in rendered
assert report["intrinsics"]["suggestion"] in rendered
assert report["extrinsics"]["suggestion"] in rendered
else:
assert "检查依据:" not in rendered
@pytest.mark.parametrize("writer", ["atomic_write_json", "append_jsonl"])
def test_failed_start_evidence_write_cannot_start_motion(tmp_path, monkeypatch, writer):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
positions, settings = list(clock.positions), list(clock.settings)
def failed_write(*args, **kwargs):
raise OSError("synthetic_preflight_write_failure")
monkeypatch.setattr(f"linkerhand_calibration.runtime.coordinator.{writer}", failed_write)
with pytest.raises(OSError, match="synthetic_preflight_write_failure"):
host.start()
assert not host.started
assert clock.positions == positions
assert clock.settings == settings
finally:
host.close()
@pytest.mark.parametrize("changed_field", ["k", "d", "r", "p", "width"])
def test_real_camera_model_fingerprint_check_is_reported_without_physical_pass(tmp_path, changed_field):
message = CameraModelInput(
width=640, height=480,
k=(600., 0., 300., 0., 620., 220., 0., 0., 1.),
d=(.1, .01, 0., 0., 0.),
r=tuple(np.eye(3).flat),
p=(500., 0., 320., 0., 0., 510., 240., 0., 0., 0., 1., 0.),
)
identity = CameraCalibrationIdentity("synthetic_camera", message.width, message.height,
camera_info_fingerprint(width=message.width, height=message.height,
camera_matrix=message.k, distortion=message.d, rectification=message.r, projection=message.p))
extrinsics = CameraExtrinsics({"test_view": identity}, "test_view",
{"test_view": np.eye(4)}, {})
observations = CameraObservations(extrinsics, tmp_path / "raw.jsonl", lambda: 10.)
pauses = []
def accept_and_report(model):
observations.accept("test_view", model, started=False, pause=pauses.append, trackers={})
return CameraPreflight.from_readiness({"test_view": CameraReadiness(
"test_view" in observations.info_received_at, True,
observations.errors.get("test_view", ""))}).as_dict()
valid = accept_and_report(message)
assert valid["configuration"]["test_view"]["status"] == "passed"
assert_physical_calibration_unverified(valid)
old_value = getattr(message, changed_field)
new_value = old_value + 1 if changed_field == "width" else (old_value[0] + .01, *old_value[1:])
invalid = accept_and_report(replace(message, **{changed_field: new_value}))
assert invalid["configuration"]["test_view"]["status"] == "failed"
assert_physical_calibration_unverified(invalid)
assert not pauses # Before Start this is a device gate, not a running-session pause.
assert "test_view" not in observations.matrices
restored = accept_and_report(message)
assert restored["configuration"]["test_view"]["status"] == "passed"
assert_physical_calibration_unverified(restored)
@@ -1,206 +0,0 @@
"""Device clocks, transport delay and exposure centre are separate quantities."""
import gzip
import json
from pathlib import Path
import statistics
from types import SimpleNamespace
import pytest
from linkerhand_calibration.camera_timing import ClockLatch, DeviceFrameClock, MvsCameraTiming
T0 = 1_789_476_000_000_000_000
def latch(elapsed_ns, *, width=200_000, host_jump=0):
return ClockLatch(10_000_000_000+elapsed_ns//10,
T0+elapsed_ns-width//2+host_jump, T0+elapsed_ns+width//2+host_jump)
def initialized():
result = DeviceFrameClock(100_000_000)
result.update([latch(0),latch(100_000_000)])
return result
def test_transport_delay_does_not_change_exposure_timestamp():
for delivery_ns in (16_000_000, 120_000_000):
clock = initialized()
stamp = clock.timestamp(latch(200_000_000).device_tick,
received_ns=T0+200_000_000+delivery_ns, exposure_us=5000.)
assert stamp == T0+202_500_000
def test_counter_unit_mismatch_and_clock_jump_cannot_forge_new_timestamps():
wrong = DeviceFrameClock(1_000_000_000)
with pytest.raises(ValueError, match='unit mismatch'):
wrong.update([latch(0),latch(100_000_000)])
clock = initialized()
anchor = clock.anchor
with pytest.raises(ValueError, match='discontinuity'):
clock.update([latch(500_000_000,host_jump=10_000_000)])
assert clock.anchor == anchor
def test_frame_reset_duplicates_future_and_stale_clocks_are_rejected():
clock = initialized()
tick = latch(200_000_000).device_tick
clock.timestamp(tick,received_ns=T0+220_000_000,exposure_us=5000.)
for duplicate in (tick,tick-1):
with pytest.raises(ValueError,match='reset or duplicate'):
clock.timestamp(duplicate,received_ns=T0+240_000_000,exposure_us=5000.)
with pytest.raises(ValueError,match='future'):
clock.timestamp(latch(900_000_000).device_tick,received_ns=T0+240_000_000,exposure_us=5000.)
with pytest.raises(ValueError,match='fresh clock'):
clock.timestamp(latch(2_500_000_000).device_tick,received_ns=T0+2_520_000_000,exposure_us=5000.)
def test_uncertain_control_transaction_does_not_refresh_the_clock():
timing = MvsCameraTiming(None,None,lambda:0,exposure_us=5000.)
timing.frame_clock = initialized()
anchor = timing.frame_clock.anchor
timing._update([latch(500_000_000,width=20_000_000)])
assert timing.frame_clock.anchor == anchor
timing._update([latch(600_000_000)])
assert timing.frame_clock.anchor == latch(600_000_000)
def test_rejected_sync_preserves_failed_transaction_and_previous_anchor(tmp_path):
path = tmp_path/'clock.jsonl'
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000., journal_path=path)
timing.frame_clock = initialized()
anchor = timing.frame_clock.anchor
jumped = latch(500_000_000, host_jump=10_000_000)
with pytest.raises(ValueError, match='discontinuity'):
timing._update([jumped])
assert timing.frame_clock.anchor == anchor
row = json.loads(path.read_text()) # Evidence is flushed before invalidation.
assert row['kind'] == 'camera_clock_sync_rejected'
assert ClockLatch(**row['previous_anchor']) == anchor
assert [ClockLatch(**x) for x in row['latches']] == [jumped]
replay = DeviceFrameClock(row['ticks_per_second'])
replay.anchor = ClockLatch(**row['previous_anchor'])
with pytest.raises(ValueError, match='discontinuity'):
replay.update([ClockLatch(**x) for x in row['latches']])
timing.close()
def test_raw_clock_brackets_wall_clock_without_replacing_it(monkeypatch):
wall = iter((T0, T0+100_000))
raw = iter((20_000, 130_000))
camera = SimpleNamespace(MV_CC_SetCommandValue=lambda name: 0)
timing = MvsCameraTiming(camera, None, lambda: next(wall), exposure_us=5000.,
raw_clock_ns=lambda: next(raw))
monkeypatch.setattr(timing, '_integer', lambda name: 123456)
measured = timing._latch()
assert measured == ClockLatch(123456, T0, T0+100_000, 20_000, 130_000)
assert measured.midpoint_ns == T0+50_000
assert measured.half_width_ns == 50_000
@pytest.mark.parametrize('slew', [-.005, .005])
def test_measured_drift_refreshes_before_continuity_budget_is_spent(monkeypatch, slew):
from dataclasses import replace
def slewed(elapsed_ns):
return latch(elapsed_ns, width=100_000, host_jump=round(elapsed_ns*slew))
# At the former fixed interval smooth slew fails the unchanged gate.
fixed = DeviceFrameClock(100_000_000)
fixed.update([slewed(0), slewed(100_000_000)])
with pytest.raises(ValueError, match='discontinuity'):
fixed.update([slewed(600_000_000)])
now = [.1]
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: now[0])
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
timing.frame_clock = DeviceFrameClock(100_000_000)
timing._update([slewed(0), slewed(100_000_000)])
monkeypatch.setattr(timing, '_latch', lambda: slewed(round(now[0]*1e9)))
maximum_error = 0
for index in range(1, 1801): # 60 seconds at the real camera's 30 Hz.
now[0] = .1+index/30
timing.refresh_if_due()
elapsed = round((now[0]-.016)*1e9)
stamp = timing.frame_clock.timestamp(latch(elapsed).device_tick,
received_ns=slewed(round(now[0]*1e9)).midpoint_ns, exposure_us=5000.)
expected = T0+round((elapsed+2_500_000)*(1+slew))
maximum_error = max(maximum_error, abs(stamp-expected))
assert maximum_error < 1_000_000
assert .02 <= timing.frame_clock.refresh_interval_seconds < .1
# A discontinuity is still rejected, including after successful adaptation.
anchor = timing.frame_clock.anchor
bad = slewed(round((now[0]+.04)*1e9))
with pytest.raises(ValueError, match='discontinuity'):
timing._update([replace(bad, before_ns=bad.before_ns+10_000_000, after_ns=bad.after_ns+10_000_000)])
assert timing.frame_clock.anchor == anchor
def test_startup_anchor_age_is_charged_to_refresh_budget(monkeypatch):
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: 10.)
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
timing.frame_clock = DeviceFrameClock(100_000_000)
initial = [latch(i*20_000_000, width=100_000 if i == 0 else 200_000,
host_jump=i*100_000) for i in range(8)]
timing._update(initial)
assert timing.frame_clock.anchor == initial[0]
assert timing.next_refresh == 10. # Renew before acquiring the next frame.
@pytest.mark.parametrize('host_jump', [0, 10_000_000, -1_000_000_000])
def test_recovery_revalidates_clock_and_preserves_frame_time_order(monkeypatch, host_jump):
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
timing.frame_clock = initialized()
def frame(elapsed_ns):
tick = latch(elapsed_ns).device_tick
return SimpleNamespace(nDevTimeStampHigh=tick >> 32, nDevTimeStampLow=tick & 0xffffffff,
fExposureTime=5000., nFrameNum=1, nHostTimeStamp=0)
previous = timing.timestamp(frame(200_000_000), T0+220_000_000)
with pytest.raises(ValueError, match='discontinuity'):
timing._update([latch(400_000_000, host_jump=10_000_000)])
timing.invalidate('clock mismatch')
with pytest.raises(ValueError, match='no synchronized clock'):
timing.timestamp(frame(500_000_000), T0+520_000_000)
recovery_latches = iter(latch(500_000_000+i*20_000_000, host_jump=host_jump) for i in range(8))
monkeypatch.setattr(timing, '_integer', lambda name: 100_000_000)
monkeypatch.setattr(timing, '_latch', lambda: next(recovery_latches))
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.sleep', lambda seconds: None)
timing.refresh_if_due()
assert timing.frame_clock.anchor is not None
assert timing.last_stamp_ns == previous
if host_jump < 0:
with pytest.raises(ValueError, match='not increasing'):
timing.timestamp(frame(700_000_000), T0+720_000_000+host_jump)
assert timing.last_stamp_ns == previous
else:
assert timing.timestamp(frame(700_000_000), T0+720_000_000+host_jump) == T0+702_500_000+host_jump
assert timing.timestamp(frame(1_500_000_000), T0+1_520_000_000+host_jump) == T0+1_502_500_000+host_jump
@pytest.mark.parametrize('serial', ['DB2163742','DB2163749','DB2163739'])
def test_real_camera_counter_units_and_exposure_events(serial):
fixture = Path(__file__).parent/'fixtures/camera_clock_receipts.json.gz'
data = json.loads(gzip.decompress(fixture.read_bytes()))[serial]
assert all(all(status == 0 for status in item['statuses']) for item in data['events']['setup'])
latches = [ClockLatch(x['nodes']['DeviceTimestamp']['value'],x['before_ns'],x['read_return_ns'])
for x in data['latches']]
timing = MvsCameraTiming(None,None,lambda:0,exposure_us=5000.)
timing.frame_clock = DeviceFrameClock(data['latches'][0]['nodes']['DeviceTimestampIncrement']['value'])
timing._update(latches[:5])
clock = timing.frame_clock
exposure_events = [x['tick'] for x in data['events']['events'] if x['event']=='ExposureStart']
delays, updates = [], 5
for frame in data['frames']:
if frame['host_unix_ns'] <= latches[4].after_ns:
continue
if updates < len(latches) and latches[updates].after_ns <= frame['host_unix_ns']:
timing._update([latches[updates]])
updates += 1
stamp = clock.timestamp(frame['camera_tick'],received_ns=frame['host_unix_ns'],exposure_us=5000.)
delays.append((frame['host_unix_ns']-stamp)/1e6)
if exposure_events[0] <= frame['camera_tick'] <= exposure_events[-1]:
assert min(abs(tick-frame['camera_tick']) for tick in exposure_events)*10 <= 200
assert len(delays)>40
assert 12 < statistics.median(delays) < 16
@@ -1,87 +0,0 @@
"""Continuous observation logging must not fsync under the control lock."""
from concurrent.futures import ThreadPoolExecutor
from threading import Event
import pytest
from linkerhand_calibration.core.artifacts import storage
from linkerhand_calibration.runtime.inputs import DetectionInput
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from runtime_host_fixture import coordinator_fixture
from test_runtime_pose_branch_guard import begin_yaw, branch_event
def test_sweep_records_do_not_wait_for_disk_sync_and_pause_persists_them(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
release, entered = Event(), Event()
try:
begin_yaw(host, clock)
stamp = host.ports.clock_ns()
rows = [branch_event(stamp, reason="pose_branch_ambiguous"),
{"kind": "diagnostic_observation_frame", "view": "top", "image_stamp_ns": stamp}]
monkeypatch.setattr(host.capture, "consume", lambda *a, **kw: (rows, None))
def slow_sync(_fd):
entered.set()
assert release.wait(3)
with monkeypatch.context() as patch:
patch.setattr(storage.os, "fsync", slow_sync)
with ThreadPoolExecutor(2) as pool:
try:
pool.submit(host.receive_detections, DetectionInput("top", stamp, ())).result(timeout=1)
clock.now += .04
pool.submit(host.receive_feedback, host.command_names,
host.profile.command.baseline_values, host.ports.clock_ns()).result(timeout=1)
assert not entered.is_set()
assert host.state_history[-1].stamp_ns == host.ports.clock_ns()
finally:
release.set()
assert host.raw_records[0] == storage.load_jsonl(host.raw_path)[0]
captured = host.raw_records[1:]
assert [row for row in captured if row["kind"] != "image_observation_frame"] == rows
assert len([row for row in captured if row["kind"] == "image_observation_frame"]) == 1
assert storage.load_jsonl(host.raw_path)[-len(captured):] == captured
persisted = []
with monkeypatch.context() as patch:
patch.setattr(storage.os, "fsync", lambda _fd: persisted.append(storage.load_jsonl(host.raw_path)))
host._pause("test_pause")
assert persisted and persisted[-1][-1]["kind"] == "paused"
assert persisted[-1][-len(captured)-1:-1] == captured
finally:
release.set()
host.close()
@pytest.mark.parametrize("boundary", ["fit", "commit", "diagnostic"])
def test_sync_failure_blocks_fitting_publication_and_diagnostic_success(tmp_path, monkeypatch, boundary):
from linkerhand_calibration.runtime import coordinator
host, _ = coordinator_fixture(tmp_path,
diagnostic_capture="o6_thumb" if boundary == "diagnostic" else "")
def failed(_path):
raise OSError("simulated journal sync failure")
try:
with monkeypatch.context() as patch:
patch.setattr(coordinator, "sync_jsonl", failed)
if boundary == "diagnostic":
host.execution.session.phase = Phase.DIAGNOSTIC_COMPLETE
host._finish_diagnostic_capture()
assert host.execution.session.phase == Phase.FAILED
assert not (host.parameters.session_dir / "diagnostic_capture.json").exists()
else:
host.execution.session.phase = Phase.FIT if boundary == "fit" else Phase.PUBLISH
with pytest.raises(OSError, match="journal sync failure"):
(host._finalize if boundary == "fit" else host._authorize_commit)()
assert not host.finalization.started
assert not host.final_json and not host.final_urdf
finally:
host.close()
def test_close_syncs_last_observation_records(tmp_path, monkeypatch):
from linkerhand_calibration.runtime import coordinator
host, _ = coordinator_fixture(tmp_path)
epoch = host._observation_epoch
calls = []
monkeypatch.setattr(coordinator, "sync_jsonl", lambda path: calls.append((path, host._observation_epoch)))
host.close()
assert calls == [(host.raw_path, epoch + 1)]
@@ -1,111 +0,0 @@
"""Pose-cache snapshots stay coherent while camera callbacks keep running."""
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from test_observation_capture import frame, make_capture
def test_fingerprint_snapshots_dictionary_and_deques_before_computing_means(monkeypatch):
profile, lock, capture = make_capture()
lock.begin_session()
lock.start_locking()
for stamp in range(1, 16):
capture.consume(frame(profile, stamp, omit=("thumb_dip",)), collect_installations=True)
expected = capture.fingerprint_poses()
original_count = capture.installation_count("front:thumb_pitch")
entered, release = Event(), Event()
original_mean = capture._median_pose
snapshots = []
def mean(poses):
snapshots.append(poses)
entered.set()
assert release.wait(2)
return original_mean(poses)
monkeypatch.setattr(capture, "_median_pose", mean)
try:
with ThreadPoolExecutor(1) as pool:
future = pool.submit(capture.fingerprint_poses)
assert entered.wait(2)
# Add another role and mutate the existing deque while its old
# fingerprint's expensive mean is intentionally blocked.
for stamp in range(20, 25):
capture.consume(frame(profile, stamp), collect_installations=True)
assert capture.installation_count("front:thumb_dip") > 0
assert capture.installation_count("front:thumb_pitch") > original_count
assert capture.locked_reference_views() == {"front"}
assert isinstance(snapshots[0], tuple)
assert len(snapshots[0]) == original_count
release.set()
assert future.result(timeout=2) == expected
finally:
release.set()
def test_fixed_pose_mean_never_holds_the_metadata_read_lock(monkeypatch):
profile, lock, capture = make_capture()
lock.begin_session()
lock.start_locking()
for stamp in range(1, 14):
capture.consume(frame(profile, stamp))
assert not capture.locked_reference_views()
entered, release = Event(), Event()
original_mean = capture._median_pose
def mean(poses):
assert isinstance(poses, tuple)
entered.set()
assert release.wait(2)
return original_mean(poses)
monkeypatch.setattr(capture, "_median_pose", mean)
try:
with ThreadPoolExecutor(1) as pool:
future = pool.submit(capture.consume, frame(profile, 14))
assert entered.wait(2)
assert capture.fingerprint_poses() == ({}, {})
assert capture.installation_count("front:thumb_pitch") == 0
assert capture.locked_reference("front") is None
release.set()
future.result(timeout=2)
assert capture.locked_reference_views() == {"front"}
finally:
release.set()
def test_old_shifted_images_cannot_trigger_false_fixed_reference_drift():
profile, lock, capture = make_capture()
lock.begin_session()
lock.start_locking()
for stamp in range(100, 115):
capture.consume(frame(profile, stamp))
before_stamps = capture._latest_stamp_by_view.copy()
before_fingerprint = capture.fingerprint_poses()
for stamp in range(1, 11):
assert capture.consume(frame(profile, stamp, shift=6)) == ((), None)
assert capture._latest_stamp_by_view == before_stamps
assert capture.fingerprint_poses() == before_fingerprint
assert lock.latest_drift_px["front"] == 0.
assert capture.trackers["front"].last_candidate_diagnostics_by_role["front_base"]["observation_stamp_ns"] == 114*40_000_000
def test_old_normal_images_cannot_clear_a_real_fixed_reference_drift():
profile, lock, capture = make_capture()
lock.begin_session()
lock.start_locking()
for stamp in range(100, 115):
capture.consume(frame(profile, stamp))
for stamp in range(120, 129):
assert capture.consume(frame(profile, stamp, shift=6))[1] is None
for stamp in range(1, 11):
assert capture.consume(frame(profile, stamp)) == ((), None)
movement = capture.consume(frame(profile, 129, shift=6))[1]
assert movement is not None
assert movement.confirmation_frames == 10
assert movement.drift_px == 6.
def test_capture_reset_resets_each_view_timestamp_gate():
profile, lock, capture = make_capture()
capture.consume(frame(profile, 100))
capture.reset()
capture.consume(frame(profile, 1))
assert capture._latest_stamp_by_view == {"front": 40_000_000}
@@ -1,156 +0,0 @@
"""One partition contract at scheduling, support and journal boundaries."""
from dataclasses import replace
import pytest
from linkerhand_calibration.core.domain.capture_plan import (
ADAPTIVE, CapturePlan, configure_training, evidence_digest, select_task_training)
from linkerhand_calibration.core.domain.sampling import command_nodes
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.engine import CalibrationEngine
from linkerhand_calibration.runtime.execution import SessionExecution
from linkerhand_calibration.runtime.session import CalibrationPhase
from linkerhand_calibration.runtime.task_quality import evaluate_task_input_support
from linkerhand_calibration.runtime.training import resolve_capture_plan
@pytest.fixture
def profile():
return configure_training(load_bundled_hand_profile("o30_right_18"), ADAPTIVE)
def decision(profile, task, cycles, action):
from linkerhand_calibration.core.fitting.training_quality import TRAINING_DECISION_VERSION
row = dict(kind="training_decision", version=TRAINING_DECISION_VERSION,
task_name=task.key, assessed_cycles=list(cycles), decision=action,
input_plan_sha256=CapturePlan.from_profile(profile).sha256, failures=["noisy"] if action == "fail" else [])
return {**row, "decision_sha256": evidence_digest(row)}
def header(profile):
return dict(kind="session_start", capture_plan=CapturePlan.from_profile(profile).as_dict())
def support_rows(profile, task, cycles):
return [dict(kind="joint_sample", task_name=task.key, joint=task.joints[0],
view=profile.measurement.measurements[task.joints[0]].view, sample_phase="steady",
cycle=cycle, direction=direction, command_u8=value, image_stamp_ns=index * 10 + node + 1)
for index, (cycle, direction) in enumerate((c, d) for c in cycles for d in ("increasing", "decreasing"))
for node, value in enumerate((0., 127., 255.))]
def test_optional_round_keeps_holdout_identity_grid_and_motion_segments(profile):
units = CalibrationEngine(profile).scan_units()
for task in profile.motion.tasks:
original_grid = command_nodes(profile, task, holdout=True)
profile = select_task_training(profile, task.key, (0, 1))
assert command_nodes(profile, task, holdout=True) == original_grid
shortened = CalibrationEngine(profile).scan_units()
assert len(units) == 144 and len(shortened) == 108
assert shortened == tuple(unit for unit in units if unit.cycle != 2)
assert all(task.holdout == 3 for task in CapturePlan.from_profile(profile).tasks)
def test_secondary_camera_uses_its_primary_joint_partition():
from linkerhand_calibration.core.domain.capture_plan import task_for_joint, training_cycles
profile = load_bundled_hand_profile("g20_right_19")
for primary, secondary in profile.measurement.cross_view_sources.items():
assert task_for_joint(profile, secondary) == task_for_joint(profile, primary)
assert training_cycles(profile, joint=secondary) == (0, 1, 2)
@pytest.mark.parametrize("mode,cycles", [("interleaved", (0, 1, 2, 3)), ("separate", (4, 5))])
def test_command_support_uses_the_correct_phase_partition(profile, mode, cycles):
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode=mode))
task = profile.motion.tasks[0]
rows = support_rows(profile, task, cycles)
assert evaluate_task_input_support(profile, task, rows).passed
# Validation endpoints cannot extend training support.
reduced = [row for row in rows if row["cycle"] == cycles[-1] or row["command_u8"] < 255.]
assert not evaluate_task_input_support(profile, task, reduced).passed
@pytest.mark.parametrize("action,expected", [("freeze", 3), ("add_training", 2), ("fail", 2)])
def test_session_advances_only_after_bounded_training_decision(profile, monkeypatch, action, expected):
from linkerhand_calibration.runtime.engine import SweepQuality
driver = SessionExecution(profile)
task = profile.motion.tasks[0]
round_cycle = 2 if action == "fail" else 1
index = max(i for i, u in enumerate(driver.session._units) if u.task_key == task.key and u.cycle == round_cycle)
driver.session._unit_index = index
driver.session.phase = CalibrationPhase.EVALUATE
driver.completed_units.update(u.identity for u in driver.session._units[:index])
monkeypatch.setattr("linkerhand_calibration.runtime.execution.evaluate_capture_unit",
lambda *a, **k: SweepQuality(True, (), (), {}))
driver.evaluate([], training_decision=decision(profile, task, range(round_cycle + 1), action))
if action == "fail":
assert driver.session.phase == CalibrationPhase.FAILED
else:
assert driver.session.current_unit.cycle == expected
assert driver.session.status().task.cycle_count == (3 if action == "freeze" else 4)
if action == "freeze":
assert driver.session.status().task.cycle == 3
def test_journal_rejects_future_holdout_and_changed_decision(profile):
task = profile.motion.tasks[0]
row = decision(profile, task, (0, 1), "freeze")
current = resolve_capture_plan(profile, [header(profile), row])
assert current.quality.task_training_cycles[task.key] == (0, 1)
with pytest.raises(ValueError, match="holdout_before_training_freeze"):
resolve_capture_plan(profile, [header(profile), support_rows(profile, task, (3,))[0], row])
with pytest.raises(ValueError, match="training_decision_invalid"):
resolve_capture_plan(profile, [header(profile), {**row, "assessed_cycles": [0, 1, 2]}])
with pytest.raises(ValueError, match="training_observation_after_freeze"):
resolve_capture_plan(profile, [header(profile), row, support_rows(profile, task, (0,))[0]])
with pytest.raises(ValueError, match="matching_plan_header"):
resolve_capture_plan(profile, [])
with pytest.raises(ValueError, match="third_training_without_decision"):
resolve_capture_plan(profile, [header(profile), support_rows(profile, task, (2,))[0]])
def test_fixed_and_adaptive_checkpoints_are_isolated(profile):
fixed = configure_training(profile, "fixed")
engine = CalibrationEngine(profile)
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION
row = {**header(profile), "profile_id": profile.key.profile_id,
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
"pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION,
"capture_schedule_version": engine.capture_schedule_version,
"calibration_scope": None}
from linkerhand_calibration.core.urdf.partial_scope import profile_scope
row["calibration_scope"] = profile_scope(profile)
assert engine.resume_compatible(row)
assert not CalibrationEngine(fixed).resume_compatible(row)
row["capture_schedule_version"] = CalibrationEngine(fixed).capture_schedule_version
assert not engine.resume_compatible(row)
def test_stage_timing_accounts_for_elapsed_time_once():
from linkerhand_calibration.runtime.timing_diagnostics import StageTiming
timing = StageTiming(0.)
timing.mark_start(1.)
timing.enter("geometry_solving", 2.)
timing.enter("geometry_solving", 5.)
timing.enter("steady_sampling", 6.)
report = timing.as_dict(7.)
assert report["stage_seconds"] == {"startup": 2., "geometry_solving": 4., "steady_sampling": 1.}
assert sum(report["stage_seconds"].values()) == report["elapsed_seconds"]
timing.enter('terminal', 9.)
assert timing.as_dict(20.)['start_to_terminal_seconds'] == 8.
def test_adaptive_resume_restarts_suffix_after_incomplete_dependency(profile):
from linkerhand_calibration.runtime.joint_resume import JointResume
tasks = profile.motion.tasks[:3]
for task in tasks:
profile = select_task_training(profile, task.key, (0, 1))
engine = CalibrationEngine(profile)
resume = JointResume(profile)
resume.units = {u.identity for u in engine.scan_units() if u.task_key in {t.key for t in tasks}}
resume.units.remove(next(u.identity for u in engine.scan_units() if u.task_key == tasks[1].key))
resume.restrict_adaptive_prefix(engine)
assert {key[0] for key in resume.units} == {tasks[0].key}
assert resume.profile.quality.task_training_cycles == {tasks[0].key: (0, 1)}
@@ -1,183 +0,0 @@
"""Old/edited journals cannot silently acquire current configuration hashes."""
import copy
from dataclasses import replace
import hashlib
import json
import pytest
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.artifacts.replay import validate_capture_provenance, validate_capture_units
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CAPTURE_SCHEDULE_VERSION, CalibrationEngine
from linkerhand_calibration.runtime.scan_quality import observation_streams
def journal():
profile = load_bundled_hand_profile("l6_right_8")
# This fixture predates an explicit schedule header and models historical
# interleaved capture, not today's separate production schedule.
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="interleaved"))
hashes = {"source_urdf_sha256": "a"*64, "profile_config_sha256": "b"*64}
matrices = {view: [[100., 0., 50.], [0., 100., 50.], [0., 0., 1.]] for view in profile.vision.view_names}
header = {"kind": "session_start", "profile_id": profile.key.profile_id, "serial_number": "VIRTUAL",
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
"pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION,
"curve_input_domain": profile.curve_input_domain, **hashes}
reference = {"kind": "fixed_base_reference_locked", "profile_id": profile.key.profile_id,
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
"protected_hashes": {**hashes, "intrinsics_sha256": hashlib.sha256(
json.dumps(dict(sorted(matrices.items())), sort_keys=True).encode()).hexdigest()},
"fixed_corners_by_view": {view: [[0., 0.], [1., 0.], [1., 1.], [0., 1.]] for view in matrices},
"fixed_poses": {view: {"rotation_xyzw": [0., 0., 0., 1.], "translation_xyz_m": [0., 0., 1.]}
for view in matrices}, "moving_tag_poses": {}}
rows = [header, *({"kind": "rectified_camera_model", "view": view, "camera_matrix": matrix,
"input_is_rectified": True, "matrix_source": "CameraInfo.P[:3,:3]"} for view, matrix in matrices.items()), reference]
return profile, hashes, rows
@pytest.mark.parametrize("mutation", ["old_policy", "old_pose_policy", "changed_hash", "missing_reference", "projection", "input_domain"])
def test_changed_or_missing_acquisition_provenance_cannot_publish(mutation):
profile, hashes, rows = journal()
validate_capture_provenance(profile, "VIRTUAL", hashes, rows)
if mutation == "old_policy":
rows[0]["acquisition_policy_version"] = "unified_engine_v2"
elif mutation == "old_pose_policy":
rows[0].pop("pose_tracking_policy_version")
elif mutation == "changed_hash":
rows[0]["source_urdf_sha256"] = "c"*64
elif mutation == "missing_reference":
rows.pop()
elif mutation == "projection":
rows[1]["camera_matrix"][0][0] = 101.
else:
rows[0]["curve_input_domain"] = "command_rad"
with pytest.raises(ValueError, match="capture_provenance"):
validate_capture_provenance(profile, "VIRTUAL", hashes, rows)
def test_historical_capture_can_be_checked_without_mixing_it_into_new_motion():
profile, hashes, rows = journal()
before = copy.deepcopy(rows)
validate_capture_provenance(profile, "VIRTUAL", hashes, rows)
assert rows == before
assert not CalibrationEngine(profile).resume_compatible(rows[0])
current = copy.deepcopy(rows)
current[0]["capture_schedule_version"] = CAPTURE_SCHEDULE_VERSION
validate_capture_provenance(profile, "VIRTUAL", hashes, current)
assert CalibrationEngine(profile).resume_compatible(current[0])
@pytest.mark.parametrize("schedule", ["unknown_future_schedule", {}, []])
def test_unknown_capture_schedule_cannot_publish(schedule):
profile, hashes, rows = journal()
rows[0]["capture_schedule_version"] = schedule
with pytest.raises(ValueError, match="capture_provenance"):
validate_capture_provenance(profile, "VIRTUAL", hashes, rows)
def complete_journal():
profile, hashes, rows = journal()
stamp = 0
for unit in CalibrationEngine(profile).scan_units():
task = next(task for task in profile.motion.tasks if task.key == unit.task_key)
common = {"task_name": unit.task_key, "cycle": unit.cycle, "direction": unit.direction, "attempt": 1}
for field, joint, spec in observation_streams(profile, task):
for i in range(129):
stamp += 1
rows.append({**common, field: joint, "view": spec.view, "image_stamp_ns": stamp,
"feedback_u8": unit.start+(unit.end-unit.start)*i/128})
from linkerhand_calibration.runtime.steady import steady_targets
for index, target in enumerate(steady_targets(profile, unit)):
for _ in range(3):
stamp += 1
rows.append({**common, field: joint, "view": spec.view, "image_stamp_ns": stamp,
"sample_phase": "steady", "steady_index": index, "steady_target": target})
rows.append({**common, "kind": "scan_unit_complete", "passed": True})
return profile, hashes, rows
def test_offline_units_use_live_quality_and_require_completed_last_attempt():
profile, _, rows = complete_journal()
validate_capture_units(profile, rows)
failed = copy.deepcopy(rows)
failed[-1]["passed"] = False
with pytest.raises(ValueError, match="capture_incomplete"):
validate_capture_units(profile, failed)
failed = rows+[dict(rows[-2], attempt=2)]
with pytest.raises(ValueError, match="capture_incomplete"):
validate_capture_units(profile, failed)
def test_conflicting_latest_completion_cannot_hide_failed_capture():
profile, _, rows = complete_journal()
rows.append(dict(rows[-1], passed=False))
with pytest.raises(ValueError, match="capture_incomplete"):
validate_capture_units(profile, rows)
def release_journal():
profile, hashes, rows = complete_journal()
# This numerical gate fixture uses the historical interleaved v2 contract.
profile = replace(profile, artifacts=replace(profile.artifacts,
output_schema_version=2, protected_input_fields=frozenset(hashes)))
return profile, hashes, rows
def test_shared_release_gate_checks_post_header_intrinsics():
from linkerhand_calibration.runtime.artifacts.capture_validation import validate_capture_for_release
profile, hashes, rows = release_journal()
reference = next(row for row in rows if row.get("kind") == "fixed_base_reference_locked")
hashes.update(intrinsics_sha256=reference["protected_hashes"]["intrinsics_sha256"],
raw_samples_sha256="e"*64)
validate_capture_for_release(profile, "VIRTUAL", hashes, rows)
hashes["intrinsics_sha256"] = "f"*64
with pytest.raises(ValueError, match="protected_input_changed:intrinsics_sha256"):
validate_capture_for_release(profile, "VIRTUAL", hashes, rows)
@pytest.mark.parametrize("entry", ["online", "offline"])
@pytest.mark.parametrize("defect", ["failed_unit", "new_incomplete_attempt", "protected_input"])
def test_both_finalization_entries_reject_invalid_capture_before_fitting(tmp_path, monkeypatch, entry, defect):
from types import SimpleNamespace
from linkerhand_calibration.runtime.artifacts import finalization
from linkerhand_calibration.runtime.artifacts.replay import replay_capture
from linkerhand_calibration.runtime import runner_support
profile, hashes, rows = release_journal()
if defect == "failed_unit":
rows[-1]["passed"] = False
elif defect == "new_incomplete_attempt":
rows.append(dict(rows[-2], attempt=2))
else:
rows[0]["source_urdf_sha256"] = "c"*64
monkeypatch.setattr(finalization, "fit_profile_calibration",
lambda *args, **kwargs: pytest.fail("invalid capture reached fitting"))
directory = tmp_path / "session"
expected = "protected_input_changed" if defect == "protected_input" else "capture_incomplete"
with pytest.raises(ValueError, match=expected):
if entry == "online":
finalization.finalize_profile_session(profile=profile, session_dir=directory,
serial_number="VIRTUAL", source_urdf=tmp_path/"unused.urdf",
protected_inputs=hashes, records=rows, require_motion_evidence=True)
else:
path = tmp_path / "raw.jsonl"
path.write_text("".join(json.dumps(row)+"\n" for row in rows))
config = SimpleNamespace(calibration_contract=SimpleNamespace(typed_profile=profile),
serial_number="VIRTUAL", source_urdf=tmp_path/"unused.urdf", camera_extrinsics=None)
monkeypatch.setattr(runner_support, "protected_inputs", lambda _: dict(hashes))
replay_capture(config, path, output=directory, publish=True)
diagnostic = json.loads((directory/"capture_validation.json").read_text())
assert diagnostic["passed"] is False and diagnostic["publication_allowed"] is False
assert expected in diagnostic["reason"]
assert not list(directory.glob("*.urdf"))
assert not (tmp_path/profile.artifacts.publication_pointer).exists()
def test_stripping_header_does_not_disable_required_production_gate(tmp_path):
from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session
profile, hashes, rows = release_journal()
with pytest.raises(ValueError, match="requires_unique_session"):
finalize_profile_session(profile=profile, session_dir=tmp_path, serial_number="VIRTUAL",
source_urdf=tmp_path/"unused.urdf", protected_inputs=hashes,
records=rows[1:], require_motion_evidence=True)
@@ -1,69 +0,0 @@
"""Live retention cannot change coverage, retries or durable fit evidence."""
from copy import deepcopy
from types import SimpleNamespace
from linkerhand_calibration.runtime.capture_index import CaptureRecordIndex
from linkerhand_calibration.runtime.engine import CalibrationEngine
from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit
from linkerhand_calibration.runtime.task_quality import evaluate_task_input_support
from linkerhand_calibration.core.fitting.command_sampling import build_sampling_plan
from linkerhand_calibration.core.artifacts.storage import append_jsonl
from o30_capture_fixture import synthetic_capture
def test_compact_index_preserves_all_twenty_joint_quality_and_sampling_decisions():
profile, _, rows, _ = synthetic_capture()
index = CaptureRecordIndex(retain_complete=False)
index.extend(rows)
assert len(index) < len(rows)
assert all('parent_pose_common' not in row and 'state_u8' not in row for row in index)
full_spans, indexed_spans = {}, {}
for unit in CalibrationEngine(profile).scan_units():
full = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans=full_spans)
compact = evaluate_capture_unit(profile, unit, 1, index, first_cycle_spans=indexed_spans)
assert full == compact
assert full_spans == indexed_spans
for task in profile.motion.tasks:
assert evaluate_task_input_support(profile, task, index) == evaluate_task_input_support(profile, task, rows)
assert build_sampling_plan(profile, task, index) == build_sampling_plan(profile, task, rows)
def test_failed_attempt_without_joint_samples_still_invalidates_prior_support():
from test_task_input_support import capture
profile, task, _, rows = capture(held_upper=250.)
rows = [dict(row, kind='joint_sample') for row in rows]
original = deepcopy(rows)
index = CaptureRecordIndex(retain_complete=False)
index.extend(rows)
assert evaluate_task_input_support(profile, task, index).passed
# The new attempt saw only raw images. It cannot fall back to old angles.
for direction in ('increasing', 'decreasing'):
index.append(dict(kind='pnp_candidate_frame', task_name=task.key, cycle=3,
direction=direction, attempt=2, roles={'large': [1]*10000}))
assert not evaluate_task_input_support(profile, task, index).passed
assert rows == original
assert all('roles' not in row for row in index)
def test_journal_backed_coordinator_keeps_full_evidence_only_in_journal(tmp_path):
from runtime_host_fixture import coordinator_fixture
from linkerhand_calibration.runtime.session import CalibrationPhase
received = []
finalizer = SimpleNamespace(uses_journal=True, started=False, close=lambda: None,
start=received.append)
host, _ = coordinator_fixture(tmp_path, finalization=finalizer)
try:
row = dict(kind='pnp_candidate_frame', task_name='example', cycle=0,
direction='increasing', roles={'original_corners': [[1.,2.]]*4})
append_jsonl(host.raw_path, row)
host.capture_index.append(row)
assert not host.capture_index.retain_complete
assert row in host.raw_records
assert all('roles' not in r for r in host.capture_index)
host.execution.session.phase = CalibrationPhase.FIT
host._finalize()
assert received[0].records == ()
assert received[0].journal_path == host.raw_path
assert received[0].journal_size == host.raw_path.stat().st_size
finally:
host.close()
@@ -1,50 +0,0 @@
from copy import deepcopy
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.geometry.tag_pose.chain_candidate_audit import (
equivalent_training_signatures, training_solution_families)
def signature():
return dict(geometry=np.tile([0., 0., 1., 0., .02, 0.], (3, 1)).tolist(),
installation=np.eye(4).tolist(), stage_poses=np.tile(np.eye(4), (3, 1, 1)).tolist())
def test_groups_use_training_state_not_validation_or_cost():
state = signature()
candidates = [dict(converged=True, training_signature=deepcopy(state),
training_cost=i, validation_errors_px=[i*100]) for i in range(64)]
assert training_solution_families(candidates) == (tuple(range(64)),)
candidates[4]['training_signature']['stage_poses'][0][0][3] = .0001
groups = training_solution_families(candidates)
assert (4,) in groups and len(groups) == 2
candidates[8]['converged'] = False
assert (8,) in training_solution_families(candidates)
def test_axis_sign_is_equivalent_but_distinct_installation_is_not():
a, b = signature(), signature()
b['geometry'] = (np.asarray(b['geometry'])*[-1, -1, -1, 1, 1, 1]).tolist()
assert equivalent_training_signatures(a, b)
b['installation'] = np.eye(4)
b['installation'][:3, :3] = Rotation.from_rotvec([.001, 0, 0]).as_matrix()
assert not equivalent_training_signatures(a, b)
def test_complete_link_does_not_merge_a_chain_of_nearby_solutions():
rows = []
for displacement in (0., 1.5e-6, 3e-6):
s = signature()
s['installation'][0][3] = displacement
rows.append(dict(converged=True, training_signature=s))
assert training_solution_families(rows) == ((0, 1), (2,))
def test_invalid_signature_fails_closed():
a, b = signature(), signature()
b['stage_poses'][0][0][0] = 2.
with pytest.raises(ValueError, match='rotation_invalid'):
equivalent_training_signatures(a, b)
@@ -1,294 +0,0 @@
"""Deferred acquisition preserves safety and cannot fabricate calibrated zeros."""
from copy import deepcopy
from dataclasses import replace
import numpy as np
import pytest
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.core.domain.capture_plan import configure_training, TASKWISE_TWO_ROUND
from linkerhand_calibration.profiles.observer_policy import configure_observer_policy
from linkerhand_calibration.runtime.chain_capture import DeferredChainCapture
from linkerhand_calibration.runtime.diagnostic_capture import resolve_diagnostic_capture
from linkerhand_calibration.runtime.motion_execution import MotionCommand
from linkerhand_calibration.runtime.execution import SessionExecution
from linkerhand_calibration.runtime.engine import CalibrationEngine
def chain_profile(profile=None):
return configure_observer_policy(configure_training(
profile or load_bundled_hand_profile('o30_right_18'), TASKWISE_TWO_ROUND))
@pytest.mark.parametrize('scope', ['one_distal', 'full_hand', 'two_actions'])
def test_chain_rejects_auxiliary_actions(scope):
from o30_capture_fixture import SOURCE
from linkerhand_calibration.core.domain.profile import ObserverPreparationPlan
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.profiles.observer_preparation import configure_observer_preparations, observer_actions
profile = configure_observer_preparations(chain_profile(), UrdfKinematicModel(SOURCE))
action = observer_actions(profile, 'pinky_dip')[0]
if scope != 'full_hand':
actions = (action,) if scope == 'one_distal' else (action, action)
profile = replace(profile, motion=replace(profile.motion,
observer_preparations={'pinky_dip': ObserverPreparationPlan(actions)}))
with pytest.raises(ValueError, match='diagnostic_chain_requires_taskwise_feedback_interleaved_plan'):
resolve_diagnostic_capture(profile, 'o30_pinky_chain')
@pytest.fixture
def chain_gate(monkeypatch):
profile = chain_profile()
plan = resolve_diagnostic_capture(profile, 'o30_pinky_chain')
gate = DeferredChainCapture(profile, plan)
# These tests isolate raw-window provenance and feedback. The complete
# coordinator test exercises real image solvers and the geometric gate.
from linkerhand_calibration.core.geometry.tag_pose.pose_bridge import PoseBridgeCheck
monkeypatch.setattr(gate, '_frames', lambda *args: ())
monkeypatch.setattr('linkerhand_calibration.runtime.chain_reference.check_chain_parent',
lambda *args: PoseBridgeCheck('pinky_pip', 'consistent', ''))
return gate
def raw_visit(gate, joint, *, version=1, stamp=100, parent=False):
spec = gate.profile.motion.joint_zero_references[joint]
task = gate.tasks[spec.task_key]
motion = MotionCommand('joint_zero', spec.command, 200., task_key=task.key, cycle=0,
zero_joints=((task.parent_reference.pose_joint,) if parent else (joint,)),
reference_reuse=parent, reference_only=parent)
rows = [dict(kind='image_observation_frame', view=gate.view, task_name=task.key,
sample_phase='joint_zero', image_stamp_ns=stamp+i, session_epoch=1, motion_version=version,
command_vector=list(spec.command), feedback_vector=list(spec.command),
tags={role: dict(tag_id=gate.tags[role].tag_id, tag_size_m=gate.tags[role].size_m,
corners_xy=[[0., 0.], [20., 0.], [20., 20.], [0., 20.]]) for role in gate.roles})
for i in range(gate.profile.acquisition.fixed_reference_minimum_frames)]
return motion, rows
def training_return(gate, joint, *, version=1, stamp=150, cycle=0):
motion, rows = raw_visit(gate, joint, version=version, stamp=stamp)
motion = replace(motion, phase='steady', cycle=cycle, direction='decreasing', zero_joints=())
for row in rows:
row.update(sample_phase='steady', cycle=cycle, direction='decreasing')
return motion, rows
def record_training_return(gate, joint, *, version, stamp):
motion, rows = training_return(gate, joint, version=version, stamp=stamp)
return gate.steady_visit(motion, rows, epoch=1, version=version)
def test_pending_visits_do_not_supply_geometry_and_parent_checks_both_sources(chain_gate):
gate = chain_gate
for i, joint in enumerate(gate.plan.chain_joints[:2]):
motion, rows = raw_visit(gate, joint, version=i+1, stamp=100*(i+1))
result = gate.reference_visit(motion, rows, epoch=1, version=i+1)
assert result.attempt.as_record()['no_zero_locked']
assert result.record['geometry_status'] == 'pending'
record_training_return(gate, joint, version=i+1, stamp=100*(i+1)+50)
motion, rows = raw_visit(gate, gate.plan.chain_joints[-1], version=3, stamp=300, parent=True)
result = gate.reference_visit(motion, rows, epoch=1, version=3)
assert result.attempt is None
assert len(result.record['source_images']) == 20
assert not result.record['publication_allowed']
@pytest.mark.parametrize('fault', ['tag', 'duplicate', 'epoch', 'version', 'feedback', 'pixel'])
def test_pending_zero_rejects_invalid_windows_without_mutating_state(chain_gate, fault):
motion, rows = raw_visit(chain_gate, chain_gate.plan.chain_joints[0])
if fault == 'tag':
rows[-1]['tags'].pop(chain_gate.roles[-1])
elif fault == 'duplicate':
rows[-1]['image_stamp_ns'] = rows[-2]['image_stamp_ns']
elif fault in ('epoch', 'version'):
rows[-1]['session_epoch' if fault == 'epoch' else 'motion_version'] += 1
elif fault == 'feedback':
rows[-1]['feedback_vector'][10] += 3
else:
rows[-1]['tags'][chain_gate.roles[0]]['corners_xy'][0][0] += 6
with pytest.raises(ValueError):
chain_gate.reference_visit(motion, rows, epoch=1, version=1)
assert not chain_gate.references
@pytest.mark.parametrize('delta,accepted', [(7., True), (10., True), (11., False)])
def test_parent_feedback_reuses_existing_ten_unit_contract(chain_gate, delta, accepted):
for i, joint in enumerate(chain_gate.plan.chain_joints[:2]):
motion, rows = raw_visit(chain_gate, joint, version=i+1, stamp=100*(i+1))
chain_gate.reference_visit(motion, rows, epoch=1, version=i+1)
record_training_return(chain_gate, joint, version=i+1, stamp=100*(i+1)+50)
motion, rows = raw_visit(chain_gate, chain_gate.plan.chain_joints[-1], version=3, stamp=300, parent=True)
for row in rows:
row['feedback_vector'][5] += delta
if accepted:
assert chain_gate.reference_visit(motion, rows, epoch=1, version=3).attempt is None
else:
with pytest.raises(ValueError, match='parent_feedback_changed'):
chain_gate.reference_visit(motion, rows, epoch=1, version=3)
def test_parent_visit_cannot_proceed_without_both_raw_sources(chain_gate):
motion, rows = raw_visit(chain_gate, chain_gate.plan.chain_joints[-1], parent=True)
with pytest.raises(ValueError, match='parent_visit_missing'):
chain_gate.reference_visit(motion, rows, epoch=1, version=1)
@pytest.mark.parametrize('fault', ['image', 'command'])
def test_parent_revisit_rejects_changed_raw_pose_or_command(chain_gate, fault):
for i, joint in enumerate(chain_gate.plan.chain_joints[:2]):
motion, rows = raw_visit(chain_gate, joint, version=i+1, stamp=100*(i+1))
chain_gate.reference_visit(motion, rows, epoch=1, version=i+1)
record_training_return(chain_gate, joint, version=i+1, stamp=100*(i+1)+50)
rows[0]['feedback_vector'][0] = 99
assert chain_gate.references[1, joint][0]['feedback_vector'][0] != 99
motion, rows = raw_visit(chain_gate, chain_gate.plan.chain_joints[-1], version=3, stamp=300, parent=True)
for row in rows:
if fault == 'image':
for corner in row['tags'][chain_gate.roles[0]]['corners_xy']:
corner[0] += 6
else:
row['command_vector'][0] += 1
with pytest.raises(ValueError, match='parent_image_changed|current_images_missing'):
chain_gate.reference_visit(motion, rows, epoch=1, version=3)
def test_taskwise_two_plus_one_and_interleaved_holds(chain_gate):
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
gate = chain_gate
engine = CalibrationEngine(gate.profile, diagnostic_capture=gate.plan)
units = engine.scan_units()
assert len(units) == 18
assert [(unit.task_key, unit.cycle) for unit in units[::2]] == [
(task, cycle) for task in gate.plan.task_keys for cycle in (0, 1, 3)]
execution = SessionExecution(gate.profile, diagnostic_capture=gate.plan)
execution.session.device_ready()
execution.session.start()
# Baseline uses the ordinary reviewed whole-hand return path.
assert execution.action.phase == Phase.BASELINE
effects = execution._make_effects(tuple([128.]*gate.profile.command.command_count))
assert effects[-1].target == gate.profile.command.baseline_values
execution.session.baseline_complete()
execution.session.reference_locked()
execution.session.preparation_complete()
assert execution.action.phase == Phase.SWEEP
effects = execution._make_effects(gate.profile.command.baseline_values)
assert {effect.phase for effect in effects} == {'sweep', 'steady'}
assert all(a.target == b.target for a, b in zip(effects[::2], effects[1::2]))
assert list(dict.fromkeys(effect.target[10] for effect in effects)) == sorted(
set(effect.target[10] for effect in effects))
def test_first_training_return_is_raw_and_holdout_cannot_replace_it(chain_gate):
gate = chain_gate
joint = gate.plan.chain_joints[0]
motion, rows = raw_visit(gate, joint)
gate.reference_visit(motion, rows, epoch=1, version=1)
record = record_training_return(gate, joint, version=2, stamp=200)
assert record['cycle'] == 0 and record['direction'] == 'decreasing'
assert not record['publication_allowed'] and not record['is_accepted_joint_sample']
assert record['source_images'][0] == (gate.view, 200)
before = deepcopy(gate.references)
motion, rows = training_return(gate, joint, version=3, stamp=300, cycle=3)
assert gate.steady_visit(motion, rows, epoch=1, version=3) is None
assert gate.references == before
def test_parent_requires_completed_training_return_not_only_initial_hold(chain_gate):
for i, joint in enumerate(chain_gate.plan.chain_joints[:2]):
motion, rows = raw_visit(chain_gate, joint, version=i+1, stamp=100*(i+1))
chain_gate.reference_visit(motion, rows, epoch=1, version=i+1)
motion, rows = raw_visit(chain_gate, chain_gate.plan.chain_joints[-1], version=3, stamp=300, parent=True)
with pytest.raises(ValueError, match='parent_training_return_missing'):
chain_gate.reference_visit(motion, rows, epoch=1, version=3)
@pytest.mark.parametrize('fault', ['too_few', 'cycle', 'direction', 'feedback', 'tag'])
def test_training_return_rejects_invalid_evidence_transactionally(chain_gate, fault):
gate = chain_gate
joint = gate.plan.chain_joints[0]
motion, rows = raw_visit(gate, joint)
gate.reference_visit(motion, rows, epoch=1, version=1)
before = deepcopy(gate.references)
motion, rows = training_return(gate, joint, version=2, stamp=200)
if fault == 'too_few':
rows.pop()
elif fault == 'cycle':
rows[-1]['cycle'] = 3
elif fault == 'direction':
rows[-1]['direction'] = 'increasing'
elif fault == 'feedback':
rows[-1]['feedback_vector'][10] += 3
else:
rows[-1]['tags'].pop(gate.roles[-1])
with pytest.raises(ValueError):
gate.steady_visit(motion, rows, epoch=1, version=2)
assert gate.references == before and not gate.training_returns
def test_only_deferred_capture_omits_the_unfitted_zero_excursion(chain_gate):
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
for plan in (None, chain_gate.plan):
execution = SessionExecution(chain_gate.profile, diagnostic_capture=plan)
execution.session._unit_index = next(i for i, unit in enumerate(execution.session._units)
if unit.task_key == 'pinky_mcp_pitch_side' and unit.cycle == 0)
execution.session.phase = Phase.PREPARE
effects = execution._make_effects(chain_gate.profile.command.baseline_values)
excursion = any(m.phase == 'zero_approach' and m.target[10] == 255 for m in effects)
assert excursion is (plan is None)
assert any(m.phase == 'joint_zero' for m in effects)
def test_training_return_source_is_not_replaced_by_later_callbacks(chain_gate):
joint = chain_gate.plan.chain_joints[0]
motion, rows = raw_visit(chain_gate, joint)
chain_gate.reference_visit(motion, rows, epoch=1, version=1)
record_training_return(chain_gate, joint, version=2, stamp=200)
before = deepcopy(chain_gate.references)
assert record_training_return(chain_gate, joint, version=2, stamp=250) is None
assert chain_gate.references == before
def test_training_arc_is_bounded_and_never_consumes_holdout(chain_gate):
gate = chain_gate
row = dict(kind='diagnostic_observation_frame', view='side', task_name='pinky_mcp_pitch_side',
cycle=0, direction='decreasing', sample_phase='sweep', session_epoch=1,
feedback_vector=list(gate.profile.command.baseline_values),
tags={r: {'reprojection_valid_candidates': [object()]} for r in ('side_base', 'pinky_pip')})
for index in range(1000):
command = list(gate.profile.command.baseline_values)
command[10] = 255-index % 256
gate.observe(dict(row, command_vector=command, image_stamp_ns=index+1))
key = (1, 'pinky_mcp_pitch')
assert len(gate.arcs[key]) <= 129
before = tuple(r['image_stamp_ns'] for r in gate.arcs[key].values())
gate.observe(dict(row, cycle=3, command_vector=command, image_stamp_ns=2000))
gate.observe(dict(row, cycle=1, command_vector=command, image_stamp_ns=2001))
gate.training_returns.add(key)
gate.observe(dict(row, command_vector=command, image_stamp_ns=2002))
assert tuple(r['image_stamp_ns'] for r in gate.arcs[key].values()) == before
def test_stale_training_model_cannot_commit_after_clear(chain_gate):
from types import SimpleNamespace
request = SimpleNamespace(generation=chain_gate.generation)
chain_gate._pending = request
chain_gate.clear()
assert chain_gate.accept(request, object()) is None
assert not chain_gate.models and not chain_gate.pending()
def test_unresolved_training_model_cannot_release_return(chain_gate):
from linkerhand_calibration.runtime.chain_reference import ChainReferenceResult
joint = chain_gate.plan.chain_joints[0]
motion, _ = training_return(chain_gate, joint)
assert not chain_gate.reference_ready(motion, 1)
chain_gate.models[1, joint] = ChainReferenceResult(False, 'image_motion_families_not_distinguishable')
with pytest.raises(ValueError, match='reference_model_unresolved'):
chain_gate.reference_ready(motion, 1)
def test_parent_pose_requires_a_resolved_model():
from linkerhand_calibration.runtime.chain_reference import ChainReferenceResult, check_chain_parent
for result in (None, ChainReferenceResult(False, 'unresolved'), ChainReferenceResult(True, '')):
with pytest.raises(ValueError, match='parent_model_unavailable'):
check_chain_parent(result, ())
@@ -1,124 +0,0 @@
"""Complete raw chain schedule through the real coordinator, using synthetic FK."""
import cv2
import json
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from test_chain_capture import chain_profile
from runtime_host_fixture import coordinator_fixture, ready
from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points
from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from linkerhand_calibration.runtime.diagnostic_capture import reject_diagnostic_capture
def chain_frame(host, values):
base = np.array([0., 0., .6])
camera_rotation = Rotation.from_euler('xy', [.1, -.15])
mcp, pip, dip = [Rotation.from_rotvec([0., 0., values[i]/255*1.3]) for i in (10, 14, 19)]
parent = mcp*pip
pip_point = mcp.apply([0., -.04, 0.])
dip_point = pip_point+parent.apply([0., -.04, 0.])
poses = {'side_base': (Rotation.identity(), np.array([-.05, 0., 0.])),
'pinky_pip': (parent, pip_point+parent.apply([0., -.01, 0.])),
'pinky_dip': (parent*dip, dip_point+(parent*dip).apply([0., -.01, 0.]))}
detections = []
for tag in next(view.tags for view in host.profile.vision.views if view.name == 'side'):
if tag.role not in poses:
continue
rotation, translation = poses[tag.role]
local = rotation.apply(square_object_points(tag.size_m))+translation
points = camera_rotation.apply(local)+base
pixels = points@host.cameras.matrices['side'].T
detections.append(TagDetection(tag.tag_id,
tuple(map(tuple, pixels[:, :2]/pixels[:, 2:])), 0, 100.))
return DetectionInput('side', host.ports.clock_ns(), tuple(detections))
def test_complete_chain_collects_taskwise_2_plus_1_without_fabricated_zeros(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path, model='o30',
diagnostic_capture='o30_pinky_chain', profile_transform=chain_profile)
ready(host, clock)
host.cameras.matrices['side'] = np.array([[1400., 0., 320.], [0., 1400., 240.], [0., 0., 1.]])
monkeypatch.setattr(host.branch_initialization, 'request', lambda *a, **kw:
pytest.fail('batch acquisition must not select an upstream model'))
monkeypatch.setattr(host.finalization, 'start', lambda *a, **kw:
pytest.fail('pending chain must not publish'))
assert host.start().success
try:
for _ in range(18000):
if host.execution.session.phase in (Phase.DIAGNOSTIC_COMPLETE, Phase.PAUSED, Phase.FAILED):
break
clock.now += .05
values = clock.positions[-1] if clock.positions else host.profile.command.baseline_values
clock.health_receiver(json.dumps(dict(hand_type='right', model='O30', side='RIGHT',
uid='OFFLINE_DEVICE', joint_names=list(host.profile.command.names),
online=True, position_mode=True, active_faults=[], joint_faults={})))
host.receive_feedback(host.profile.command.names, values, host.ports.clock_ns())
host.tick()
host.receive_detections(chain_frame(host, values))
assert host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE, host.reason
assert len(host.execution.completed_units) == 18
assert not host.execution.zero_references
assert not host.final_json and not host.final_urdf
assert tuple(clock.positions[-1]) == host.profile.command.baseline_values
# Count real command reversals, including preparation and safe return;
# counting only scan units previously hid a fourth full excursion.
for channel in (10, 14, 19):
commands = np.asarray([host.profile.command.baseline_values,
*clock.positions], dtype=float)[:, channel]
changed = commands[np.r_[True, np.diff(commands) != 0]]
turns = changed[np.r_[True, np.diff(np.sign(np.diff(changed))) != 0, True]]
assert turns.tolist() == [0., 255., 0., 255., 0., 255., 0.]
from linkerhand_calibration.runtime.resume_storage import load_passed_capture
rows = list(load_passed_capture(host.raw_path))
assert not any(row.get('kind') in ('joint_sample', 'joint_zero_sample') for row in rows)
references = [r for r in rows if r.get('kind') == 'deferred_chain_training_return']
assert len(references) == 3
assert all(r['cycle'] == 0 and r['direction'] == 'decreasing' for r in references)
models = [r for r in rows if r.get('kind') == 'deferred_chain_reference_model']
assert len(models) == 2 and all(r['result']['resolved'] for r in models)
assert all(not r['publication_allowed'] and not r['physical_zero_validated'] for r in models)
assert models[0]['result']['quaternion_xyzw'] == models[1]['result']['quaternion_xyzw']
visits = [r for r in rows if r.get('kind') == 'deferred_chain_reference_visit' and r['reference_reuse']]
assert visits and all(r['parent_model_joint'] == 'pinky_pip'
and r['parent_pose_check']['status'] == 'consistent' for r in visits)
decisions = [r for r in rows if r.get('kind') == 'observer_preparation_decision'
and r.get('action') == 'excite']
assert not decisions
for task in host.diagnostic_capture.task_keys:
assert {row['cycle'] for row in rows if row.get('kind') == 'image_observation_frame'
and row.get('sample_phase') == 'steady' and row.get('task_name') == task} == {0, 1, 3}
with pytest.raises(ValueError, match='not_for_publication'):
reject_diagnostic_capture(rows)
finally:
host.close()
def test_chain_missing_distal_tag_cannot_skip_reference_timeout(tmp_path):
host, clock = coordinator_fixture(tmp_path, model='o30',
diagnostic_capture='o30_pinky_chain', profile_transform=chain_profile)
ready(host, clock)
host.cameras.matrices['side'] = np.array([[1400., 0., 320.], [0., 1400., 240.], [0., 0., 1.]])
assert host.start().success
try:
for _ in range(2200):
if host.execution.session.phase in (Phase.PAUSED, Phase.FAILED):
break
clock.now += .05
values = clock.positions[-1] if clock.positions else host.profile.command.baseline_values
clock.health_receiver(json.dumps(dict(hand_type='right', model='O30', side='RIGHT',
uid='OFFLINE_DEVICE', joint_names=list(host.profile.command.names),
online=True, position_mode=True, active_faults=[], joint_faults={})))
host.receive_feedback(host.profile.command.names, values, host.ports.clock_ns())
host.tick()
frame = chain_frame(host, values)
host.receive_detections(DetectionInput(frame.view, frame.stamp_ns,
tuple(tag for tag in frame.detections if tag.tag_id != 5)))
assert host.execution.session.phase == Phase.PAUSED
assert host.reason == 'deferred_chain_reference_unavailable:deferred_chain_current_images_missing'
assert not host.execution.completed_units and not host.execution.zero_references
assert not host.final_json and not host.final_urdf
finally:
host.close()
@@ -1,77 +0,0 @@
"""Only a declared scope expansion with unchanged preceding measurements can migrate."""
from copy import deepcopy
import hashlib
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from linkerhand_calibration.core.urdf.partial_scope import profile_scope
from linkerhand_calibration.runtime.checkpoint_extension import ScopeExtension
from linkerhand_calibration.runtime.engine import CalibrationEngine
from linkerhand_calibration.runtime.joint_resume import _index_motion_provenance
@pytest.fixture
def contracts(tmp_path):
original = yaml.safe_load((Path(__file__).parents[1] / 'config/profiles/o30_right_18.yaml').read_text())
source = deepcopy(original)
source['scope']['default_scope'] = 'without_finger_roll'
tasks = source['motion']['tasks']
tasks.insert(4, tasks.pop())
old, new = tmp_path / 'old.yaml', tmp_path / 'new.yaml'
old.write_text(yaml.safe_dump(source)); new.write_text(yaml.safe_dump(original))
return old, new, original
def test_extension_only_adds_roll_after_existing_sixteen_tasks(contracts):
old, new, _ = contracts
extension = ScopeExtension.load(old, new)
assert len(extension.source.motion.tasks) == 16
assert len(extension.target.motion.tasks) == 17
assert extension.target.motion.tasks[-1].key == 'fingers_mcp_roll_front'
assert extension.target.retained_joints == frozenset()
header = {'calibration_scope': profile_scope(extension.source)}
assert not CalibrationEngine(extension.target).resume_compatible(header)
@pytest.mark.parametrize('change', ['quality', 'speed', 'order', 'geometry'])
def test_extension_cannot_hide_other_contract_changes(contracts, change):
old, new, payload = contracts
if change == 'quality':
payload['acquisition']['minimum_valid_samples'] = 41
elif change == 'speed':
payload['motion']['tasks'][0]['formal_speed_u8'] -= 1
elif change == 'order':
tasks = payload['motion']['tasks']; tasks[0], tasks[1] = tasks[1], tasks[0]
else:
payload['zero']['spatial']['orientation_anchor_joint'] = 'thumb_mcp'
new.write_text(yaml.safe_dump(payload))
with pytest.raises(ValueError):
ScopeExtension.load(old, new)
def test_metadata_binds_both_profiles_and_preserves_intrinsics_and_audit(contracts):
old, new, _ = contracts
extension = ScopeExtension.load(old, new)
old_hash, new_hash = (hashlib.sha256(p.read_bytes()).hexdigest() for p in (old, new))
inputs = {'profile_config_sha256': new_hash, 'source_urdf_sha256': 'a' * 64}
header = {'kind': 'session_start', **inputs, 'profile_config_sha256': old_hash,
'calibration_scope': profile_scope(extension.source)}
reference = {'kind': 'fixed_base_reference_locked', 'protected_hashes': {
**header, 'intrinsics_sha256': 'b' * 64}}
checkpoint = SimpleNamespace(header=header, fixed_reference=reference)
before = deepcopy((header, reference))
migrated, fixed, audit = extension.metadata(checkpoint, inputs, 'source.jsonl', 'c' * 64)
assert migrated['profile_config_sha256'] == new_hash
assert migrated['calibration_scope'] is None
assert fixed['protected_hashes']['intrinsics_sha256'] == 'b' * 64
assert audit['original_header'] == header and audit['original_fixed_reference'] == reference
assert list(_index_motion_provenance([audit]).values()) == [audit]
assert (header, reference) == before
for key in ('source_urdf_sha256', 'profile_config_sha256'):
changed = {**inputs, key: 'd' * 64}
with pytest.raises(ValueError, match='hash_invalid|inputs_changed'):
extension.metadata(checkpoint, changed, 'source.jsonl', 'c' * 64)
@@ -1,98 +0,0 @@
"""Changing a pending observer cannot relabel accepted geometry or quality."""
from copy import deepcopy
from dataclasses import replace
import hashlib
from types import SimpleNamespace
import pytest
from linkerhand_calibration.core.domain.capture_plan import STAGED, configure_training
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.profiles.export import dump_hand_profile
from linkerhand_calibration.profiles.observer_policy import configure_observer_policy
from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract
from linkerhand_calibration.runtime.checkpoint_observer_upgrade import ObserverPolicyUpgrade, _copy_indexed_evidence
from linkerhand_calibration.runtime.joint_resume import _index_motion_provenance
@pytest.fixture
def migration():
old = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), STAGED),
'occlusion_aware_witness_v1')
preserved = set(old.measurement.measurements) - {'index_dip'}
new = configure_observer_policy(old, preserved_joints=preserved)
upgrade = ObserverPolicyUpgrade(old, new, dump_hand_profile(old).encode(), dump_hand_profile(new).encode())
inputs = dict(profile_config_sha256=hashlib.sha256(upgrade.target_yaml).hexdigest(), source_urdf_sha256='a'*64)
old_inputs = {**inputs, 'profile_config_sha256': hashlib.sha256(upgrade.source_yaml).hexdigest()}
header = dict(kind='session_start', **old_inputs, preparation_contract=preparation_contract(old))
reference = dict(kind='fixed_base_reference_locked', protected_hashes={**old_inputs, 'intrinsics_sha256': 'b'*64})
checkpoint = SimpleNamespace(header=header, fixed_reference=reference, rows=(header, reference),
joints=SimpleNamespace(references={joint: object() for joint in preserved},
units={('index_pip_side', 0, 'increasing', 'sweep', '')}))
return upgrade, checkpoint, inputs
def test_only_pending_strategy_changes_with_complete_audit(migration):
upgrade, checkpoint, inputs = migration
before = deepcopy((checkpoint.header, checkpoint.fixed_reference))
header, reference, audit = upgrade.metadata(checkpoint, inputs, 'source.jsonl', 'c'*64)
assert header['preparation_contract'] == preparation_contract(upgrade.target)
assert header['profile_config_sha256'] == inputs['profile_config_sha256']
assert reference['protected_hashes']['intrinsics_sha256'] == 'b'*64
assert set(audit['observer_policy_changes']) == {'index_dip'}
assert audit['original_header'] == before[0]
assert list(_index_motion_provenance([audit]).values()) == [audit]
assert (checkpoint.header, checkpoint.fixed_reference) == before
@pytest.mark.parametrize('fault', ['quality', 'motion', 'accepted_model', 'frozen', 'source_hash', 'target_hash'])
def test_upgrade_rejects_contract_or_accepted_evidence_changes(migration, fault):
upgrade, checkpoint, inputs = migration
if fault == 'quality':
upgrade = replace(upgrade, target=replace(upgrade.target,
acquisition=replace(upgrade.target.acquisition, minimum_valid_samples=999)))
elif fault == 'motion':
upgrade = replace(upgrade, target=replace(upgrade.target,
motion=replace(upgrade.target.motion, tasks=tuple(reversed(upgrade.target.motion.tasks)))))
elif fault == 'accepted_model':
checkpoint.rows += (dict(kind='motion_branch_initialization', status='resolved', zero_joints=['index_dip']),)
elif fault == 'frozen':
checkpoint.rows += (dict(kind='staged_training_decision', decision='freeze'),)
elif fault == 'source_hash':
checkpoint.fixed_reference['protected_hashes']['source_urdf_sha256'] = 'd'*64
else:
inputs['profile_config_sha256'] = 'd'*64
with pytest.raises(ValueError, match='observer_upgrade_'):
upgrade.metadata(checkpoint, inputs, 'source.jsonl', 'c'*64)
def test_active_observation_migration_only_adds_unmeasured_bounded_paths(migration):
from linkerhand_calibration.profiles.observer_preparation import configure_observer_preparations
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from o30_capture_fixture import SOURCE
upgrade,checkpoint,inputs=migration
target=configure_observer_preparations(upgrade.source,UrdfKinematicModel(SOURCE),
preserved_joints=checkpoint.joints.references)
upgrade=replace(upgrade,target=target,target_yaml=dump_hand_profile(target).encode(),source_urdf=SOURCE)
inputs['profile_config_sha256']=hashlib.sha256(upgrade.target_yaml).hexdigest()
_,_,audit=upgrade.metadata(checkpoint,inputs,'source.jsonl','c'*64)
assert audit['policy']=='unmeasured_observer_upgrade_v2'
assert set(audit['observer_preparations'])=={'index_dip'}
checkpoint.joints.references['index_dip']=object()
with pytest.raises(ValueError,match='changes_existing_contract'):
upgrade.metadata(checkpoint,inputs,'source.jsonl','c'*64)
def test_indexed_checkpoint_migration_copies_content_bound_archive(tmp_path):
from linkerhand_calibration.core.artifacts.evidence_journal import (
initialize_evidence_journal, append_indexed_records, archive_path, manifest_path,
)
source = tmp_path/'source/raw_samples.jsonl'
source.parent.mkdir()
initialize_evidence_journal(source, threshold_bytes=128)
append_indexed_records(source, ({'kind': 'pnp_candidate_frame', 'view': 'side',
'image_stamp_ns': 1, 'pixels': 'x'*2048},), durable=True)
target = tmp_path/'target'; target.mkdir()
_copy_indexed_evidence(source, target)
assert (target/archive_path(source).name).read_bytes() == archive_path(source).read_bytes()
assert (target/manifest_path(source).name).read_bytes() == manifest_path(source).read_bytes()
@@ -1,66 +0,0 @@
"""Checkpoint verification has observable liveness and a bounded deadline."""
from threading import Event, current_thread
import pytest
from linkerhand_calibration.runtime.startup import checkpoint_heartbeat
from test_unified_runner import LifecycleHarness
def test_loading_does_not_consume_device_or_service_deadlines():
loading = {'state': 'PREPARING_CHECKPOINT'}
harness = LifecycleHarness([loading]*6 + [{'state': 'WAIT_DEVICE'}, {'state': 'READY'},
{'state': 'COMPLETE'}], step=65.)
assert harness.run()['state'] == 'COMPLETE'
assert harness.starts == 1
def test_alive_but_stuck_checkpoint_has_a_finite_loading_deadline():
harness = LifecycleHarness([{'state': 'PREPARING_CHECKPOINT'}]*10, step=65.)
assert harness.run(checkpoint_timeout=200.)['reason'] == 'calibration_checkpoint_preparation_timeout'
assert harness.starts == 0
def test_loading_does_not_hide_device_failure_after_loading():
harness = LifecycleHarness([{'state': 'PREPARING_CHECKPOINT'}]*3
+ [{'state': 'WAIT_DEVICE'}]*4, step=65.)
assert harness.run()['reason'].startswith('calibration_device_not_ready:')
assert harness.starts == 0
@pytest.mark.parametrize('interrupted', [False, True])
def test_startup_heartbeat_stops_before_normal_callbacks_even_on_error(interrupted):
events, workers = [], []
received = Event()
def publish(snapshot):
events.append(snapshot)
if current_thread().name == 'checkpoint-status':
workers.append(current_thread())
received.set()
try:
with checkpoint_heartbeat(publish, enabled=True, interval=.01):
assert received.wait(1.)
assert all(s.state == 'PREPARING_CHECKPOINT' and s.motion.command is None for s in events)
if interrupted:
raise KeyboardInterrupt
except KeyboardInterrupt:
assert interrupted
assert workers and all(not w.is_alive() for w in workers)
def test_fresh_session_does_not_publish_checkpoint_status():
with checkpoint_heartbeat(lambda _: pytest.fail('not resuming'), enabled=False):
pass
def test_background_publication_failure_is_reported_after_worker_cleanup():
received, workers = Event(), []
def publish(_):
if current_thread().name == 'checkpoint-status':
workers.append(current_thread())
received.set()
raise ValueError('publisher unavailable')
with pytest.raises(RuntimeError, match='checkpoint_status_publication_failed'):
with checkpoint_heartbeat(publish, enabled=True, interval=.01):
assert received.wait(1.)
assert all(not worker.is_alive() for worker in workers)
@@ -1,71 +0,0 @@
"""Command truth is independent of encoder endpoint variability."""
from copy import deepcopy
from dataclasses import replace
import math
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.domain.reference import read_joint_zero_references
from linkerhand_calibration.core.fitting.command_motion import fit_command_motion
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.runtime.acquisition import accepted_joint_records
@pytest.fixture(scope='module')
def inputs(o30_capture):
profile, source, rows, _ = o30_capture
joint = 'thumb_cmc_roll'
sweep = accepted_joint_records(profile, rows)
steady = accepted_joint_records(profile, rows, sample_phase='steady')
refs = read_joint_zero_references(profile, rows)
return profile, UrdfKinematicModel(source), {joint:sweep[joint]}, {joint:steady[joint]}, {joint:refs[joint]}
def test_feedback_drift_does_not_change_command_mapping_or_visual_frame(inputs):
p, model, sweep, steady, refs = inputs
first = fit_command_motion(*inputs)
changed = deepcopy(sweep)
for rows in changed.values():
for row in rows:
row['feedback_u8'] = 3.+row['feedback_u8']*(247. if row['cycle'] < 3 else 250.)/255.
second = fit_command_motion(p, model, changed, steady, refs)
assert first.mappings == second.mappings
assert first.coordinate_curves == second.coordinate_curves
diagnostic = second.feedback_diagnostics['thumb_cmc_roll']
assert diagnostic['outside_training_support'] > 0
assert diagnostic['status'] == 'incomplete_or_inaccurate'
assert not diagnostic['required_for_release']
def test_command_holdout_error_cannot_hide_behind_good_feedback(inputs):
p, model, sweep, steady, refs = inputs
bad = deepcopy(steady)
# The independent fourth cycle visibly differs by 7 degrees, while its
# commands and encoder samples are untouched. No refit can absorb this.
fitted = fit_command_motion(*inputs)
axis = fitted.observed_curves['thumb_cmc_roll'].circle['axis_xyz']
for row in bad['thumb_cmc_roll']:
if row['cycle'] == 3:
row['relative_quaternion_xyzw'] = (Rotation.from_quat(row['relative_quaternion_xyzw']) *
Rotation.from_rotvec([v*math.radians(7) for v in axis])).as_quat().tolist()
with pytest.raises(ValueError, match='steady_command_holdout_failed'):
fit_command_motion(p, model, sweep, bad, refs)
def test_release_policy_cannot_silently_enable_unsupported_acquisition(inputs):
from linkerhand_calibration.core.domain.profile import validate_profile
p = inputs[0]
with pytest.raises(ValueError, match='interleaved'):
validate_profile(replace(p, acquisition=replace(p.acquisition, command_capture_mode='separate')))
@pytest.mark.parametrize("feedback", [None, 0.])
def test_missing_or_stuck_feedback_does_not_change_command_truth(inputs, feedback):
profile, model, sweep, steady, references = inputs
frozen = fit_command_motion(*inputs)
changed = {name: [dict(row, feedback_u8=feedback) for row in rows] for name, rows in sweep.items()}
actual = fit_command_motion(profile, model, changed, steady, references)
assert actual.mappings == frozen.mappings
assert actual.coordinate_curves == frozen.coordinate_curves
assert all(value['status'] == 'unavailable' for value in actual.feedback_diagnostics.values())
@@ -1,182 +0,0 @@
"""Public lookup semantics, report integrity and actual compact-file replay."""
import json
from dataclasses import replace
import numpy as np
import pytest
from test_unified_artifact_pair import dual_fixture
from test_frozen_tag_replay import transform
from linkerhand_calibration.core.urdf.acceptance import SerializedJointMapping, validate_compact_urdf_tables
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher
from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper
from linkerhand_calibration.runtime.artifacts.serializers.compact_v2 import from_report, REPORT_FILENAME
def stage_compact(tmp_path):
session, path, urdf, report, validator = dual_fixture(tmp_path)
(session / REPORT_FILENAME).write_text(json.dumps(report))
payload = from_report(report)
path.write_text(json.dumps(payload))
return session, path, urdf, report, payload, validator
def test_rad_lookup_report_and_manifest_reader(tmp_path):
session, path, urdf, report, payload, validator = stage_compact(tmp_path)
assert "quality" not in payload and "command_unit" not in payload
assert set(payload["joints"]["drive"]) == {"sdk_channel", "input_values", "angle_rad"}
mapping = SerializedJointMapping(payload)
expected = .2 + .8*.5 - .07
for direction in ("", "increasing", "decreasing"):
assert mapping.evaluate([1.1], direction, active_joints=["drive"])["drive"] == pytest.approx(expected)
with pytest.raises(ValueError, match="command-only"):
SerializedJointMapping(payload, input_kind="feedback")
release = ArtifactPublisher(tmp_path, "latest").publish(session_directory=session,
calibration_json=path, corrected_urdf=urdf, validate=validator)
manifest = json.loads(release.manifest.read_text())
assert manifest["calibration_report"] == REPORT_FILENAME
assert len(manifest["calibration_report_sha256"]) == 64
mapper = load_unified_mapper(release.manifest)
from linkerhand_calibration.calibrated_joint_state_bridge import load_calibrated_command_mapper
assert load_calibrated_command_mapper(path).map_positions([1.1]) == pytest.approx(mapper.map_positions([1.1]))
assert mapper.map_positions([1.1])[0] == pytest.approx(expected)
assert mapper.map_positions([1.2])[0] == pytest.approx(expected+.04)
assert mapper.map_positions([1.1])[0] == pytest.approx(expected)
feedback = load_unified_mapper(path, input_kind="feedback")
assert feedback.map_positions([.5])[0] == pytest.approx(expected)
assert mapper.map_positions([1.1])[1] == pytest.approx(payload["joints"]["passive"]["angle_rad"][32])
with pytest.raises(ValueError, match="outside"):
mapper.map_positions([3])
(session / REPORT_FILENAME).write_text(json.dumps(report)+" ")
with pytest.raises(ValueError, match="SHA256"):
load_unified_mapper(path)
def test_byte_indices_are_exactly_sdk_0_to_255_and_passive_is_recursive(tmp_path):
_, _, _, report, _ = dual_fixture(tmp_path)
report["command_unit"] = "u8"
for kind in ("command", "feedback"):
row = report["joints"]["drive"][kind+"_to_rad"]
row.update(input_domain=kind+"_u8", input_unit="u8", knots=[0, 128, 255],
valid_input_range=[0, 255], angle_rad=[1., .5, 0.],
increasing_rad=[1.01, .51, .01], decreasing_rad=[.99, .49, -.01])
report["joints"]["nested"] = {"urdf_joint": "nested", "passive": True,
"mimic": {"joint": "passive", "multiplier": -.5, "offset_rad": .2}}
payload = from_report(report)
assert all(set(row) == {"sdk_channel", "angle_rad"} for row in payload["joints"].values())
values = payload["joints"]["drive"]["angle_rad"]
assert len(values) == 256
assert [values[i] for i in (0, 128, 255)] == pytest.approx([1, .5, 0])
mapping = SerializedJointMapping(payload)
for index in range(256):
assert mapping.evaluate([index], "", active_joints=["drive"])["drive"] == values[index]
with pytest.raises(ValueError, match="outside"):
mapping.evaluate([256], "", active_joints=["drive"])
passive = np.asarray(payload["joints"]["passive"]["angle_rad"])
assert payload["joints"]["nested"]["angle_rad"] == pytest.approx(.2-.5*passive)
# Missing endpoint evidence is not manufactured into a 256-entry export.
report["joints"]["drive"]["command_to_rad"].update(knots=[1, 128, 255], valid_input_range=[1, 255])
with pytest.raises(ValueError, match="measured_full_command_domain"):
from_report(report)
@pytest.mark.parametrize("mutation", ["short_byte", "unknown_branch", "bad_channel", "unordered", "nan"])
def test_malformed_public_lookup_is_rejected(tmp_path, mutation):
_, _, _, _, payload, _ = stage_compact(tmp_path)
row = payload["joints"]["drive"]
if mutation == "short_byte":
payload["input_unit"] = "u8"
for entry in payload["joints"].values():
entry.pop("input_values")
elif mutation == "unknown_branch":
row["increasing_rad"] = row["angle_rad"]
elif mutation == "bad_channel":
row["sdk_channel"] = .5
elif mutation == "unordered":
row["input_values"][1] = row["input_values"][0]
else:
row["angle_rad"][1] = float("nan")
with pytest.raises(ValueError):
SerializedJointMapping(payload)
def test_passive_table_must_match_actual_urdf(tmp_path):
_, _, urdf, _, payload, _ = stage_compact(tmp_path)
payload["joints"]["passive"]["angle_rad"] = [v+.01 for v in payload["joints"]["passive"]["angle_rad"]]
with pytest.raises(ValueError, match="mimic"):
validate_compact_urdf_tables(payload, UrdfKinematicModel(urdf))
def test_average_table_itself_must_pass_independent_visual_holdout(tmp_path):
session, path, urdf, report, _, validator = stage_compact(tmp_path)
# Feedback remains perfect. Alter command training curves consistently, so
# the report/lookup correspondence check passes but independent replay fails.
mapping = report["joints"]["drive"]["command_to_rad"]
for key in ("angle_rad", "increasing_rad", "decreasing_rad"):
mapping[key] = [v+.06 for v in mapping[key]]
(session / REPORT_FILENAME).write_text(json.dumps(report))
path.write_text(json.dumps(from_report(report)))
with pytest.raises(ValueError, match="compact_command_table_holdout_failed"):
ArtifactPublisher(tmp_path, "latest").publish(session_directory=session,
calibration_json=path, corrected_urdf=urdf, validate=validator)
assert not (tmp_path / "latest").exists()
def test_directional_fit_cannot_hide_excessive_backlash_in_one_table(tmp_path):
session, path, urdf, report, _, validator = stage_compact(tmp_path)
mapping = report["joints"]["drive"]["command_to_rad"]
mean = np.asarray(mapping["angle_rad"])
mapping["increasing_rad"] = (mean+.04).tolist()
mapping["decreasing_rad"] = (mean-.04).tolist()
# Independent analytic generator, using the known mounts rather than the
# validator's FK or its fitted curve as truth.
base = transform((.13, -.22, .19), (.1, .2, .3))
mounts = {"a": transform((.3, .2, -.4), (.003, .012, .018)),
"b": transform((-.4, .3, .2), (-.013, .009, .016))}
observations = []
for row in validator.command_observations:
x = (row.sdk_values[0]-.1)/2
cad = .2+.8*x+(.04 if row.sdk_directions_by_joint["drive"] == "increasing" else -.04)
proximal = transform((.2, -.1, .3), (.02, -.01, .03)) @ transform((0, cad, 0))
distal = proximal @ transform((-.1, .2, .1), (.01, 0, .04)) @ transform((.6*cad+.04, 0, 0))
pose = base @ (proximal if row.role == "a" else distal) @ mounts[row.role]
observations.append(replace(row, common_from_tag=tuple(map(tuple, pose))))
validator = replace(validator, command_observations=tuple(observations))
# Both direction-specific curves pass with frozen mounts and actual files.
path.write_text(json.dumps(report))
assert validator(path, urdf)["steady_command_tag_holdout"]["a"]["maximum_deg"] < 1e-6
# The same evidence must reject a single direction-independent public table.
(session / REPORT_FILENAME).write_text(json.dumps(report))
path.write_text(json.dumps(from_report(report)))
with pytest.raises(ValueError, match="compact_command_table_holdout_failed"):
ArtifactPublisher(tmp_path, "latest").publish(session_directory=session,
calibration_json=path, corrected_urdf=urdf, validate=validator)
assert not (tmp_path / "latest").exists()
@pytest.mark.parametrize("reason", ["compact_command_table_holdout_failed", "compact_byte_requires_measured_full_command_domain"])
def test_compact_failure_has_an_actionable_chinese_reason(reason):
from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh
code, message, suggestion = reason_zh({"reason": reason+":drive"}, model_name="VIRTUAL")
assert code.startswith("FIT-COMPACT") and message and suggestion
@pytest.mark.parametrize("phase", ["validation", "commit"])
def test_report_is_protected_through_atomic_publication(tmp_path, phase):
session, path, urdf, report, _, validator = stage_compact(tmp_path)
publisher = ArtifactPublisher(tmp_path, "latest")
def mutate(*args):
result = validator(*args)
(session / REPORT_FILENAME).write_text(json.dumps(report)+" ")
return result
if phase == "validation":
with pytest.raises(ValueError, match="report changed"):
publisher.prepare(session_directory=session, calibration_json=path, corrected_urdf=urdf, validate=mutate)
else:
staged = publisher.prepare(session_directory=session, calibration_json=path, corrected_urdf=urdf, validate=validator)
(session / REPORT_FILENAME).write_text(json.dumps(report)+" ")
with pytest.raises(ValueError, match="report changed"):
publisher.commit(staged)
assert not (tmp_path / "latest").exists()
@@ -1,84 +0,0 @@
"""Actual middle PIP/MCP images: shared-pose conditioning, never guessed poses."""
from copy import deepcopy
from dataclasses import replace
import gzip
import json
from pathlib import Path
import pytest
from linkerhand_calibration.profiles import load_hand_profile
from linkerhand_calibration.profiles.preparation import preparation_contract
from linkerhand_calibration.runtime.preparation_witness import WitnessInput,build_preparation_witness,CONDITIONED_WITNESS_POLICY
from linkerhand_calibration.runtime.shared_tag_reference import bridge_frames
from linkerhand_calibration.runtime.branch_initialization import BranchInitializationRequest,solve_initialization,initialization_artifacts
from linkerhand_calibration.runtime.motion_provenance import _initializations
from linkerhand_calibration.runtime.preparation_witness_evidence import validate_preparation_witnesses,check_witness_report
from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion,image_parameters_for_sampling
@pytest.fixture(scope='module')
def live_data(tmp_path_factory):
data=json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_middle_pip_witness.json.gz').read_bytes()))
path=tmp_path_factory.mktemp('middle_witness')/'profile.yaml';path.write_text(data['profile_yaml'])
profile=load_hand_profile(path)
report=data['failure']; witness=report['preparation_witnesses'][0]
rows={r['image_stamp_ns']:r for r in data['rows']}
source=WitnessInput(witness['joint'],witness['donor'],tuple(rows[int(k)] for k in witness['source_frame_hashes']),
tuple(rows[int(k)] for k in witness['endpoint_frame_hashes']),tuple(witness['channels']))
bridge,_=build_preparation_witness(profile,source)
frames=bridge_frames(tuple(rows[k] for k in report['source_image_stamps']),bridge.relation)
request=BranchInitializationRequest(task_name=report['task_name'],view=report['view'],
zero_joints=tuple(report['zero_joints']),session_epoch=report['session_epoch'],motion_version=report['motion_version'],
generation=1,frames=tuple(f.evidence for f in frames),relations=(bridge.relation,),existing_evidence_ids=(),
moving_roles=('middle_pip',),source_frame_hashes=tuple(report['source_frame_hashes'].items()),
image_frames=frames,image_geometry=True,witness_sources=(source,),witness_profile=profile,
image_parameters=image_parameters_for_sampling(report.get('image_sampling_policy')))
result=solve_initialization(request)
assert result.resolved,result.reason
_,formal=initialization_artifacts(request,result)
records=json.loads(json.dumps([*data['rows'],formal]))
return profile,source,request,result,records
@pytest.mark.replay
def test_real_full_pip_anchor_resolves_middle_without_relaxing_gates(live_data):
profile,source,request,result,records=live_data
bridge,legacy=build_preparation_witness(profile,source,conditioned=False)
old=resolve_image_motion(request.image_frames,request.relations,pose_bridges=(bridge,),parameters=request.image_parameters)
assert not old.resolved and old.reason=='image_motion_shared_pose_unresolved'
assert legacy['version']=='preparation_witness_v2' and bridge.source_model is None
record=records[-1]
assert record['image_model_selection_policy']==CONDITIONED_WITNESS_POLICY
assert record['preparation_witnesses'][0]['conditioning']=='shared_zero_pose_v2'
assert all(h.pose_bridge_checks[0].status=='consistent' for h in result.hypotheses)
assert result.model.maximum_reprojection_error_px==1.5
assert result.model.reprojection_tie_px==.03
assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns)
assert preparation_contract(profile)['witness_pose_conditioning']['joints']==['middle_mcp_pitch']
@pytest.mark.replay
def test_formal_witness_and_recipient_replay_original_images(live_data):
profile,_,_,_,records=live_data
evidence=_initializations(records)
validate_preparation_witnesses(profile,records,evidence)
@pytest.mark.parametrize('fault',['policy','version','conditioning'])
def test_conditioned_record_cannot_be_downgraded(live_data,fault):
report=deepcopy(live_data[-1][-1])
if fault=='policy':report['image_model_selection_policy']='training_independent_preparation_witness_holm_v1'
elif fault=='version':report['preparation_witnesses'][0]['version']='preparation_witness_v2'
else:report['preparation_witnesses'][0]['conditioning']='unknown'
with pytest.raises(ValueError):check_witness_report(report)
def test_changed_independent_endpoint_still_vetoes(live_data):
profile,source,request,_,_=live_data
endpoint=deepcopy(source.endpoint_rows)
for row in endpoint:
row['tags']['middle_pip']['corners_xy']=[[x+10,y+8] for x,y in row['tags']['middle_pip']['corners_xy']]
result=solve_initialization(replace(request,witness_sources=(replace(source,endpoint_rows=tuple(endpoint)),)))
assert not result.resolved
assert result.model is None
@@ -1,398 +0,0 @@
from pathlib import Path
from xml.etree import ElementTree
import yaml
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
def test_package_owns_both_g20_source_urdfs_and_their_meshes() -> None:
for side in ("left", "right"):
urdf = (
PACKAGE_ROOT
/ "urdf"
/ f"g20_{side}"
/ f"linkerhand_g20_{side}.urdf"
)
assert urdf.is_file()
root = ElementTree.parse(urdf).getroot()
mesh_paths = {
mesh.attrib["filename"] for mesh in root.findall(".//mesh")
}
assert mesh_paths
assert all(not Path(path).is_absolute() for path in mesh_paths)
assert all((urdf.parent / path).is_file() for path in mesh_paths)
def test_fastdds_profile_has_capacity_for_full_resolution_images() -> None:
root = ElementTree.parse(
PACKAGE_ROOT / "config" / "fastdds_large_images.xml"
).getroot()
namespace = {"dds": "http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles"}
profiles = root.find("dds:profiles", namespace)
assert profiles is not None
segment = profiles.find(
".//dds:transport_descriptor[dds:type='SHM']/dds:segment_size",
namespace,
)
assert segment is not None
assert int(segment.text) >= 64 * 1024 * 1024
participant = profiles.find("dds:participant", namespace)
assert participant is not None
assert participant.attrib["is_default_profile"] == "true"
def test_three_camera_launches_force_fastdds_large_image_transport() -> None:
for launch_name in (
"three_camera_calibration.launch.py",
"three_camera_extrinsics.launch.py",
):
launch_text = (PACKAGE_ROOT / "launch" / launch_name).read_text()
assert 'name="RMW_IMPLEMENTATION"' in launch_text
assert 'value="rmw_fastrtps_cpp"' in launch_text
assert 'name="FASTDDS_DEFAULT_PROFILES_FILE"' in launch_text
assert 'name="FASTRTPS_DEFAULT_PROFILES_FILE"' in launch_text
def test_front_tag_parameters_match_namespaced_detector() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
)
detector = config["/apriltag/apriltag"]["ros__parameters"]
calibration = config["g20_thumb_calibration"]["ros__parameters"]
assert detector["tag"]["ids"] == calibration["tag_ids"]
assert detector["tag"]["frames"] == calibration["tag_frames"]
assert detector["tag"]["sizes"] == calibration["tag_sizes_m"]
assert detector["tag"]["ids"] == [0, 1, 2, 3]
assert detector["qos_profile"] == "sensor_data"
assert detector["detector"]["decimate"] == 1.5
assert detector["detector"]["refine"] is True
assert detector["detector"]["debug"] is False
def test_three_camera_tag_ids_and_topics_use_eleven_unique_tags() -> None:
tags = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_tags.yaml").read_text()
)
expected = {
"front": [0, 1, 2, 3, 10],
"side": [4, 5, 6, 7],
"top": [8, 9],
}
all_ids = set()
for view, ids in expected.items():
key = f"/g20_calibration/{view}/apriltag/apriltag"
parameters = tags[key]["ros__parameters"]
assert parameters["tag"]["ids"] == ids
assert parameters["size"] == 0.016
assert parameters["tag"]["sizes"] == [0.016] * len(ids)
assert parameters["qos_profile"] == "sensor_data"
assert parameters["detector"]["decimate"] == 1.5
all_ids.update(ids)
assert all_ids == set(range(11))
assert tags[
"/g20_calibration/side/apriltag/apriltag"
]["ros__parameters"]["tag"]["frames"][0] == "side_base"
def test_right_19_tag_config_matches_the_physical_layout() -> None:
tags = yaml.safe_load(
(
PACKAGE_ROOT
/ "config"
/ "three_camera_tags_g20_right_19.yaml"
).read_text()
)
expected = {
"front": [0, 1, 2, 3, 10, 11, 12, 13],
"side": [4, 5, 6, 7, 14, 15, 16, 17, 18],
"top": [8, 9],
}
all_ids: set[int] = set()
for view, ids in expected.items():
parameters = tags[
f"/g20_calibration/{view}/apriltag/apriltag"
]["ros__parameters"]
assert parameters["tag"]["ids"] == ids
expected_sizes = [0.016 for _tag_id in ids]
assert parameters["tag"]["sizes"] == expected_sizes
all_ids.update(ids)
assert all_ids == set(range(19))
side_frames = tags[
"/g20_calibration/side/apriltag/apriltag"
]["ros__parameters"]["tag"]["frames"]
assert side_frames == [
"side_base", "ring_pip", "pinky_pip", "pinky_dip", "ring_dip",
"middle_pip", "middle_dip", "index_pip", "index_dip",
]
def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_calibration.yaml").read_text()
)
parameters = config["g20_calibration"]["ros__parameters"]
assert parameters["tag_size_m"] == 0.016
assert parameters["tag_size_override_ids"] == [7, 14, 16, 18]
assert parameters["tag_size_overrides_m"] == [0.016] * 4
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
127,
127,
127,
127,
255,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["setting_topic"] == "/g20/cb_hand_setting_cmd"
assert parameters["normal_calibration_speed"] == 15
assert parameters["index_roll_calibration_speed"] == 5
assert parameters["index_flex_calibration_speed"] == 10
assert parameters["adaptive_formal_speed_enabled"] is True
assert parameters["adaptive_formal_speed_max_scale"] == 1.5
assert parameters["adaptive_formal_speed_minimum_bins"] == 64
assert parameters["adaptive_formal_speed_maximum_bin_gap"] == 8
assert parameters["right_thumb_yaw_255_endpoint_tolerance_u8"] == 5.0
assert parameters["speed_setting_settle_seconds"] >= 0.2
assert parameters["top_pnp_invalid_reset_seconds"] == 1.0
assert parameters["pnp_group_initialization_frames"] == 8
assert parameters["pnp_group_normal_alignment_scale_deg"] == 5.0
assert parameters["pnp_group_maximum_normal_alignment_deg"] == 15.0
assert parameters["thumb_ip_pnp_coupling_multiplier"] == 1.03
assert parameters["thumb_ip_pnp_coupling_scale_deg"] == 3.0
assert parameters["thumb_ip_pnp_maximum_coupling_residual_deg"] == 7.5
assert parameters["baseline_hold_seconds"] == 0.5
assert parameters["minimum_baseline_hold_frames"] == 10
assert parameters["directional_zero_maximum_branch_gap_deg"] == 2.0
assert parameters["directional_zero_maximum_branch_gap_range_deg"] == 0.3
assert (
parameters["cross_view_roll_maximum_branch_gap_difference_deg"]
== 0.3
)
assert parameters["cross_view_roll_diagnostic_finger"] == ""
assert parameters["repetitions"] == 3
assert parameters["g20_right_19_repetitions"] >= 4
assert parameters["validation_enabled"] is False
assert parameters["combination_validation_enabled"] is False
assert parameters["combination_validation_frames"] >= 10
assert parameters["combination_maximum_position_p95_m"] <= 0.003
assert parameters["combination_maximum_orientation_p95_deg"] <= 2.0
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["minimum_state_span_u8"] >= 240.0
assert parameters["endpoint_tolerance_u8"] == 2.0
assert parameters["thumb_yaw_zero_endpoint_tolerance_u8"] == 4.0
assert parameters["pinky_pip_zero_endpoint_tolerance_u8"] == 5.0
assert parameters["minimum_sweep_bins"] >= 32
assert parameters["maximum_bin_gap"] <= 16
assert parameters["automatic_sweep_retry_limit"] == 1
assert parameters["automatic_fit_retry_limit"] == 0
assert parameters["motor_stall_timeout_seconds"] == 2.0
assert parameters["motor_stall_startup_grace_seconds"] == 1.0
assert parameters["motor_stall_minimum_progress_u8"] == 1.0
assert parameters["automatic_motion_retry_limit"] == 0
assert parameters["provisional_warning_ratio"] == 1.25
assert parameters["retry_speed_scales"] == [1.0]
assert parameters["retry_endpoint_hold_seconds"] == [0.5]
assert parameters["position_timeout_seconds"] >= 20.0
assert parameters["maximum_state_image_skew_ms"] <= 50.0
assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0
assert parameters["axis_maximum_pose_line_rms_m"] <= 0.001
assert parameters["passive_axis_maximum_plane_rms_m"] == 0.004
assert parameters["active_maximum_rotation_orthogonal_rms_deg"] == 2.5
assert parameters["passive_maximum_rotation_orthogonal_rms_deg"] == 7.5
assert parameters["zero_maximum_axis_cycle_difference_deg"] <= 0.75
assert parameters["zero_maximum_axis_cone_mismatch_deg"] <= 5.0
assert parameters["zero_maximum_observability_condition_number"] >= 1.0
assert parameters["zero_maximum_offset_deg"] <= 20.0
assert parameters["zero_finger_maximum_offset_deg"] <= 3.0
assert parameters["mechanical_endpoint_maximum_offset_deg"] == 5.0
assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5
assert parameters["image_trajectory_minimum_radius_px"] >= 20.0
assert parameters["trajectory_maximum_cycle_travel_difference_deg"] <= 3.0
assert parameters["passive_maximum_cycle_travel_difference_deg"] <= 10.0
assert parameters["passive_maximum_monotonic_correction_deg"] <= 3.0
assert parameters["maximum_hysteresis_deg"] <= 2.0
assert parameters["passive_maximum_hysteresis_deg"] <= 2.0
assert parameters["command_maximum_direction_gap_deg"] <= 2.0
assert parameters["maximum_validation_mae_deg"] <= 1.0
assert parameters["maximum_validation_p95_deg"] <= 2.0
assert parameters["maximum_validation_error_deg"] <= 3.0
assert parameters["zero_maximum_confidence_half_width_deg"] <= 1.5
for view in ("front", "side", "top"):
assert parameters[f"{view}_camera_info_topic"].startswith(
f"/g20_calibration/{view}/"
)
assert parameters[f"{view}_detections_topic"] == (
f"/g20_calibration/{view}/apriltag/detections"
)
def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "calibration.yaml").read_text()
)
parameters = config["g20_thumb_calibration"]["ros__parameters"]
assert parameters["angle_estimation_mode"] == "trajectory_center_3d"
assert parameters["passive_ip_multiplier"] == 1.02
assert parameters["pnp_minimum_valid_rate"] == 0.95
assert parameters["pnp_maximum_reprojection_error_px"] <= 1.5
assert "pnp_reprojection_tie_px" not in parameters # Shared runtime policy.
assert parameters["pnp_tracker_reset_seconds"] == 5.0
assert parameters["pnp_group_relative_rotation_scale_deg"] == 5.0
assert parameters["pnp_group_relative_translation_scale_m"] == 0.01
assert parameters["pnp_group_reprojection_weight"] == 0.05
assert parameters["pnp_trajectory_reprojection_scale_px"] == 0.1
assert parameters["pnp_rigid_rotation_scale_deg"] == 5.0
assert parameters["pnp_rigid_p95_accepted_drift_deg"] == 8.0
assert parameters["pnp_rigid_maximum_accepted_drift_deg"] == 15.0
assert (
parameters["pnp_rigid_p95_accepted_distance_drift_m"]
<= 0.003
)
assert (
parameters["pnp_rigid_maximum_accepted_distance_drift_m"]
<= 0.006
)
assert parameters["pnp_maximum_pose_jump_deg"] <= 35.0
assert parameters["trajectory_maximum_plane_rms_m"] <= 0.004
assert parameters["trajectory_maximum_radial_rms_m"] <= 0.004
assert parameters["trajectory_minimum_radius_m"] >= 0.005
assert parameters["trajectory_minimum_arc_deg"] >= 15.0
assert (
parameters["trajectory_maximum_root_role_disagreement_deg"]
<= 5.0
)
assert parameters["trajectory_maximum_anchor_drift_m"] <= 0.005
assert parameters["trajectory_static_translation_outlier_m"] <= 0.005
assert (
parameters["trajectory_maximum_static_translation_rms_m"]
<= 0.002
)
assert parameters["minimum_edge_pixels"] == 30.0
assert parameters["maximum_static_std_deg"] == 3.0
assert parameters["minimum_pose_inlier_rate"] == 0.90
assert parameters["repetitions"] == 1
assert parameters["command_step"] == 8
assert parameters["scan_mode"] == "continuous"
assert parameters["continuous_motion_mode"] == "endpoint"
assert parameters["auto_start_tip"] is True
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["stable_frames"] == 5
assert parameters["capture_frames"] == 8
assert parameters["maximum_stable_spread_deg"] == 3.0
assert parameters["maximum_stable_translation_spread_m"] <= 0.003
assert parameters["validation_command_count"] == 5
assert parameters["continuous_minimum_bins"] >= 32
assert parameters["continuous_maximum_bin_gap"] <= 16
assert parameters["continuous_segment_minimum_seconds"] >= 0.1
assert parameters["continuous_segment_timeout_seconds"] >= 5.0
assert parameters["continuous_prepare_timeout_seconds"] >= 30.0
def test_cmc_pitch_zero_config_uses_three_trajectory_circle_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "cmc_pitch_zero.yaml").read_text()
)
parameters = config["g20_thumb_cmc_pitch_zero"]["ros__parameters"]
assert parameters["t0_id"] == 0
assert parameters["t3_id"] == 1
assert parameters["joint_name"] == "thumb_cmc_pitch"
assert parameters["motor_index"] == 0
assert parameters["zero_command_u8"] == 255
assert parameters["measure_travel"] is False
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
193,
148,
105,
42,
245,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["repetitions"] == 3
assert parameters["zero_capture_frames"] == 30
assert parameters["trajectory_command_u8"] <= 64
assert parameters["trajectory_minimum_state_span_u8"] >= 160.0
assert parameters["trajectory_minimum_bins"] >= 18
assert parameters["trajectory_minimum_arc_deg"] >= 20.0
assert parameters["trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["trajectory_maximum_p95_radial_error_px"] <= 3.5
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_edge_pixels"] == 40.0
assert parameters["maximum_static_position_rms_px"] <= 1.5
assert "maximum_axis_alignment_deg" not in parameters
assert "maximum_static_std_deg" not in parameters
assert "maximum_anchor_drift_deg" not in parameters
assert parameters["camera_alignment_enabled"] is True
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
assert parameters["camera_alignment_required_frames"] >= 10
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
def test_cmc_roll_config_measures_full_endpoint_travel() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "cmc_roll_zero_travel.yaml").read_text()
)
parameters = config[
"g20_thumb_cmc_roll_calibration"
]["ros__parameters"]
assert parameters["t0_id"] == 0
assert parameters["t3_id"] == 1
assert parameters["joint_name"] == "thumb_cmc_roll"
assert parameters["motor_index"] == 5
assert parameters["zero_command_u8"] == 255
assert parameters["trajectory_command_u8"] == 0
assert parameters["measure_travel"] is True
assert parameters["baseline_command_u8"][5] == 255
assert parameters["repetitions"] == 3
assert parameters["zero_capture_frames"] == 30
assert parameters["trajectory_minimum_state_span_u8"] >= 240.0
assert parameters["trajectory_minimum_bins"] >= 30
assert parameters["minimum_travel_deg"] >= 20.0
assert parameters["maximum_travel_difference_deg"] <= 1.0
assert parameters["maximum_round_difference_deg"] == 1.0
assert "maximum_axis_alignment_deg" not in parameters
assert "maximum_static_std_deg" not in parameters
assert "maximum_anchor_drift_deg" not in parameters
assert parameters["camera_alignment_enabled"] is True
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
assert parameters["camera_alignment_required_frames"] >= 10
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
@@ -1,244 +0,0 @@
from __future__ import annotations
import copy
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.compat.legacy.thumb_core import (
BASELINE_COMMAND,
DIRECTION_DECREASING,
DIRECTION_INCREASING,
PAIR_IP,
PAIR_MCP,
PAIR_ROOT,
PHASE_ROOT,
PHASE_TIP,
build_command,
create_final_payload,
fit_calibration_curves,
image_plane_tag_quaternion_xyzw,
isotonic_nonincreasing,
relative_quaternion_xyzw,
rotation_inlier_fraction,
rotation_rms_rad,
scan_targets,
validate_final_payload,
)
def _quaternion(base: Rotation, axis: np.ndarray, angle: float) -> list[float]:
value = base * Rotation.from_rotvec(axis * angle)
return [float(component) for component in value.as_quat()]
def _synthetic_records(
repetitions: int = 3,
command_step: int = 1,
) -> list[dict]:
bases = {
PAIR_ROOT: Rotation.from_euler("xyz", [0.2, -0.1, 0.3]),
PAIR_MCP: Rotation.from_euler("xyz", [-0.15, 0.1, 0.25]),
PAIR_IP: Rotation.from_euler("xyz", [0.05, 0.2, -0.2]),
}
axes = {
PAIR_ROOT: np.asarray([0.2, 0.9, -0.1], dtype=float),
PAIR_MCP: np.asarray([-0.1, 0.3, 0.95], dtype=float),
PAIR_IP: np.asarray([0.05, -0.2, 0.98], dtype=float),
}
axes = {key: value / np.linalg.norm(value) for key, value in axes.items()}
records: list[dict] = []
for phase in (PHASE_ROOT, PHASE_TIP):
for cycle, direction, command in scan_targets(
repetitions, command_step
):
progress = (255 - command) / 255.0
branch = (
0.008 * np.sin(np.pi * progress)
if direction == DIRECTION_INCREASING
else 0.0
)
root = 0.82 * progress + branch if phase == PHASE_ROOT else 0.0
mcp = 1.16 * progress + branch if phase == PHASE_TIP else 0.0
ip = 1.018 * mcp + 0.001 * np.sin(np.pi * progress)
rotations = {
PAIR_ROOT: _quaternion(bases[PAIR_ROOT], axes[PAIR_ROOT], root),
PAIR_MCP: _quaternion(bases[PAIR_MCP], axes[PAIR_MCP], mcp),
PAIR_IP: _quaternion(bases[PAIR_IP], axes[PAIR_IP], ip),
}
records.append(
{
"kind": "sample",
"phase": phase,
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_quaternion_xyzw": rotations,
}
)
return records
def test_build_command_changes_only_selected_channel() -> None:
result = build_command(15, 37)
assert len(result) == 20
assert result[15] == 37
assert result[:15] == list(BASELINE_COMMAND[:15])
assert result[16:] == list(BASELINE_COMMAND[16:])
roll_result = build_command(5, 20)
assert roll_result[5] == 20
assert roll_result[:5] == list(BASELINE_COMMAND[:5])
assert roll_result[6:] == list(BASELINE_COMMAND[6:])
with pytest.raises(ValueError):
build_command(6, 10)
def test_three_cycle_scan_has_every_integer_in_both_directions() -> None:
targets = scan_targets(3, command_step=1)
assert len(targets) == 3 * 2 * 256
assert targets[0] == (0, DIRECTION_DECREASING, 255)
assert targets[255] == (0, DIRECTION_DECREASING, 0)
assert targets[256] == (0, DIRECTION_INCREASING, 0)
assert targets[511] == (0, DIRECTION_INCREASING, 255)
def test_quick_scan_has_bounded_sparse_grid_and_endpoints() -> None:
targets = scan_targets(1, command_step=8)
assert len(targets) == 66
assert targets[0] == (0, DIRECTION_DECREASING, 255)
assert targets[32] == (0, DIRECTION_DECREASING, 0)
assert targets[33] == (0, DIRECTION_INCREASING, 0)
assert targets[-1] == (0, DIRECTION_INCREASING, 255)
increasing = [
command
for _, direction, command in targets
if direction == DIRECTION_INCREASING
]
assert max(np.diff(increasing)) == 8
def test_relative_rotation_cancels_camera_orientation() -> None:
camera_to_parent = Rotation.from_euler("xyz", [0.4, -0.2, 0.1])
parent_to_child = Rotation.from_rotvec([0.1, 0.3, -0.2])
camera_to_child = camera_to_parent * parent_to_child
actual = Rotation.from_quat(
relative_quaternion_xyzw(
camera_to_parent.as_quat(), camera_to_child.as_quat()
)
)
assert (parent_to_child.inv() * actual).magnitude() < 1e-10
def test_image_plane_tag_rotation_uses_ordered_opposite_edges() -> None:
angle = np.deg2rad(27.0)
x_axis_image = np.asarray([np.cos(angle), -np.sin(angle)])
y_axis_image = np.asarray([np.sin(angle), np.cos(angle)])
corners = np.asarray(
[
-x_axis_image - y_axis_image,
x_axis_image - y_axis_image,
1.1 * x_axis_image + y_axis_image,
-0.9 * x_axis_image + y_axis_image,
]
)
actual = Rotation.from_quat(image_plane_tag_quaternion_xyzw(corners))
expected = Rotation.from_rotvec([0.0, 0.0, angle])
assert (expected.inv() * actual).magnitude() < 1e-10
def test_static_rms_rejects_isolated_planar_pnp_flip() -> None:
quaternions = [
Rotation.from_rotvec([0.0, np.deg2rad(0.1 * np.sin(index)), 0.0]).as_quat()
for index in range(149)
]
quaternions.append(
Rotation.from_rotvec([0.0, np.deg2rad(27.0), 0.0]).as_quat()
)
threshold = np.deg2rad(5.0)
assert rotation_rms_rad(quaternions) > np.deg2rad(2.0)
assert rotation_rms_rad(
quaternions, outlier_threshold_rad=threshold
) < np.deg2rad(0.5)
assert rotation_inlier_fraction(
quaternions, outlier_threshold_rad=threshold
) == pytest.approx(149 / 150)
def test_isotonic_projection_is_nonincreasing() -> None:
projected = isotonic_nonincreasing([3.0, 2.0, 2.4, 1.0, 0.0])
assert np.all(np.diff(projected) <= 0.0)
assert projected.tolist() == pytest.approx([3.0, 2.2, 2.2, 1.0, 0.0])
def test_fit_produces_complete_runtime_payload() -> None:
fit = fit_calibration_curves(_synthetic_records())
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
0.82, abs=2e-3
)
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
1.16, abs=2e-3
)
assert fit.joints["thumb_ip"]["passive"] is True
assert fit.ip_coupling["r_squared"] > 0.999
for joint in fit.joints.values():
assert len(joint["angle_rad"]) == 256
assert len(joint["decreasing_rad"]) == 256
assert len(joint["increasing_rad"]) == 256
assert joint["angle_rad"][255] == 0.0
assert joint["decreasing_rad"][255] == 0.0
assert joint["increasing_rad"][255] == 0.0
payload = create_final_payload(
serial_number="G20_LEFT_TEST",
fit=fit,
validation_errors_rad=[0.01, -0.02],
passed=True,
)
assert set(payload) == {
"schema_version",
"model",
"side",
"serial_number",
"angle_unit",
"command_range",
"zero_command_u8",
"baseline_command_u8",
"joints",
"ip_coupling",
"quality",
}
assert payload["schema_version"] == 2
for joint in payload["joints"].values():
assert "angle_rad" in joint
assert "decreasing_rad" not in joint
assert "increasing_rad" not in joint
validate_final_payload(payload)
invalid = copy.deepcopy(payload)
invalid["joints"]["thumb_mcp"]["angle_rad"].pop()
with pytest.raises(ValueError):
validate_final_payload(invalid)
def test_sparse_fit_interpolates_complete_monotonic_runtime_payload() -> None:
fit = fit_calibration_curves(
_synthetic_records(repetitions=1, command_step=8)
)
for joint in fit.joints.values():
combined = np.asarray(joint["angle_rad"], dtype=float)
assert combined.shape == (256,)
assert combined[255] == 0.0
assert np.all(np.diff(combined) <= 1e-10)
for direction in ("decreasing_rad", "increasing_rad"):
curve = np.asarray(joint[direction], dtype=float)
assert curve.shape == (256,)
assert curve[255] == 0.0
assert np.all(np.diff(curve) <= 1e-10)
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
0.82, abs=2e-3
)
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
1.16, abs=2e-3
)
@@ -1,25 +0,0 @@
import numpy as np
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.fitting.observed_motion import fit_byte_observed_motion
def test_positive_sdk_direction_reorients_observation_axis_and_table_together():
rows = []
mount = Rotation.from_euler("xyz", [.23, -.17, .31])
for cycle in range(4):
for direction in ("increasing", "decreasing"):
for index, value in enumerate(np.linspace(0, 255, 86).astype(int)):
angle = value/255*.8
q = Rotation.from_euler("z", angle) * mount
rows.append({"sample_id": f"{cycle}/{direction}/{index}", "cycle": cycle,
"direction": direction, "command_u8": int(value),
"relative_quaternion_xyzw": q.as_quat().tolist()})
curves, errors, evidence = fit_byte_observed_motion({"arbitrary_joint": rows}, source_model=None,
mimic_sources={}, baseline_by_joint={"arbitrary_joint": 0}, direction_by_joint={"arbitrary_joint": 1},
input_domain="command_u8")
curve = curves["arbitrary_joint"]
assert curve.angle_rad[-1] > curve.angle_rad[0]
assert abs(curve.angle_rad[-1]-.8) < 1e-6
assert max(abs(e) for e in errors["arbitrary_joint"]) < 1e-6
assert not evidence
@@ -1,49 +0,0 @@
"""Larger witness arcs must be identical to an existing clearance-bound path."""
from dataclasses import replace
import pytest
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.profiles.preparation import configure_preparation, validate_preparation_witnesses
from linkerhand_calibration.core.domain.capture_plan import configure_training
from linkerhand_calibration.core.domain.profile import ModelPreparation
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
from o30_capture_fixture import SOURCE
def configuration():
profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'),
'staged_2_to_3'), 'occlusion_aware_witness_v1')
ref = profile.motion.joint_zero_references['middle_pip']
witness = ModelPreparation('middle_pip', ref.command, (ref.command, ref.approach_commands[0]))
return profile, witness
def validate(profile, witness):
witnesses = dict(profile.motion.preparation_witnesses, middle_mcp_pitch=witness)
candidate = replace(profile, motion=replace(profile.motion, preparation_witnesses=witnesses))
validate_preparation_witnesses(candidate, UrdfKinematicModel(SOURCE))
def test_existing_pip_path_can_be_reused_at_identical_clearance():
profile, witness = configuration()
validate(profile, witness)
@pytest.mark.parametrize('change', ['undeclared_amplitude', 'clearance', 'other_joint', 'roll_full_range'])
def test_large_witness_must_match_declared_single_joint_path(change):
profile, witness = configuration()
excursion = list(witness.approach_commands[1])
if change == 'undeclared_amplitude':
excursion[12] = 200.0
elif change == 'other_joint':
excursion[8] = 5.0
elif change == 'clearance':
command = list(witness.command); command[9] = 0.0; excursion[9] = 0.0
witness = replace(witness, command=tuple(command), approach_commands=(tuple(command),tuple(excursion)))
else:
witness = profile.motion.preparation_witnesses['middle_mcp_pitch']
excursion = list(witness.command); excursion[3] = 0.0
witness = replace(witness, approach_commands=(witness.command, tuple(excursion)))
with pytest.raises(ValueError):
validate(profile, witness)
@@ -1,109 +0,0 @@
"""Image lookahead cannot monopolize feedback callbacks or grow a backlog."""
from threading import Event
from types import SimpleNamespace
import pytest
from linkerhand_calibration.runtime.detection_dispatch import DetectionDispatcher
def observation(stamp, view='front'):
return SimpleNamespace(view=view, stamp_ns=stamp)
def test_waits_for_feedback_to_cover_image_before_delivering():
delivered = Event()
dispatcher = DetectionDispatcher(('front',), lambda row: delivered.set(),
maximum_wait_seconds=1., failed=lambda error: None)
try:
dispatcher.submit(observation(20))
dispatcher.feedback_available(19)
assert not delivered.wait(.02)
dispatcher.feedback_available(21)
assert delivered.wait(.2)
finally:
dispatcher.close()
def test_slow_image_work_retains_only_latest_pending_frame_per_view():
running, release, finished = Event(), Event(), Event()
images = []
def deliver(row):
images.append(row.stamp_ns)
if row.stamp_ns == 1:
running.set()
assert release.wait(1.)
else:
finished.set()
dispatcher = DetectionDispatcher(('front',), deliver,
maximum_wait_seconds=.05, failed=lambda error: None)
try:
dispatcher.feedback_available(100)
dispatcher.submit(observation(1))
assert running.wait(1.)
for stamp in range(2, 100):
dispatcher.submit(observation(stamp))
# Feedback and submissions cannot wait for blocked geometry or the
# coordinator lock held by that geometry's consumer.
dispatcher.feedback_available(101)
release.set()
assert finished.wait(1.)
assert images == [1, 99]
finally:
release.set()
dispatcher.close()
def test_start_or_abort_discards_images_waiting_for_feedback():
delivered = Event()
dispatcher = DetectionDispatcher(('front',), lambda row: delivered.set(),
maximum_wait_seconds=1., failed=lambda error: None)
dispatcher.submit(observation(10))
assert not delivered.wait(.02)
dispatcher.clear()
dispatcher.feedback_available(11)
dispatcher.close()
assert not delivered.is_set()
def test_new_session_cannot_use_the_previous_sessions_feedback_watermark():
delivered = Event()
dispatcher = DetectionDispatcher(('front',), lambda row: delivered.set(),
maximum_wait_seconds=1., failed=lambda error: None)
try:
dispatcher.feedback_available(100)
dispatcher.clear()
dispatcher.submit(observation(20))
assert not delivered.wait(.02)
dispatcher.feedback_available(21)
assert delivered.wait(.2)
finally:
dispatcher.close()
def test_missing_feedback_has_a_bounded_wait_and_capture_still_decides_validity():
delivered = Event()
dispatcher = DetectionDispatcher(('front',), lambda row: delivered.set(),
maximum_wait_seconds=.02, failed=lambda error: None)
try:
dispatcher.submit(observation(10))
assert delivered.wait(.5)
finally:
dispatcher.close()
def test_worker_errors_reach_the_control_thread():
failed = Event()
def fail(_):
raise ValueError('unexpected geometry error')
dispatcher = DetectionDispatcher(('front',), fail,
maximum_wait_seconds=.02, failed=lambda error: failed.set())
try:
dispatcher.submit(observation(10))
assert failed.wait(1.)
with pytest.raises(RuntimeError, match='image dispatch failed') as error:
dispatcher.raise_if_failed()
assert isinstance(error.value.__cause__, ValueError)
finally:
dispatcher.close()
@@ -1,255 +0,0 @@
"""Detached analysis boundaries and source integrity; expensive fitting is mocked."""
from copy import deepcopy
import hashlib
import json
import pytest
from linkerhand_calibration.core.geometry.tag_pose import motion_image_diagnostics
from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import MotionImageDiagnosticReport
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime import diagnostic_analysis as analysis
from test_recorded_diagnostic_replay import recorded_fixture
def write_actual_capture(directory):
fixture = recorded_fixture("192637")
records = [fixture["session_start"], fixture["camera_model"], *fixture["records"],
fixture["recorded_initialization"]]
path = directory / "raw_samples.jsonl"
path.write_text("".join(json.dumps(row)+"\n" for row in records))
return fixture, records, path
def mock_image_analysis(monkeypatch, callback=None):
calls = []
def solve(frames, relations):
calls.append((frames, relations))
if callback is not None:
callback()
return MotionImageDiagnosticReport("complete", "", tuple(frame.evidence.stamp_ns for frame in frames))
monkeypatch.setattr(motion_image_diagnostics, "analyze_motion_images", solve)
return calls
def test_joint_chain_holdout_never_enters_old_stage_fitter(tmp_path, monkeypatch):
(tmp_path/'diagnostic_capture.json').write_text(json.dumps(dict(mode='o30_pinky_chain')))
(tmp_path/'raw_samples.jsonl').write_text('{"cycle":3}\n')
monkeypatch.setattr(analysis, 'analyze_capture', lambda *a, **kw:
pytest.fail('independent chain holdout must not enter per-stage fitting'))
result = analysis.finish_diagnostic_analysis(load_bundled_hand_profile('o30_right_18'), tmp_path)
assert result['status'] == 'pending_joint_chain_validation'
assert not result['publication_allowed'] and not result['accuracy_validated']
assert result['training_cycles'] == [0, 1] and result['holdout_cycles'] == [3]
@pytest.mark.parametrize("corrupt", [False, True])
def test_indexed_capture_is_decoded_and_corrupt_archive_is_rejected(tmp_path, monkeypatch, corrupt):
from linkerhand_calibration.core.artifacts.evidence_journal import (
archive_path, initialize_evidence_journal,
)
from linkerhand_calibration.storage import append_jsonl_many
fixture = recorded_fixture("192637")
rows = [fixture["session_start"], fixture["camera_model"], *fixture["records"],
fixture["recorded_initialization"]]
raw = tmp_path / "raw_samples.jsonl"
initialize_evidence_journal(raw, threshold_bytes=1)
append_jsonl_many(raw, rows)
archive = archive_path(raw)
if corrupt:
data = bytearray(archive.read_bytes())
data[0] ^= 1
archive.write_bytes(data)
before = (raw.read_bytes(), archive.read_bytes())
calls = mock_image_analysis(monkeypatch)
profile = load_bundled_hand_profile("o6_right_8")
if corrupt:
with pytest.raises(ValueError):
analysis.analyze_capture(profile, raw)
assert not calls
assert not (tmp_path / "diagnostic_analysis.json").exists()
else:
report = analysis.analyze_capture(profile, raw)
assert report["status"] == "complete"
assert len(calls[0][0]) == 120
assert report["publication_allowed"] is False
assert (raw.read_bytes(), archive.read_bytes()) == before
def test_actual_initialization_images_remain_bound_in_separate_analysis_output(tmp_path, monkeypatch):
fixture, _, raw = write_actual_capture(tmp_path)
before = raw.read_bytes()
(tmp_path / "diagnostic_capture.json").write_text('{"original": true}')
original = (tmp_path / "diagnostic_capture.json").read_bytes()
calls = mock_image_analysis(monkeypatch)
profile = load_bundled_hand_profile("o6_right_8")
output = tmp_path / "analysis"
result = analysis.analyze_capture(profile, raw, output_dir=output)
assert len(calls) == 1
frames, relations = calls[0]
assert len(frames) == 120
assert [frame.evidence.stamp_ns for frame in frames] == fixture["recorded_initialization"]["source_image_stamps"]
assert {relation.joint for relation in relations} == {"rh_thumb_cmc_pitch", "rh_thumb_ip"}
assert result["raw_samples_sha256"] == hashlib.sha256(before).hexdigest()
assert result["publication_allowed"] is result["accuracy_validated"] is False
assert result["moving_candidates_counterfactual"] is True
assert result["stages"][0]["recorded_branch_status"] == "unresolved"
assert result["stages"][0]["source_image_count"] == 120
assert json.loads((output / "diagnostic_analysis.json").read_text())["publication_allowed"] is False
assert (output / "diagnostic_analysis.md").exists()
assert raw.read_bytes() == before
assert (tmp_path / "diagnostic_capture.json").read_bytes() == original
assert not list(tmp_path.rglob("*.urdf"))
@pytest.mark.parametrize("fault", ["second_session", "profile", "image_content", "source_identity", "duplicate_image"])
def test_bad_capture_identity_is_rejected_before_any_math_or_output(tmp_path, monkeypatch, fault):
_, rows, raw = write_actual_capture(tmp_path)
if fault == "second_session":
rows.append(deepcopy(rows[0]))
elif fault == "profile":
rows[0]["profile_id"] = "O12/right/foreign/v1"
elif fault == "image_content":
rows[2]["tags"]["thumb_ip"]["corners_xy"][0][0] += 1.
elif fault == "source_identity":
rows[2]["session_epoch"] += 1
else:
rows.append(deepcopy(rows[2]))
rows[-1]["tags"]["thumb_ip"]["corners_xy"][0][0] += 1.
raw.write_text("".join(json.dumps(row)+"\n" for row in rows))
before = raw.read_bytes()
monkeypatch.setattr(motion_image_diagnostics, "analyze_motion_images",
lambda *args, **kwargs: pytest.fail("invalid evidence reached image solver"))
with pytest.raises(ValueError, match="diagnostic_analysis_"):
analysis.analyze_capture(load_bundled_hand_profile("o6_right_8"), raw)
assert raw.read_bytes() == before
assert not (tmp_path / "diagnostic_analysis.json").exists()
def test_frozen_root_is_preserved_while_moving_candidates_remain_counterfactual():
fixture = recorded_fixture("192637")
profile = load_bundled_hand_profile("o6_right_8")
rows = [fixture["session_start"], *fixture["records"], fixture["recorded_initialization"]]
_, stages = analysis._stages(profile, rows)
_, relations, records = stages[0]
records = deepcopy(records)
for row in records:
for role in ("thumb_pitch", "thumb_ip"):
row["tags"][role]["candidate_diagnostics"]["branch_frozen"] = True
before = deepcopy(records)
frames = analysis.image_frames(records, relations)
for frame, original in zip(frames, records, strict=True):
poses = {item.role: item for item in frame.evidence.roles}
root = poses["front_base"]
assert root.frozen is True and len(root.candidates) == 1
assert root.candidates[0].quaternion_xyzw == tuple(original["tags"]["front_base"]["selected_pose"]["quaternion_xyzw"])
for role in ("thumb_pitch", "thumb_ip"):
assert poses[role].frozen is False
assert len(poses[role].candidates) == len(original["tags"][role]["reprojection_valid_candidates"])
assert records == before
def test_source_change_during_analysis_prevents_publication_of_diagnostic_output(tmp_path, monkeypatch):
_, _, raw = write_actual_capture(tmp_path)
calls = mock_image_analysis(monkeypatch, lambda: raw.write_text(raw.read_text()+"\n"))
with pytest.raises(ValueError, match="source_changed_during_read"):
analysis.analyze_capture(load_bundled_hand_profile("o6_right_8"), raw)
assert len(calls) == 1
assert not (tmp_path / "diagnostic_analysis.json").exists()
@pytest.mark.parametrize("error_type", [ValueError, RuntimeError])
def test_failed_analysis_preserves_completed_capture_and_never_claims_accuracy(tmp_path, monkeypatch, error_type):
_, _, raw = write_actual_capture(tmp_path)
before = raw.read_bytes()
capture = tmp_path / "diagnostic_capture.json"
capture.write_text('{"result": "DIAGNOSTIC_COMPLETE"}')
original = capture.read_bytes()
def fail(*args, **kwargs):
raise error_type("test_analysis_failure")
monkeypatch.setattr(analysis, "analyze_capture", fail)
report = analysis.finish_diagnostic_analysis(load_bundled_hand_profile("o6_right_8"), tmp_path)
assert report["status"] == "failed"
assert report["accuracy_validated"] is report["publication_allowed"] is False
assert raw.read_bytes() == before and capture.read_bytes() == original
assert not list(tmp_path.rglob("*.urdf"))
def test_unrelated_camera_sweep_is_not_reported_as_failed_joint_geometry(tmp_path, monkeypatch):
_, rows, raw = write_actual_capture(tmp_path)
unrelated = deepcopy(rows[2])
unrelated.update(kind="diagnostic_observation_frame", sample_phase="sweep",
task_name="thumb_yaw_top", cycle=0, direction="decreasing", attempt=1)
unrelated["image_stamp_ns"] += 1
for item in unrelated["tags"].values():
item["candidate_diagnostics"]["observation_stamp_ns"] = unrelated["image_stamp_ns"]
# The front camera was running for a later task in this same diagnostic;
# it has no measurement relation in the top-view yaw task.
rows.append(unrelated)
raw.write_text("".join(json.dumps(row)+"\n" for row in rows))
calls = mock_image_analysis(monkeypatch)
report = analysis.analyze_capture(load_bundled_hand_profile("o6_right_8"), raw)
assert len(calls) == len(report["stages"]) == 1
assert report["stages"][0]["task_name"] == "thumb_pitch_ip_front"
def test_fourth_round_sweep_never_enters_diagnostic_training(tmp_path, monkeypatch):
fixture = recorded_fixture("175815")
rows = [fixture["session_start"], *fixture["records"]]
for row in rows[1:]:
row["cycle"] = 3
raw = tmp_path / "raw_samples.jsonl"
raw.write_text("".join(json.dumps(row)+"\n" for row in rows))
before = raw.read_bytes()
monkeypatch.setattr(motion_image_diagnostics, "analyze_motion_images",
lambda *args, **kwargs: pytest.fail("holdout images reached optimizer"))
with pytest.raises(ValueError, match="refuses_holdout_training"):
analysis.analyze_capture(load_bundled_hand_profile("o6_right_8"), raw)
assert raw.read_bytes() == before
@pytest.mark.parametrize("collision", ["filename", "symlink", "hardlink"])
def test_analysis_outputs_cannot_replace_raw_input(tmp_path, monkeypatch, collision):
import os
_, _, raw = write_actual_capture(tmp_path)
before = raw.read_bytes()
target = tmp_path / "diagnostic_analysis.md"
if collision == "filename":
raw.rename(target)
raw = target
elif collision == "symlink":
target.symlink_to(raw)
else:
os.link(raw, target)
monkeypatch.setattr(motion_image_diagnostics, "analyze_motion_images",
lambda *args, **kwargs: pytest.fail("output collision reached optimizer"))
with pytest.raises(ValueError, match="output_overwrites_source"):
analysis.analyze_capture(load_bundled_hand_profile("o6_right_8"), raw)
assert raw.read_bytes() == before
def test_failure_report_cannot_bypass_output_source_collision_guard(tmp_path):
_, _, raw = write_actual_capture(tmp_path)
before = raw.read_bytes()
(tmp_path / "diagnostic_analysis.md").symlink_to(raw)
report = analysis.finish_diagnostic_analysis(load_bundled_hand_profile("o6_right_8"), tmp_path)
assert report["status"] == "failed"
assert report["accuracy_validated"] is report["publication_allowed"] is False
assert raw.read_bytes() == before
def test_failure_report_write_error_does_not_escape_into_capture_failure(tmp_path, monkeypatch, capsys):
_, _, raw = write_actual_capture(tmp_path)
before = raw.read_bytes()
def fail(*args, **kwargs):
raise OSError("diagnostic_output_unwritable")
monkeypatch.setattr(analysis, "analyze_capture", fail)
monkeypatch.setattr(analysis, "atomic_write_json", fail)
report = analysis.finish_diagnostic_analysis(load_bundled_hand_profile("o6_right_8"), tmp_path)
assert report["status"] == "failed"
assert report["publication_allowed"] is report["accuracy_validated"] is False
assert raw.read_bytes() == before
assert "原始采集状态保持不变" in capsys.readouterr().out
@@ -1,401 +0,0 @@
"""Real coordinator diagnostics with projected Tags and an in-memory SDK clock."""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
from dataclasses import replace
from pathlib import Path
from threading import Event
import cv2
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.geometry.pnp import square_object_points
from linkerhand_calibration.core.geometry.tag_pose.tracking import SquareTagPoseTracker
from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection
from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from runtime_host_fixture import coordinator_fixture, ready
THUMB_JOINTS = {"rh_thumb_cmc_yaw", "rh_thumb_cmc_pitch", "rh_thumb_ip"}
TERMINAL_PHASES = {Phase.DIAGNOSTIC_COMPLETE, Phase.PAUSED, Phase.ABORTED, Phase.FAILED}
def projected_frame(host, view, feedback, *, stationary_centers=False):
"""Project fixed mounts on two synthetic revolute chains.
The previous transport fixture rotated each Tag about its own fixed centre.
That is deliberately retained below as an unobservable counterexample. The
complete-flow fixture now supplies the physical moving-centre evidence that
pre-freeze branch confirmation requires; no recorded baseline is rewritten.
"""
yaw = (255 - feedback[1]) / 255 * .8
pitch = (255 - feedback[0]) / 255 * .9
ip = (255 - feedback[0]) / 255 * .5
base_rotation = Rotation.from_euler("xyz", [.2, .1, 0])
base_center = np.asarray([-.07, -.045, .4])
yaw_rotation = base_rotation * Rotation.from_rotvec([0, 0, yaw])
pitch_rotation = base_rotation * Rotation.from_rotvec([0, 0, pitch])
ip_rotation = pitch_rotation * Rotation.from_rotvec([-ip, 0, 0])
pitch_joint = base_center + base_rotation.apply([0.015, 0.02, 0])
ip_joint = pitch_joint + pitch_rotation.apply([0.06, 0, 0])
mounts = {
"thumb_yaw": (yaw_rotation, base_center + yaw_rotation.apply([.06, 0, 0])),
"thumb_pitch": (pitch_rotation, pitch_joint + pitch_rotation.apply([.03, 0, 0])),
"thumb_ip": (ip_rotation, ip_joint + ip_rotation.apply([0, .04, 0])),
}
detections = []
for index, tag in enumerate(next(v for v in host.profile.vision.views if v.name == view).tags):
if not tag.fixed_reference and tag.role not in mounts:
continue # Unselected pinky Tags stay hidden throughout the test.
rotation, center = mounts.get(tag.role, (base_rotation, base_center))
if stationary_centers:
center = np.asarray([-.07 + .07 * index, -.045 + .025 * index, .4])
rvec = rotation.as_rotvec()
corners, _ = cv2.projectPoints(square_object_points(tag.size_m), rvec, center,
host.cameras.matrices[view], np.zeros(5))
detections.append(TagDetection(tag.tag_id,
tuple(tuple(float(value) for value in row) for row in corners.reshape(4, 2)), 0, 100.))
return DetectionInput(view, host.ports.clock_ns(), tuple(detections))
def start_diagnostic(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
ready(host, clock)
for view in set(host.profile.vision.view_names) - set(host.required_observation_views):
for mapping in (host.cameras.matrices, host.cameras.image_sizes,
host.cameras.info_received_at, host.cameras.detections_received_at):
mapping.pop(view, None)
for view in host.required_observation_views:
host.cameras.matrices[view] = np.asarray([[1000., 0., 320.], [0., 1000., 240.], [0., 0., 1.]])
monkeypatch.setattr(host.finalization, "start", lambda *a, **kw: pytest.fail("diagnostic started finalizer"))
assert host.start().success
return host, clock
def advance(host, clock, *, images=True):
clock.now += .04
feedback = clock.positions[-1] if clock.positions else host.profile.command.baseline_values
host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns())
host.tick()
if images:
task = next((task for task in host.profile.motion.tasks
if host._motion is not None and task.key == host._motion.task_key), None)
views = (task.view,) if task is not None else host.required_observation_views
for view in views:
host.receive_detections(projected_frame(host, view, feedback))
def run_until(host, clock, predicate, *, images=True):
for _ in range(8000):
if predicate():
return
if host.execution.session.phase in TERMINAL_PHASES:
pytest.fail(f"premature terminal state: {host.state}: {host.reason}")
advance(host, clock, images=images)
pytest.fail(f"virtual capture timed out: {host.state}: {host.reason}")
def assert_terminal_is_idle(host, clock, monkeypatch):
before = (len(clock.positions), len(clock.settings), len(host.raw_records), host.raw_path.read_bytes())
monkeypatch.setattr(host.capture, "consume", lambda *a, **kw: pytest.fail("terminal observation consumed"))
for _ in range(5):
advance(host, clock)
assert (len(clock.positions), len(clock.settings), len(host.raw_records), host.raw_path.read_bytes()) == before
@pytest.mark.parametrize("diagnostic", [True, False])
def test_legacy_3d_stationary_centers_keep_raw_without_confirming_zero(tmp_path, monkeypatch, diagnostic):
import sys
original = projected_frame
monkeypatch.setattr(sys.modules[__name__], "projected_frame",
lambda host, view, feedback: original(host, view, feedback, stationary_centers=True))
if diagnostic:
host, clock = start_diagnostic(tmp_path, monkeypatch)
else:
host, clock = coordinator_fixture(tmp_path)
ready(host, clock)
for view in host.required_observation_views:
host.cameras.matrices[view] = np.asarray([[1000., 0., 320.], [0., 1000., 240.], [0., 0., 1.]])
assert host.start().success
# Historical 3D candidate geometry requires center translation. The new
# image solver uses all corners and may observe rotation around a Tag's
# center; this test retains the old diagnostic/compatibility contract.
host.branch_initialization.image_geometry = False
try:
run_until(host, clock, lambda: host.execution.session.phase in TERMINAL_PHASES)
if diagnostic:
assert host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE
assert set(host.execution.diagnostic_zero_attempts) == THUMB_JOINTS
attempts = [row for row in host.raw_records if row.get("kind") == "diagnostic_joint_zero_attempt"]
assert len(attempts) == 2 # One visit per group across both directions.
assert all(row["no_zero_locked"] and not row["is_accepted_joint_sample"] for row in attempts)
assert len(host.execution.completed_units) == 4
else:
assert host.execution.session.phase == Phase.PAUSED
assert host.reason == "joint_zero_motion_unresolved:view=top:joints=rh_thumb_cmc_yaw:motion_geometry_unresolved"
assert not host.execution.diagnostic_zero_attempts
assert not host.execution.zero_references
reports = [r for r in host.raw_records if r.get("kind") == "motion_branch_initialization"]
assert reports and all(r["status"] == "unresolved" for r in reports)
assert any(q["reason"] == "insufficient_pivot_translation"
for r in reports for h in r["hypotheses"] for q in h["relations"])
assert not any(row.get("kind") == "joint_sample" for row in host.raw_records)
assert not host.finalization.started and not host.final_json and not host.final_urdf
assert_terminal_is_idle(host, clock, monkeypatch)
finally:
host.close()
@pytest.mark.parametrize("unused_side", ["absent", "ambiguous_recorded_tag"])
def test_complete_thumb_diagnostic_uses_real_capture_and_never_publishes(tmp_path, monkeypatch, unused_side):
host, clock = start_diagnostic(tmp_path, monkeypatch)
from linkerhand_calibration.core.domain.motion_path import record_scan_identity
evaluate = host.execution.evaluate
evaluated_windows = []
def evaluate_window(records, **kwargs):
assert records is host._unit_rows
unit = host.execution.action.scan_unit
assert all(record_scan_identity(row) == unit.identity for row in records
if row.get("sample_phase") == "sweep")
evaluated_windows.append(unit.identity)
return evaluate(records, **kwargs)
monkeypatch.setattr(host.execution, "evaluate", evaluate_window)
# This historical transport fixture assumes its 3D PnP hinge solution is
# available; its nearly frontal mirror families need not be identifiable
# by the new image optimizer. Preserve the original capture/fault contract
# here. test_image_motion_live_flow covers the complete production image
# path with recorded preparation and directional observations.
host.branch_initialization.image_geometry = False
if unused_side == "ambiguous_recorded_tag":
fixture = json.loads((Path(__file__).with_name("fixtures") /
"o6_side_base_20260914_190619.json").read_text())
item = fixture["observation"]["pnp_observation_evidence"]
matrix = np.asarray(fixture["camera_model"]["camera_matrix"])
corners = item["corners_xy"]
# First reproduce the real stationary ambiguity with the unchanged
# tracker. Side transport then delivers the same corners during this
# run, but must never become a reference dependency of the thumb.
tracker = SquareTagPoseTracker()
for i in range(12):
pose, reason = tracker.estimate("side_base", corners,
tag_size_m=item["tag_size_m"], camera_matrix=matrix, stamp_ns=(i+1)*40_000_000)
assert pose is None and reason == "pose_branch_unconfirmed"
assert tracker.observation_snapshot("side_base", 12*40_000_000)[1]["branch_revision"] == 0
receive = host.receive_detections
def with_unused_side(frame):
receive(DetectionInput("side", frame.stamp_ns,
(TagDetection(3, tuple(tuple(p) for p in corners), 0, 100.),)))
receive(frame)
monkeypatch.setattr(host, "receive_detections", with_unused_side)
try:
run_until(host, clock, lambda: host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE)
assert set(host.execution.zero_references) == THUMB_JOINTS
zero_rows = [row for row in host.raw_records if row.get("kind") == "joint_zero_reference"]
assert len(zero_rows) == 3
sweeps = [row for row in host.raw_records if row.get("kind") == "joint_sample"]
assert sweeps
assert {row["joint"] for row in sweeps} == THUMB_JOINTS
assert {row["sample_phase"] for row in sweeps} == {"sweep"}
assert {row["cycle"] for row in sweeps} == {0}
assert {row["direction"] for row in sweeps} == {"increasing", "decreasing"}
assert all(row["pnp_observation_evidence"] for row in sweeps)
assert len(host.execution.completed_units) == 4
assert set(evaluated_windows) == host.execution.completed_units
assert all(tuple(command[2:]) == host.profile.command.baseline_values[2:] for command in clock.positions)
assert host.last_command == host.profile.command.baseline_values
assert host.latest_feedback == host.profile.command.baseline_values
assert not host.finalization.started
assert not host.final_json and not host.final_urdf
assert not list(tmp_path.rglob("*.urdf"))
assert not any(path.is_symlink() for path in tmp_path.rglob("*"))
report_path = host.parameters.session_dir / "diagnostic_capture.json"
report_text = report_path.read_text()
report = json.loads(report_text)
assert report["result"] == "DIAGNOSTIC_COMPLETE"
assert report["publication_allowed"] is False
assert report["accuracy_validated"] is False
assert report["raw_samples_sha256"] == hashlib.sha256(host.raw_path.read_bytes()).hexdigest()
assert set(report["joint_zero_references"]) == THUMB_JOINTS
assert len(report["completed_units"]) == 4
assert len(report["unit_quality"]) == 4
assert all(row["passed"] and row["accepted_pose_quality"]["passed"]
for row in report["unit_quality"])
assert report["accepted_pose_quality_incomplete_attempts"] == 0
assert report["pose_step_summary"]
assert {key: report["protected_inputs"][key] for key in host.parameters.protected_inputs} == host.parameters.protected_inputs
from linkerhand_calibration.core.artifacts.storage import load_jsonl
captured = load_jsonl(host.raw_path)
# A one-round thumb diagnostic proves its recorded references, not
# full-hand coverage or the formal four-round accuracy contract.
evidence = validate_motion_provenance(host.profile, captured, required=True)
assert evidence["motion_evidence_verified"] is True
assert evidence["is_accuracy_certificate"] is False
assert not host.capture_index.retain_complete
assert not host.capture_index.retain_training_geometry
assert not any(row.get("kind") in {"image_observation_frame",
"diagnostic_observation_frame", "motion_branch_observation"}
for row in host.capture_index)
locked = next(row for row in captured if row["kind"] == "fixed_base_reference_locked")
assert len([row for row in captured if row["kind"] == "fixed_base_reference_locked"]) == 1
assert set(locked["fixed_corners_by_view"]) == {"front", "top"}
assert set(locked["fixed_poses"]) == {"front", "top"}
assert set(host.capture.locked_reference_views()) == {"front", "top"}
assert not any(row.get("view") == "side" for row in captured)
assert set(host.trackers) == {"front", "top"}
start = next(row for row in captured if row["kind"] == "session_start")
assert start["required_observation_views"] == ["front", "top"]
assert report["protected_inputs"] == locked["protected_hashes"]
assert len(report["protected_inputs"]["intrinsics_sha256"]) == 64
snapshot = host.snapshot().calibration
assert snapshot.phase == "DIAGNOSTIC_COMPLETE"
assert snapshot.overall_progress_01 == 1.
assert snapshot.task.cycle_count == 1
assert_terminal_is_idle(host, clock, monkeypatch)
assert not host.abort().success
assert not host.start().success
assert report_path.read_text() == report_text
finally:
host.close()
@pytest.mark.parametrize("fault", ["ambiguous_pose", "missing_tag"])
def test_diagnostic_retains_rejected_candidates_but_missing_tag_still_retries_once(tmp_path, monkeypatch, fault):
host, clock = start_diagnostic(tmp_path, monkeypatch)
# This historical transport fixture assumes its 3D PnP hinge solution is
# available; its nearly frontal mirror families need not be identifiable
# by the new image optimizer. Preserve the original capture/fault contract
# here. test_image_motion_live_flow covers the complete production image
# path with recorded preparation and directional observations.
host.branch_initialization.image_geometry = False
commit = SquareTagPoseTracker.commit_motion_frame
def affected(role):
return role == "thumb_ip" and host._motion is not None and host._motion.phase == "sweep"
# Inject rejection at the new atomic delivery boundary after real PnP and
# geometric selection. The geometry tests separately exercise ambiguity;
# this transport test still requires retaining the original raw candidates.
def reject_motion(selections, **kwargs):
if any(affected(item.snapshot.role) for item in selections):
return SquareTagPoseTracker.reject_motion_frame(
tuple(item.snapshot for item in selections), "pose_branch_ambiguous")
return commit(selections, **kwargs)
receive = host.receive_detections
def hide_tag(frame):
if host._motion is not None and host._motion.phase == "sweep" and frame.view == "front":
frame = replace(frame, detections=tuple(tag for tag in frame.detections if tag.tag_id != 2))
return receive(frame)
if fault == "ambiguous_pose":
monkeypatch.setattr(SquareTagPoseTracker, "commit_motion_frame", staticmethod(reject_motion))
else:
monkeypatch.setattr(host, "receive_detections", hide_tag)
try:
run_until(host, clock, lambda: host.execution.session.phase in TERMINAL_PHASES)
assert not host.finalization.started
assert not host.final_json and not host.final_urdf
assert len([row for row in host.raw_records if row.get("kind") == "joint_zero_reference"]) == 3
assert not any(row.get("kind") == "joint_sample" and row.get("joint") == "rh_thumb_ip"
for row in host.raw_records)
checks = [row for row in host.raw_records if row.get("kind") == "scan_unit_complete"
and row["task_name"] == "thumb_pitch_ip_front"]
assert len(checks) == 2
assert all(row["accepted_pose_quality"]["passed"] is False for row in checks)
if fault == "missing_tag":
assert host.execution.session.phase == Phase.PAUSED
assert "sweep_quality_failed" in host.reason
assert host.snapshot().calibration.pause.details["observation_timing"] == checks[-1]["observation_timing"]
assert [row["attempt"] for row in checks] == [1, 2]
assert all(row["passed"] is False for row in checks)
assert not (host.parameters.session_dir / "diagnostic_capture.json").exists()
else:
assert host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE
assert [row["attempt"] for row in checks] == [1, 1]
assert all(row["passed"] for row in checks)
frames = [row for row in host.raw_records if row.get("kind") == "diagnostic_observation_frame"
and row["view"] == "front" and row["sample_phase"] == "sweep"]
assert frames and all(row["tags"]["thumb_ip"]["selected_pose"] is None for row in frames)
assert all(row["tags"]["thumb_ip"]["reprojection_valid_candidates"] for row in frames)
assert {row["direction"] for row in frames} == {"increasing", "decreasing"}
report = json.loads((host.parameters.session_dir / "diagnostic_capture.json").read_text())
assert report["accepted_pose_quality_incomplete_attempts"] == 2
assert not report["accuracy_validated"] and not report["publication_allowed"]
finally:
host.close()
@pytest.mark.parametrize("action", ["pause", "abort"])
def test_interrupted_thumb_diagnostic_holds_without_success_report(tmp_path, monkeypatch, action):
host, clock = start_diagnostic(tmp_path, monkeypatch)
try:
run_until(host, clock, lambda: host._motion is not None and host._motion.phase == "sweep"
and host.last_command[1] < 240)
if action == "pause":
host._pause("test_visual_fault")
assert host.execution.session.phase == Phase.PAUSED
else:
assert host.abort().success
assert host.execution.session.phase == Phase.ABORTED
assert host.last_command != host.profile.command.baseline_values
assert not (host.parameters.session_dir / "diagnostic_capture.json").exists()
assert not host.finalization.started
assert_terminal_is_idle(host, clock, monkeypatch)
finally:
host.close()
def test_diagnostic_report_write_failure_is_terminal_and_cannot_restart_motion(tmp_path, monkeypatch):
from linkerhand_calibration.runtime import coordinator as coordinator_module
original_write = coordinator_module.atomic_write_json
def write(path, payload):
if path.name == "diagnostic_capture.json":
raise OSError("synthetic report write failure")
return original_write(path, payload)
monkeypatch.setattr(coordinator_module, "atomic_write_json", write)
host, clock = start_diagnostic(tmp_path, monkeypatch)
try:
run_until(host, clock, lambda: host.execution.session.phase == Phase.FAILED)
assert "diagnostic_capture_report_failed:synthetic report write failure" == host.reason
assert not (host.parameters.session_dir / "diagnostic_capture.json").exists()
assert not host.finalization.started
assert not host.final_json and not host.final_urdf
assert_terminal_is_idle(host, clock, monkeypatch)
finally:
host.close()
def test_pose_started_before_diagnostic_completion_cannot_change_hashed_capture(tmp_path, monkeypatch):
host, clock = start_diagnostic(tmp_path, monkeypatch)
entered, release = Event(), Event()
try:
run_until(host, clock, lambda: host.execution.session.phase == Phase.RETURN_BASELINE)
def consume(*args, **kwargs):
entered.set()
assert release.wait(5)
return ({"kind": "late_sample"},), None
monkeypatch.setattr(host.capture, "consume", consume)
with ThreadPoolExecutor(1) as pool:
future = pool.submit(host.receive_detections,
DetectionInput("front", host.ports.clock_ns(), ()))
assert entered.wait(2)
epoch = host._observation_epoch
run_until(host, clock, lambda: host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE,
images=False)
assert host._observation_epoch > epoch
report = json.loads((host.parameters.session_dir / "diagnostic_capture.json").read_text())
before = host.raw_path.read_bytes()
release.set()
future.result(timeout=2)
assert all(row.get("kind") != "late_sample" for row in host.raw_records)
assert host.raw_path.read_bytes() == before
assert hashlib.sha256(before).hexdigest() == report["raw_samples_sha256"]
finally:
release.set()
host.close()
@@ -1,216 +0,0 @@
"""Only the selected diagnostic's camera dependencies may gate its session."""
from dataclasses import replace
import hashlib
import json
import numpy as np
import pytest
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.diagnostic_capture import (
DiagnosticCapturePlan,
resolve_diagnostic_capture,
)
from linkerhand_calibration.runtime.inputs import CameraModelInput, DetectionInput
from linkerhand_calibration.runtime.observation_scope import required_observation_views
from linkerhand_calibration.runtime.session import CalibrationPhase, CalibrationSession
from runtime_host_fixture import coordinator_fixture, ready
@pytest.mark.parametrize("profile_name", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"])
def test_formal_sessions_keep_every_declared_camera(profile_name):
profile = load_bundled_hand_profile(profile_name)
assert required_observation_views(profile) == profile.vision.view_names
assert CalibrationSession(profile).required_observation_views == profile.vision.view_names
def test_o6_thumb_dependencies_are_front_and_top():
profile = load_bundled_hand_profile("o6_right_8")
plan = resolve_diagnostic_capture(profile, "o6_thumb")
assert required_observation_views(profile, plan) == ("front", "top")
assert CalibrationSession(profile, diagnostic_capture=plan).required_observation_views == ("front", "top")
def test_virtual_task_and_camera_names_follow_measurements_including_secondary_view():
profile = load_bundled_hand_profile("o6_right_8")
renamed = {"front": "inspect", "side": "oblique", "top": "overhead"}
views = {view.name: replace(view, name=renamed[view.name]) for view in profile.vision.views}
tasks = tuple(replace(task, key=f"virtual_{task.key}", view=renamed[task.view])
for task in reversed(profile.motion.tasks))
measurements = {name: replace(spec, view=renamed.get(spec.view, spec.view))
for name, spec in profile.measurement.measurements.items()}
yaw = "rh_thumb_cmc_yaw"
secondary = "yaw_from_oblique_camera"
measurements[secondary] = replace(measurements[yaw], joint=secondary, view="oblique")
virtual = replace(profile,
vision=replace(profile.vision, views=(views["side"], views["top"], views["front"]),
extrinsic_reference_view="inspect"),
motion=replace(profile.motion, tasks=tasks),
measurement=replace(profile.measurement, measurements=measurements,
cross_view_sources={yaw: secondary}))
task = next(task for task in tasks if yaw in task.joints)
plan = DiagnosticCapturePlan("virtual_selection", (task.key,))
# Order comes from the rig, while inclusion comes from both observations.
assert required_observation_views(virtual, plan) == ("oblique", "overhead")
assert required_observation_views(virtual) == ("oblique", "overhead", "inspect")
def _remove_camera(host, view):
for mapping in (host.cameras.matrices, host.cameras.image_sizes,
host.cameras.info_received_at, host.cameras.detections_received_at,
host.cameras.errors, host.cameras.models):
mapping.pop(view, None)
def _invalid_camera_info():
# A well-shaped model with a different identity from the protected cameras.
return CameraModelInput(640, 480,
(500., 0., 320., 0., 500., 240., 0., 0., 1.), (),
tuple(np.eye(3).flat),
(500., 0., 320., 0., 0., 500., 240., 0., 0., 0., 1., 0.))
def test_missing_side_pipeline_does_not_block_thumb_ready_or_start(tmp_path):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
ready(host, clock)
_remove_camera(host, "side")
host.tick()
assert host.execution.session.phase == CalibrationPhase.READY
devices = host._device_status(clock.now)
assert devices.ready
assert set(devices.cameras) == {"front", "top"}
assert host.start().success
assert host.reference_lock.fixed_tag_by_view == {"front": 0, "top": 6}
preflight = json.loads((host.parameters.session_dir / "camera_preflight.json").read_text())
assert set(preflight["configuration"]) == {"front", "top"}
finally:
host.close()
@pytest.mark.parametrize("view", ["front", "top"])
@pytest.mark.parametrize("fault", ["info_missing", "detections_stale", "invalid_configuration"])
def test_required_camera_fault_after_ready_blocks_start_without_motion(tmp_path, view, fault):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
ready(host, clock)
if fault == "info_missing":
host.cameras.info_received_at.pop(view)
elif fault == "detections_stale":
host.cameras.detections_received_at[view] = clock.now - 2.01
else:
host.receive_camera_info(view, _invalid_camera_info())
assert not host.start().success
assert host.execution.session.phase == CalibrationPhase.WAIT_DEVICE
assert view in host.reason
assert not host.started
assert not clock.positions
assert not clock.settings
finally:
host.close()
@pytest.mark.parametrize("view", ["front", "top"])
def test_required_camera_model_changes_still_pause_started_diagnostic(tmp_path, view):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
ready(host, clock)
assert host.start().success
host.receive_camera_info(view, _invalid_camera_info())
assert host.execution.session.phase == CalibrationPhase.PAUSED
assert host.reason.startswith(f"protected_camera_model_changed:{view}:")
assert not host.final_json and not host.final_urdf
finally:
host.close()
def test_side_callbacks_cannot_change_diagnostic_evidence_or_pause(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
ready(host, clock)
assert host.start().success
host.reference_lock.start_locking()
corners = np.asarray([[10., 10.], [30., 10.], [30., 30.], [10., 30.]])
for view, tag in host.reference_lock.fixed_tag_by_view.items():
for _ in range(host.reference_lock.minimum_frames):
host.reference_lock.observe(view, tag, corners)
assert host.reference_lock.locked
fingerprint = host.reference_lock.fingerprint()
before = host.raw_path.read_bytes()
record_kinds = [row["kind"] for row in host.raw_records]
seen = dict(host.cameras.detections_received_at)
matrices = {view: matrix.copy() for view, matrix in host.cameras.matrices.items()}
errors = dict(host.cameras.errors)
phase = host.execution.session.phase
monkeypatch.setattr(host.capture, "consume",
lambda *args, **kwargs: pytest.fail("unselected side observation entered capture"))
clock.now += .1
host.receive_detections(DetectionInput("side", host.ports.clock_ns(), ()))
host.receive_camera_info("side", _invalid_camera_info())
assert host.execution.session.phase == phase
assert host.cameras.detections_received_at == seen
assert host.cameras.errors == errors
for view, matrix in matrices.items():
np.testing.assert_array_equal(host.cameras.matrices[view], matrix)
assert host.reference_lock.fingerprint() == fingerprint
assert host.raw_path.read_bytes() == before
assert [row["kind"] for row in host.raw_records] == record_kinds
finally:
host.close()
def test_diagnostic_intrinsics_fingerprint_ignores_unselected_cached_camera(tmp_path):
host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
ready(host, clock)
assert host.start().success
host.reference_lock.start_locking()
corners = np.asarray([[10., 10.], [30., 10.], [30., 30.], [10., 30.]])
for view, tag in host.reference_lock.fixed_tag_by_view.items():
for _ in range(host.reference_lock.minimum_frames):
host.reference_lock.observe(view, tag, corners)
host._save_reference()
first = host._current_fingerprint
expected = hashlib.sha256(json.dumps({view: host.cameras.matrices[view].tolist()
for view in ("front", "top")}, sort_keys=True).encode()).hexdigest()
assert first["protected_hashes"]["intrinsics_sha256"] == expected
assert set(first["fixed_corners_by_view"]) == {"front", "top"}
# Cached data can predate Start; its presence cannot alter this session's identity.
host.cameras.matrices["side"] = 9. * np.eye(3)
host._save_reference()
assert host._current_fingerprint == first
finally:
host.close()
def test_formal_side_pipeline_remains_a_required_device(tmp_path):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
_remove_camera(host, "side")
host.tick()
assert host.execution.session.phase == CalibrationPhase.WAIT_DEVICE
assert "side" in host.reason
assert not host.start().success
assert set(host.reference_lock.fixed_tag_by_view) == {"front", "side", "top"}
finally:
host.close()
def test_formal_side_callbacks_still_enter_capture_and_check_camera_identity(tmp_path, monkeypatch):
host, clock = coordinator_fixture(tmp_path)
try:
ready(host, clock)
assert host.start().success
calls = []
monkeypatch.setattr(host.capture, "consume", lambda frame, *args, **kwargs: calls.append(frame) or ([], None))
clock.now += .1
host.receive_detections(DetectionInput("side", host.ports.clock_ns(), ()))
assert [frame.view for frame in calls] == ["side"]
assert host.cameras.detections_received_at["side"] == clock.now
host.receive_camera_info("side", _invalid_camera_info())
assert host.execution.session.phase == CalibrationPhase.PAUSED
assert host.reason.startswith("protected_camera_model_changed:side:")
finally:
host.close()
@@ -1,128 +0,0 @@
"""Named diagnostic presets keep motion bounded and cannot publish or resume."""
from pathlib import Path
import pytest
from linkerhand_calibration.product import load_product_config
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime import runner
from linkerhand_calibration.runtime.diagnostic_capture import resolve_diagnostic_capture, reject_diagnostic_capture
from linkerhand_calibration.runtime.engine import CalibrationEngine
PACKAGE = Path(__file__).resolve().parents[1]
def test_all_o30_tasks_cover_every_joint_without_extra_rounds():
from linkerhand_calibration.runtime.observation_scope import required_observation_views
from linkerhand_calibration.core.domain.capture_plan import configure_training, TASKWISE_TWO_ROUND
from linkerhand_calibration.profiles.observer_policy import configure_observer_policy
profile = configure_observer_policy(configure_training(
load_bundled_hand_profile('o30_right_18'), TASKWISE_TWO_ROUND))
plan = resolve_diagnostic_capture(profile, 'o30_all_tasks')
assert plan.task_keys == tuple(task.key for task in profile.motion.tasks)
assert len(plan.task_keys) == 17
assert {joint for task in profile.motion.tasks for joint in task.joints} == set(profile.zero.active_joints)
assert required_observation_views(profile, plan) == ('front', 'side', 'top')
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
assert len(units) == 36
assert {unit.cycle for unit in units} == {0}
assert not profile.motion.preparation_witnesses
assert not profile.motion.observer_preparations
with pytest.raises(ValueError, match='diagnostic_capture_not_for_publication_or_resume'):
reject_diagnostic_capture([dict(kind='session_start', diagnostic_capture=plan.as_dict())])
def test_o30_pinky_preset_keeps_dependency_order_and_side_camera():
from linkerhand_calibration.runtime.observation_scope import required_observation_views
profile = load_bundled_hand_profile('o30_right_18')
plan = resolve_diagnostic_capture(profile, 'o30_pinky_side')
assert plan.task_keys == ('pinky_mcp_pitch_side', 'pinky_pip_side', 'pinky_dip_side')
assert required_observation_views(profile, plan) == ('side',)
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
assert [(u.task_key, u.cycle, u.direction) for u in units] == [
(key, 0, direction) for key in plan.task_keys
for direction in ('increasing', 'decreasing')]
with pytest.raises(ValueError, match='diagnostic_capture_not_for_publication_or_resume'):
reject_diagnostic_capture([dict(kind='session_start', diagnostic_capture=plan.as_dict())])
def test_o30_roll_preset_keeps_one_round_and_existing_clearance():
profile = load_bundled_hand_profile('o30_right_18')
plan = resolve_diagnostic_capture(profile, 'o30_thumb_roll')
assert plan.task_keys == ('thumb_cmc_roll_front',)
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
assert [(u.task_key, u.cycle, u.direction) for u in units] == [
('thumb_cmc_roll_front', 0, 'increasing'), ('thumb_cmc_roll_front', 0, 'decreasing')]
task = profile.motion.tasks[0]
assert task.auxiliary_commands == ((2, 0),)
assert task.exit_waypoints == (((0, 0),), ((2, 255),))
with pytest.raises(ValueError, match='diagnostic_capture_not_for_publication_or_resume'):
reject_diagnostic_capture([dict(kind='session_start', diagnostic_capture=plan.as_dict())])
def test_existing_finger_roll_adds_camera_not_motion():
from dataclasses import replace
from linkerhand_calibration.runtime.observation_scope import required_observation_views
profile = load_bundled_hand_profile('o30_right_18')
plan = resolve_diagnostic_capture(profile, 'o30_finger_roll_views')
assert plan.task_keys == ('fingers_mcp_roll_front',)
assert plan.observer_views == ('side',)
assert required_observation_views(profile, plan) == ('front', 'side')
primary_only = replace(plan, observer_views=())
assert required_observation_views(profile, primary_only) == ('front',)
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
assert units == CalibrationEngine(profile, diagnostic_capture=primary_only).scan_units()
assert {unit.cycle for unit in units} == {0}
assert plan.as_dict()['observer_views'] == ('side',)
assert 'observer_views' not in primary_only.as_dict()
with pytest.raises(ValueError, match='diagnostic_observation_views_invalid'):
required_observation_views(profile, replace(plan, observer_views=('unknown',)))
with pytest.raises(ValueError, match='diagnostic_observer_views_invalid'):
replace(plan, observer_views=('side', 'side'))
with pytest.raises(ValueError, match='diagnostic_capture_not_for_publication_or_resume'):
reject_diagnostic_capture([dict(kind='session_start', diagnostic_capture=plan.as_dict())])
def test_named_diagnostic_cli_disables_resume_and_uses_common_runner(monkeypatch):
config = load_product_config(PACKAGE/'config/o30_right_product.yaml',
workspace=PACKAGE.parents[1], check_can=False)
monkeypatch.setattr(runner, 'load_product_config', lambda *args, **kwargs: config)
calls = []
monkeypatch.setattr(runner, 'run_online', lambda config, **kwargs: calls.append(kwargs) or 0)
with pytest.raises(SystemExit) as stopped:
runner.main(['--diagnostic-capture', 'o30_thumb_roll', '--training-policy', 'fixed'])
assert stopped.value.code == 0
assert calls == [dict(record_bag=False, commands_enabled=True, allow_resume=False,
diagnostic_capture='o30_thumb_roll')]
with pytest.raises(SystemExit) as stopped:
runner.main(['--diagnostic-capture', 'o6_thumb'])
assert stopped.value.code == 2
assert len(calls) == 1
def test_chain_cli_uses_two_plus_one_default_and_never_resumes(monkeypatch):
config = load_product_config(PACKAGE/'config/o30_right_product.yaml',
workspace=PACKAGE.parents[1], check_can=False)
monkeypatch.setattr(runner, 'load_product_config', lambda *args, **kwargs: config)
calls = []
monkeypatch.setattr(runner, 'run_online', lambda config, **kwargs: calls.append(kwargs) or 0)
with pytest.raises(SystemExit) as stopped:
runner.main(['--diagnostic-capture', 'o30_pinky_chain', '--training-policy', 'fixed'])
assert stopped.value.code == 2 and not calls
with pytest.raises(SystemExit) as stopped:
runner.main(['--diagnostic-capture', 'o30_pinky_chain'])
assert stopped.value.code == 0
assert calls == [dict(record_bag=False, commands_enabled=True, allow_resume=False,
diagnostic_capture='o30_pinky_chain')]
@pytest.mark.parametrize('other', [['--offline-raw', 'capture.jsonl'], ['--publish-offline'],
['--commands-disabled'], ['--diagnostic-thumb']])
def test_named_diagnostic_cli_rejects_conflicting_modes(monkeypatch, other):
monkeypatch.setattr(runner, 'run_online', lambda *args, **kwargs: pytest.fail('must not start hardware'))
with pytest.raises(SystemExit) as stopped:
runner.main(['--diagnostic-capture', 'o30_thumb_roll', *other])
assert stopped.value.code == 2
@@ -1,98 +0,0 @@
"""A diagnostic thumb capture cannot become a production fit or checkpoint."""
from copy import deepcopy
import json
import pytest
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session
from linkerhand_calibration.runtime.artifacts.replay import validate_capture_provenance
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CAPTURE_SCHEDULE_VERSION
from linkerhand_calibration.runtime.resume import discover_resume_candidate
DIAGNOSTIC = {
"mode": "o6_thumb",
"task_keys": ["thumb_yaw_top", "thumb_pitch_ip_front"],
"cycles": [0],
}
@pytest.mark.parametrize("marker", [DIAGNOSTIC, True, "o6_thumb"])
def test_offline_replay_rejects_diagnostic_header_before_other_provenance(marker):
rows = [{"kind": "session_start", "diagnostic_capture": marker}]
before = deepcopy(rows)
with pytest.raises(ValueError, match="^diagnostic_capture_not_for_publication_or_resume$"):
validate_capture_provenance(None, "TEST", {}, rows)
assert rows == before
@pytest.mark.parametrize("record", [
{"kind": "diagnostic_observation_frame"},
{"kind": "scan_unit_complete", "quality_scope": "diagnostic_observation_coverage"},
])
def test_removing_header_does_not_promote_diagnostic_records(record):
with pytest.raises(ValueError, match="^diagnostic_capture_not_for_publication_or_resume$"):
validate_capture_provenance(None, "TEST", {}, [record])
@pytest.mark.parametrize("publish", [False, True])
def test_direct_finalizer_rejects_diagnostic_before_files_or_fitting(tmp_path, publish):
source = tmp_path / "original.urdf"
source.write_text('<robot name="untouched"/>', encoding="utf-8")
original = source.read_bytes()
previous = tmp_path / "previous_release"
previous.mkdir()
pointer = tmp_path / "latest_passed"
pointer.symlink_to(previous, target_is_directory=True)
directory = tmp_path / "diagnostic_output"
rows = [{"kind": "session_start", "diagnostic_capture": DIAGNOSTIC}]
with pytest.raises(ValueError, match="^diagnostic_capture_not_for_publication_or_resume$"):
finalize_profile_session(profile=None, session_dir=directory,
serial_number="TEST", source_urdf=source, protected_inputs={},
records=rows, publish=publish,
standard_loader=lambda _: pytest.fail("diagnostic data must never reach a loader"))
assert not directory.exists()
assert source.read_bytes() == original
assert pointer.resolve() == previous
def _checkpoint(directory, *, diagnostic=False):
directory.mkdir()
header = {
"kind": "session_start", "profile_id": "O6/right/layout/v1",
"serial_number": "TEST", "acquisition_policy_version": ACQUISITION_POLICY_VERSION,
"capture_schedule_version": CAPTURE_SCHEDULE_VERSION,
"pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION,
"protected_hashes": {"source": "a" * 64},
}
if diagnostic:
header["diagnostic_capture"] = DIAGNOSTIC
records = [header, {"kind": "fixed_base_reference_locked"},
{"kind": "scan_unit_complete", "passed": True}]
path = directory / "raw_samples.jsonl"
path.write_text("\n".join(json.dumps(row) for row in records), encoding="utf-8")
return path
@pytest.mark.parametrize("production_available", [False, True])
def test_diagnostic_checkpoint_never_shadows_or_supplies_production_resume(tmp_path, production_available):
production = tmp_path / "01"
if production_available:
_checkpoint(production)
diagnostic = _checkpoint(tmp_path / "02", diagnostic=True)
before = diagnostic.read_bytes()
# Discovery must reject the raw header even if diagnostic_capture.json is
# absent and the recorded direction claims to have passed all quality gates.
found = discover_resume_candidate(tmp_path, profile_id="O6/right/layout/v1",
serial_number="TEST", protected_hashes={"source": "a" * 64})
assert found == (production if production_available else None)
assert diagnostic.read_bytes() == before
def test_resume_discovery_checks_diagnostic_evidence_beyond_header(tmp_path):
path = _checkpoint(tmp_path / "01")
path.write_text(path.read_text() + '\n' + json.dumps({"kind": "diagnostic_observation_frame"}))
assert discover_resume_candidate(tmp_path, profile_id="O6/right/layout/v1",
serial_number="TEST", protected_hashes={"source": "a" * 64}) is None
@@ -1,159 +0,0 @@
"""Raw image coverage can finish a diagnosis but cannot certify joint poses."""
from copy import deepcopy
import pytest
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.diagnostic_quality import evaluate_diagnostic_unit
from linkerhand_calibration.runtime.engine import CalibrationEngine
from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit
from linkerhand_calibration.core.domain.motion_path import task_segment
def raw_frames(profile, unit):
task = next(task for task in profile.motion.tasks if task.key == unit.task_key)
rows = []
for index in range(129):
feedback = list(profile.command.baseline_values)
feedback[task.command_index] = unit.start + (unit.end - unit.start) * index / 128
for view in profile.vision.views:
stamp = 1_000_000_000 + index * 40_000_000
rows.append({"kind": "diagnostic_observation_frame", "view": view.name,
"task_name": unit.task_key, "cycle": unit.cycle, "direction": unit.direction,
"attempt": 1, "sample_phase": "sweep", "image_stamp_ns": stamp,
"feedback_vector": feedback, "command_vector": list(feedback),
"command_unit": profile.command.unit, "is_accepted_joint_sample": False,
"tags": {tag.role: {"tag_role": tag.role, "tag_id": tag.tag_id,
"corners_xy": [[10, 10], [50, 10], [50, 50], [10, 50]],
"selected_pose": None,
"candidate_diagnostics": {"observation_stamp_ns": stamp,
"branch_status": "frozen_ambiguous"}}
for tag in view.tags if not tag.fixed_reference}})
return rows
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19", "o30_right_18"])
def test_raw_corners_cover_motion_without_becoming_measured_joint_angles(layout):
profile = load_bundled_hand_profile(layout)
unit = CalibrationEngine(profile).scan_units()[0]
rows = raw_frames(profile, unit)
before = deepcopy(rows)
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert result.observations.passed, result.observations.failures
assert not result.accepted_poses.passed
assert result.accepted_pose_report()["accuracy_validated"] is False
assert not evaluate_capture_unit(profile, unit, 1, rows,
first_cycle_spans={}, include_steady=False).passed
assert rows == before
assert all("joint" not in row and "observation_joint" not in row for row in rows)
@pytest.mark.parametrize("fault", ["missing", "parent_missing", "stale_image", "tag_id",
"tag_role", "corners", "feedback", "command", "unit", "view", "task", "cycle",
"direction", "attempt", "outside_range"])
def test_raw_coverage_still_requires_current_correctly_bound_moving_tags_and_feedback(fault):
profile = load_bundled_hand_profile("o6_right_8")
unit = next(unit for unit in CalibrationEngine(profile).scan_units()
if unit.task_key == "thumb_pitch_ip_front")
rows = raw_frames(profile, unit)
for row in rows:
if row["view"] != "front":
continue
item = row["tags"]["thumb_ip"]
if fault == "missing":
del row["tags"]["thumb_ip"]
elif fault == "parent_missing":
del row["tags"]["thumb_pitch"]
elif fault == "stale_image":
item["candidate_diagnostics"]["observation_stamp_ns"] -= 1
elif fault in {"tag_id", "tag_role", "corners"}:
item[{"corners": "corners_xy"}.get(fault, fault)] = {
"tag_id": 1, "tag_role": "thumb_pitch", "corners": None}[fault]
elif fault == "outside_range":
row["feedback_vector"][0] = -1
else:
key, value = {"feedback": ("feedback_vector", None), "command": ("command_vector", []),
"unit": ("command_unit", "rad"), "view": ("view", "side"),
"task": ("task_name", "thumb_yaw_top"), "cycle": ("cycle", 1),
"direction": ("direction", "increasing"), "attempt": ("attempt", 2)}[fault]
row[key] = value
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert not result.observations.passed
assert not result.accepted_poses.passed
def test_visible_tags_and_full_endpoint_span_cannot_hide_a_synchronization_gap():
profile = load_bundled_hand_profile("o6_right_8")
unit = CalibrationEngine(profile).scan_units()[0]
rows = raw_frames(profile, unit)
for row in rows:
if row["view"] == "top" and 80 < row["feedback_vector"][1] < 130:
row["feedback_vector"] = None
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert not result.observations.passed
assert any("maximum_gap=" in reason for reason in result.observations.failures)
metrics = next(iter(result.observations.metrics.values()))
assert metrics["feedback_span"] == 1.
assert metrics["allowed_maximum_bin_gap"] == 16
timing = next(iter(result.observation_timing.values()))
assert timing["raw_frames"] == timing["visible_frames"] == 129
assert timing["missing_feedback_frames"] > 0
assert timing["maximum_synchronized_gap_seconds"] > timing["maximum_image_gap_seconds"]
def test_command_release_diagnostic_does_not_substitute_encoder_for_command():
profile = load_bundled_hand_profile('o30_right_18')
unit = next(u for u in CalibrationEngine(profile).scan_units()
if u.task_key == 'pinky_dip_side')
rows = raw_frames(profile, unit)
channel = profile.command.command_index_by_joint['pinky_dip']
for row in rows:
row['command_vector'][channel] = 127.
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert not result.observations.passed
assert any('command_span' in reason for reason in result.observations.failures)
assert not result.accepted_poses.passed
@pytest.mark.parametrize("phase", ["steady", "joint_zero"])
def test_non_sweep_diagnostic_images_cannot_fill_missing_sweep_coverage(phase):
profile = load_bundled_hand_profile("o6_right_8")
unit = CalibrationEngine(profile).scan_units()[0]
rows = [dict(row, sample_phase=phase) for row in raw_frames(profile, unit)]
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert not result.observations.passed and not result.accepted_poses.passed
assert all(value["raw_frames"] == 0 for value in result.observation_timing.values())
@pytest.mark.parametrize("segment_key", ["linked_increasing", "linked_decreasing",
"middle_lower_decreasing", "middle_lower_increasing"])
def test_grouped_diagnostic_uses_each_channel_and_segment_identity(segment_key):
profile = load_bundled_hand_profile("o30_right_18")
unit = next(u for u in CalibrationEngine(profile).scan_units()
if u.task_key == "fingers_mcp_roll_front" and u.segment_key == segment_key)
task = next(t for t in profile.motion.tasks if t.key == unit.task_key)
segment = task_segment(task, segment_key)
rows = raw_frames(profile, unit)
for row in rows:
progress = (row["image_stamp_ns"] - 1_000_000_000) / (128 * 40_000_000)
commands = list(profile.command.baseline_values)
for channel, start in segment.start_commands:
end = dict(segment.end_commands)[channel]
commands[channel] = start + progress * (end - start)
row.update(segment_key=segment_key, command_vector=commands,
feedback_vector=list(commands))
result = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert result.observations.passed, result.observations.failures
assert not result.accepted_poses.passed
assert len(result.observations.metrics) == len(segment.joints)
wrong_segment = [dict(row, segment_key="another_segment") for row in rows]
assert not evaluate_diagnostic_unit(profile, unit, 1, wrong_segment).observations.passed
# Another finger's full travel must not cover this finger's stopped encoder.
channel = profile.command.command_index_by_joint["middle_mcp_roll"]
for row in rows:
row["feedback_vector"][channel] = 80.
stopped = evaluate_diagnostic_unit(profile, unit, 1, rows)
assert not stopped.observations.passed
assert any("middle_mcp_roll" in reason and "below_motion_resolution" in reason
for reason in stopped.observations.failures)
@@ -1,218 +0,0 @@
"""Raw diagnostics survive unobservable geometry without manufacturing zeros."""
from copy import deepcopy
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
import json
from threading import Event
import pytest
from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionBranchResolution
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime import coordinator as coordinator_module
from linkerhand_calibration.runtime.diagnostic_capture import reject_diagnostic_capture
from linkerhand_calibration.runtime.diagnostic_reference import build_diagnostic_zero_attempt
from linkerhand_calibration.runtime.execution import SessionExecution
from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
from test_diagnostic_coordinator import (start_diagnostic, run_until, TERMINAL_PHASES,
assert_terminal_is_idle, projected_frame)
FRONT_JOINTS = {"rh_thumb_cmc_pitch", "rh_thumb_ip"}
def unresolved_front(monkeypatch):
solve = coordinator_module.solve_initialization
monkeypatch.setattr(coordinator_module, "solve_initialization", lambda request:
MotionBranchResolution(False, "motion_geometry_unresolved")
if request.view == "front" else solve(request))
@pytest.mark.parametrize("fault", ["geometry_only", "missing_sweep_tag", "missing_zero_tag", "unstable_feedback"])
def test_unresolved_reference_diagnostic_obeys_raw_coverage_and_sdk_stability(tmp_path, monkeypatch, fault):
unresolved_front(monkeypatch)
host, clock = start_diagnostic(tmp_path, monkeypatch)
receive = host.receive_detections
def hide(frame):
phase = None if host._motion is None else host._motion.phase
if frame.view == "front" and ((fault == "missing_sweep_tag" and phase == "sweep")
or (fault == "missing_zero_tag" and phase == "joint_zero")):
frame = replace(frame, detections=tuple(tag for tag in frame.detections if tag.tag_id != 2))
return receive(frame)
monkeypatch.setattr(host, "receive_detections", hide)
if fault == "unstable_feedback":
steady_ready = MotionExecution.steady_ready
monkeypatch.setattr(MotionExecution, "steady_ready", lambda segment, now:
False if segment.command.phase == "joint_zero" and "rh_thumb_ip" in segment.command.zero_joints
else steady_ready(segment, now))
try:
run_until(host, clock, lambda: host.execution.session.phase in TERMINAL_PHASES)
assert set(host.execution.zero_references) == {"rh_thumb_cmc_yaw"}
assert not any(row.get("kind") in {"joint_sample", "joint_zero_sample", "secondary_joint_sample", "pnp_candidate_frame"}
and row.get("view") == "front" for row in host.raw_records)
assert not host.finalization.started and not host.final_json and not host.final_urdf
attempts = [r for r in host.raw_records if r.get("kind") == "diagnostic_joint_zero_attempt"]
if fault in {"missing_zero_tag", "unstable_feedback"}:
assert host.execution.session.phase == Phase.PAUSED
assert not attempts and not host.execution.diagnostic_zero_attempts
expected = ("diagnostic_joint_zero_observations_missing" if fault == "missing_zero_tag"
else "diagnostic_joint_zero_feedback_unstable")
assert expected in host.reason
else:
assert len(attempts) == 1
assert set(host.execution.diagnostic_zero_attempts) == FRONT_JOINTS
checks = [r for r in host.raw_records if r.get("kind") == "scan_unit_complete" and r["task_name"] == "thumb_pitch_ip_front"]
if fault == "missing_sweep_tag":
assert host.execution.session.phase == Phase.PAUSED
assert [r["attempt"] for r in checks] == [1, 2]
assert all(not r["passed"] for r in checks)
else:
assert host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE
assert len(host.execution.completed_units) == 4
assert len(checks) == 2 and all(r["passed"] for r in checks)
assert all(not r["accepted_pose_quality"]["passed"] for r in checks)
raw = [r for r in host.raw_records if r.get("kind") == "diagnostic_observation_frame"
and r.get("view") == "front" and r.get("sample_phase") == "sweep"]
# Independent tracking can recover, but without a zero its
# selected pose is still only a diagnostic observation.
assert any(item["selected_pose"] is not None for row in raw for role, item in row["tags"].items()
if role in {"thumb_pitch", "thumb_ip"})
report = json.loads((host.parameters.session_dir / "diagnostic_capture.json").read_text())
assert not report["accuracy_validated"] and not report["publication_allowed"]
with pytest.raises(ValueError, match="diagnostic_capture_not_for_publication_or_resume"):
reject_diagnostic_capture(host.raw_records)
assert_terminal_is_idle(host, clock, monkeypatch)
finally:
host.close()
def raw_reference():
profile = load_bundled_hand_profile("o6_right_8")
name = "rh_thumb_cmc_yaw"
spec = profile.motion.joint_zero_references[name]
motion = MotionCommand("joint_zero", spec.command, 1., task_key=spec.task_key, zero_joints=(name,))
rows = []
for stamp in range(1, profile.acquisition.fixed_reference_minimum_frames+1):
rows.append({"kind": "diagnostic_observation_frame", "sample_phase": "joint_zero",
"task_name": spec.task_key, "view": "top", "session_epoch": 2, "motion_version": 3,
"zero_joints": [name], "image_stamp_ns": stamp, "command_unit": profile.command.unit,
"command_vector": list(spec.command), "feedback_vector": list(spec.command),
"tags": {"thumb_yaw": {"tag_role": "thumb_yaw", "tag_id": 7,
"corners_xy": [[0., 0.], [20., 0.], [20., 20.], [0., 20.]],
"candidate_diagnostics": {"observation_stamp_ns": stamp}}}})
return profile, motion, rows
@pytest.mark.parametrize("invalid", ["missing", "duplicate", "session", "motion", "stamp", "binding", "baseline", "feedback"])
def test_raw_reference_attempt_requires_current_distinct_complete_images(invalid):
profile, motion, rows = raw_reference()
rows = deepcopy(rows)
if invalid == "missing":
rows.pop()
elif invalid == "duplicate":
rows[-1] = deepcopy(rows[-2])
elif invalid in {"session", "motion"}:
rows[-1]["session_epoch" if invalid == "session" else "motion_version"] += 1
elif invalid == "stamp":
rows[-1]["tags"]["thumb_yaw"]["candidate_diagnostics"]["observation_stamp_ns"] -= 1
elif invalid == "binding":
rows[-1]["tags"]["thumb_yaw"]["tag_id"] = 99
elif invalid == "baseline":
rows[-1]["command_vector"][1] -= 1
else:
rows[-1]["feedback_vector"] = None
with pytest.raises(ValueError, match="diagnostic_joint_zero_observations_missing"):
build_diagnostic_zero_attempt(profile, motion, rows, reason="unresolved",
session_epoch=2, motion_version=3)
def test_diagnostic_attempt_is_not_a_formal_reference_or_reusable_sample():
profile, motion, rows = raw_reference()
attempt = build_diagnostic_zero_attempt(profile, motion, rows, reason="unresolved",
session_epoch=2, motion_version=3)
execution = SessionExecution(profile)
execution._effects = [motion]
with pytest.raises(ValueError, match="outside_current_motion"):
execution.record_diagnostic_zero_attempt(attempt)
execution.diagnostic_zero_attempts[motion.zero_joints[0]] = attempt
with pytest.raises(RuntimeError, match="without frozen evidence"):
execution.motion_complete()
with pytest.raises(ValueError, match="diagnostic_capture_not_for_publication_or_resume"):
reject_diagnostic_capture([attempt.as_record()])
assert not execution.zero_references
def test_raw_reference_binding_follows_virtual_task_and_view_names():
profile, motion, rows = raw_reference()
task_name, view_name = "virtual_arrival", "inspection_camera"
profile = replace(profile,
vision=replace(profile.vision, views=tuple(replace(view, name=view_name)
if view.name == "top" else view for view in profile.vision.views)),
measurement=replace(profile.measurement, measurements={name:
replace(spec, view=view_name) if spec.view == "top" else spec
for name, spec in profile.measurement.measurements.items()}),
motion=replace(profile.motion, tasks=tuple(replace(task, key=task_name, view=view_name)
if task.key == motion.task_key else task for task in reversed(profile.motion.tasks)),
joint_zero_references={name: replace(spec, task_key=task_name)
if spec.task_key == motion.task_key else spec
for name, spec in profile.motion.joint_zero_references.items()}))
motion = replace(motion, task_key=task_name)
for row in rows:
row.update(task_name=task_name, view=view_name)
attempt = build_diagnostic_zero_attempt(profile, motion, rows, reason="unresolved",
session_epoch=2, motion_version=3)
assert attempt.task_name == task_name
assert attempt.joint_views == (("rh_thumb_cmc_yaw", view_name),)
assert {view for view, _ in attempt.source_images} == {view_name}
def test_diagnostic_reference_cannot_relabel_an_arbitrary_target_as_declared_zero():
profile, motion, rows = raw_reference()
target = list(motion.target)
target[1] -= 10
motion = replace(motion, target=tuple(target))
for row in rows:
row["command_vector"] = target
row["feedback_vector"] = target
with pytest.raises(ValueError, match="declared_pose_mismatch"):
build_diagnostic_zero_attempt(profile, motion, rows, reason="unresolved",
session_epoch=2, motion_version=3)
@pytest.mark.parametrize("action", ["pause", "abort"])
def test_late_unresolved_zero_image_cannot_create_diagnostic_attempt(tmp_path, monkeypatch, action):
unresolved_front(monkeypatch)
host, clock = start_diagnostic(tmp_path, monkeypatch)
entered, release = Event(), Event()
try:
run_until(host, clock, lambda: host._motion is not None
and "rh_thumb_ip" in host._motion.zero_joints and host._steady_capture_after_ns is not None)
consume = host.capture.consume
def held(frame, motion, **kwargs):
result = consume(frame, motion, **kwargs)
entered.set()
assert release.wait(5)
return result
monkeypatch.setattr(host.capture, "consume", held)
clock.now += .04
host.receive_feedback(host.profile.command.names, host.last_command, host.ports.clock_ns())
with ThreadPoolExecutor(1) as pool:
future = pool.submit(host.receive_detections, projected_frame(host, "front", host.last_command))
assert entered.wait(2)
if action == "abort":
assert host.abort().success
else:
host._pause("test_pause_during_raw_zero")
before = host.raw_path.read_bytes()
release.set()
future.result(timeout=5)
assert host.raw_path.read_bytes() == before
assert not host.execution.diagnostic_zero_attempts
assert set(host.execution.zero_references) == {"rh_thumb_cmc_yaw"}
assert not (host.parameters.session_dir / "diagnostic_capture.json").exists()
assert_terminal_is_idle(host, clock, monkeypatch)
finally:
release.set()
host.close()
@@ -1,230 +0,0 @@
"""Diagnostic summaries preserve evidence without claiming calibration accuracy."""
from copy import deepcopy
from dataclasses import replace
import json
from types import SimpleNamespace
import pytest
from scipy.spatial.transform import Rotation
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.diagnostic_capture import resolve_diagnostic_capture
from linkerhand_calibration.runtime.diagnostic_reference import DiagnosticZeroAttempt
from linkerhand_calibration.runtime.diagnostic_report import build_diagnostic_report, fixed_reference_summary, pose_step_summary
from linkerhand_calibration.runtime.diagnostic_report import task_observation_summary
from linkerhand_calibration.runtime.engine import CalibrationEngine
from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase
from linkerhand_calibration.runtime.status import normalize_status, render_status_zh
def pose_row(stamp, angle, *, direction="decreasing", cycle=0, attempt=1, cached=False):
return {"sample_phase": "sweep", "view": "front", "task_name": "thumb_pitch_ip_front",
"cycle": cycle, "direction": direction, "attempt": attempt, "image_stamp_ns": stamp,
"pnp_observation_evidence": {"child": {"tag_role": "thumb_pitch", "tag_id": 1,
"pose_source": "cached_fixed_reference" if cached else "current_image",
"selected_pose": {"quaternion_xyzw": Rotation.from_euler("z", angle, degrees=True).as_quat().tolist()}}}}
def test_pose_step_report_deduplicates_tag_images_and_keeps_actual_jump():
rows = [pose_row(1_000_000_000, 0), pose_row(1_033_000_000, 92)]
original = deepcopy(rows)
report = pose_step_summary(rows + deepcopy(rows))
tag = report["front:thumb_pitch"]
assert tag["accepted_image_count"] == 2
assert tag["adjacent_pair_count"] == 1
assert tag["maximum_rotation_step_deg"] == pytest.approx(92)
assert tag["largest_steps"][0]["interval_ms"] == pytest.approx(33)
assert tag["largest_steps"][0]["to_image"] == "front:1033000000"
assert rows == original
@pytest.mark.parametrize("change", [
{"direction": "increasing"}, {"cycle": 1}, {"attempt": 2}, {"cached": True},
])
def test_different_units_or_cached_poses_cannot_create_false_adjacent_jumps(change):
report = pose_step_summary([pose_row(1_000_000_000, 0), pose_row(1_033_000_000, 92, **change)])
assert report["front:thumb_pitch"]["maximum_rotation_step_deg"] is None
assert not report["front:thumb_pitch"]["largest_steps"]
def test_long_gap_is_not_reported_as_a_single_frame_jump():
report = pose_step_summary([pose_row(1_000_000_000, 0), pose_row(6_000_000_000, 92)])
assert report["front:thumb_pitch"]["maximum_rotation_step_deg"] is None
def test_fixed_reference_reports_image_error_without_claiming_measured_zero_motion():
rows = [pose_row(1_000_000_000, 0), pose_row(1_033_000_000, 0)]
for index, row in enumerate(rows):
item = row['pnp_observation_evidence']['child']
item.update(tag_role='front_base', tag_id=0, pose_source='verified_fixed_reference',
candidate_diagnostics={'observation_stamp_ns': row['image_stamp_ns'],
'pose_constraint': 'fixed_reference', 'fixed_reference_verified': True,
'fixed_reference_reprojection_error_px': .2 + index*.4})
rejected = deepcopy(rows[-1]['pnp_observation_evidence']['child'])
rejected.update(selected_pose=None, pose_source='current_image')
rejected['candidate_diagnostics'].update(observation_stamp_ns=1_066_000_000,
fixed_reference_verified=False, fixed_reference_reprojection_error_px=2.0)
events = [{'kind': 'pnp_branch_event', 'view': 'front', 'image_stamp_ns': 1_066_000_000,
'pnp_observation_evidence': rejected}]
report = fixed_reference_summary(rows + deepcopy(rows) + events)
assert report['front:front_base']['image_count'] == 3
assert report['front:front_base']['accepted_image_count'] == 2
assert report['front:front_base']['maximum_reprojection_error_px'] == 2.0
assert pose_step_summary(rows + events) == {}
def test_preset_keeps_profile_immutable_and_rejects_wrong_joint_binding():
profile = load_bundled_hand_profile("o6_right_8")
before = deepcopy(profile)
plan = resolve_diagnostic_capture(profile, "o6_thumb")
assert plan.task_keys == ("thumb_yaw_top", "thumb_pitch_ip_front")
assert plan.cycles == (0,)
assert profile == before
wrong = replace(profile, motion=replace(profile.motion, tasks=(
replace(profile.motion.tasks[0], command_index=5), *profile.motion.tasks[1:])))
with pytest.raises(ValueError, match="diagnostic_thumb_task_binding_changed"):
resolve_diagnostic_capture(wrong, "o6_thumb")
def test_diagnostic_terminal_and_cycle_count_are_distinct_from_calibration_pass():
profile = load_bundled_hand_profile("o6_right_8")
plan = resolve_diagnostic_capture(profile, "o6_thumb")
session = CalibrationSession(profile, diagnostic_capture=plan)
session.phase = Phase.SWEEP
status = session.status().as_dict()
status["diagnostic_capture"] = plan.as_dict()
rendered = render_status_zh(normalize_status(status, profile=profile))
assert "第 1/1 轮" in rendered
assert "诊断扫描" in rendered
assert "不发布标定 JSON/URDF" in rendered
session.phase = Phase.DIAGNOSTIC_COMPLETE
status = normalize_status(session.status().as_dict(), profile=profile)
assert status["state"] == "DIAGNOSTIC_COMPLETE"
assert status["overall_progress_01"] == 1
assert "未进行精度验收" in render_status_zh(status)
def test_diagnostic_cannot_authorize_commit_even_if_publication_phase_is_requested(tmp_path):
from runtime_host_fixture import coordinator_fixture
host, _ = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb")
try:
host.execution.session.phase = Phase.PUBLISH
with pytest.raises(RuntimeError, match="diagnostic_capture_not_for_publication"):
host._authorize_commit()
assert not host.finalization.started
assert not list(tmp_path.rglob("*.urdf"))
finally:
host.close()
def report_inputs(tmp_path):
"""Only report binding is under test; no pose/accuracy evidence is synthesized."""
profile = load_bundled_hand_profile("o6_right_8")
plan = resolve_diagnostic_capture(profile, "o6_thumb")
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
names = ("rh_thumb_cmc_pitch", "rh_thumb_ip")
attempt = DiagnosticZeroAttempt(names, "thumb_pitch_ip_front", "motion_geometry_unresolved",
1, 11, profile.command.baseline_values, profile.command.baseline_values,
(("front", 1000),), tuple((name, "front") for name in names))
zero = {"kind": "joint_zero_reference", "joint": "rh_thumb_cmc_yaw"}
records = [zero, json.loads(json.dumps(attempt.as_record()))]
raw_path = tmp_path / "raw_samples.jsonl"
raw_path.write_text("".join(json.dumps(row)+"\n" for row in records))
return profile, plan, dict(raw_path=raw_path, records=records,
references={"rh_thumb_cmc_yaw": SimpleNamespace(as_record=lambda: zero)},
completed_units={(unit.task_key, unit.cycle, unit.direction) for unit in units},
expected_units=units, protected_inputs={}, command=profile.command.baseline_values,
feedback=profile.command.baseline_values,
diagnostic_zero_attempts={name: attempt for name in names})
def test_segmented_task_report_keeps_every_declared_segment(tmp_path):
profile = load_bundled_hand_profile('o30_right_18')
plan = resolve_diagnostic_capture(profile, 'o30_finger_roll_views')
units = CalibrationEngine(profile, diagnostic_capture=plan).scan_units()
assert len(units) == 4
names = next(task.joints for task in profile.motion.tasks if task.key == plan.task_keys[0])
attempt = DiagnosticZeroAttempt(names, plan.task_keys[0], 'unresolved', 1, 11,
profile.command.baseline_values, profile.command.baseline_values,
(('front', 1000),), tuple((name, 'front') for name in names))
raw_path = tmp_path/'raw.jsonl'
raw_path.write_text(json.dumps(attempt.as_record())+'\n')
kwargs = dict(raw_path=raw_path, records=[attempt.as_record()], references={},
diagnostic_zero_attempts={name: attempt for name in names},
completed_units={unit.identity for unit in units}, expected_units=units,
protected_inputs={}, command=profile.command.baseline_values, feedback=profile.command.baseline_values)
report = build_diagnostic_report(profile, plan, **kwargs)
assert len(report['completed_units']) == 4
assert report['publication_allowed'] is False
kwargs['completed_units'] = {(unit.task_key, unit.cycle, unit.direction) for unit in units}
with pytest.raises(ValueError, match='incomplete_units'):
build_diagnostic_report(profile, plan, **kwargs)
def test_task_visibility_does_not_generalize_pinky_to_other_fingers():
profile = load_bundled_hand_profile('o30_right_18')
plan = resolve_diagnostic_capture(profile, 'o30_all_tasks')
row = dict(kind='image_observation_frame', task_name='fingers_mcp_roll_front',
view='side', image_stamp_ns=1, sample_phase='sweep', command_vector=[0]*20,
feedback_vector=[0]*20, tags={'side_base': {}, 'pinky_pip': {}, 'pinky_dip': {}})
report = task_observation_summary(profile, plan, [row, deepcopy(row)])
assert len(report) == 17
assert report['pinky_dip_side']['image_observation_count'] == 0
task = report['fingers_mcp_roll_front']
assert task['image_observation_count'] == 1
phase = task['views']['side']['sweep']
assert phase['pair_frames']['pinky_pip/pinky_dip'] == 1
for finger in ('ring', 'middle', 'index'):
assert phase['pair_frames'][f'{finger}_pip/{finger}_dip'] == 0
assert task['accuracy_validated'] is False
changed = {**row, 'tags': {}}
with pytest.raises(ValueError, match='duplicate_image_changed'):
task_observation_summary(profile, plan, [row, changed])
def test_raw_only_report_has_missing_zeros_without_fabricating_joint_references(tmp_path):
profile, plan, kwargs = report_inputs(tmp_path)
report = build_diagnostic_report(profile, plan, **kwargs)
assert report["missing_joint_zero_references"] == ["rh_thumb_cmc_pitch", "rh_thumb_ip"]
assert set(report["joint_zero_references"]) == {"rh_thumb_cmc_yaw"}
assert report["joint_zero_status"]["rh_thumb_ip"]["status"] == "unresolved"
assert report["joint_zero_status"]["rh_thumb_ip"]["raw_only"] is True
assert report["joint_zero_status"]["rh_thumb_cmc_yaw"]["status"] == "confirmed"
assert report["joint_sample_counts"]["rh_thumb_ip"] == {}
assert report["publication_allowed"] is report["accuracy_validated"] is False
def test_report_separates_hold_images_and_samples_from_sweep_counts(tmp_path):
profile, plan, kwargs = report_inputs(tmp_path)
for phase in ("steady", "sweep"):
identity = {"sample_phase": phase, "task_name": "thumb_yaw_top", "view": "top",
"direction": "decreasing", "attempt": 1}
kwargs["records"].append({"kind": "joint_sample", "joint": "rh_thumb_cmc_yaw", **identity})
kwargs["records"].append({"kind": "diagnostic_observation_frame", **identity})
kwargs["raw_path"].write_text("".join(json.dumps(row)+"\n" for row in kwargs["records"]))
report = build_diagnostic_report(profile, plan, **kwargs)
key = "top:decreasing:attempt=1"
assert report["joint_sample_counts"]["rh_thumb_cmc_yaw"][key] == 2 # Historical total retained.
assert report["joint_sample_counts_by_phase"]["rh_thumb_cmc_yaw"] == {
"steady": {key: 1}, "sweep": {key: 1}}
assert report["raw_observation_counts_by_phase"] == {"steady": {key: 1}, "sweep": {key: 1}}
@pytest.mark.parametrize("fault", ["unrecorded", "overlap", "missing", "binding", "accepted_sample"])
def test_report_rejects_missing_or_conflicting_unresolved_zero_evidence(tmp_path, fault):
profile, plan, kwargs = report_inputs(tmp_path)
attempts = kwargs["diagnostic_zero_attempts"]
if fault == "unrecorded":
kwargs["records"] = kwargs["records"][:1]
elif fault == "overlap":
kwargs["references"]["rh_thumb_ip"] = kwargs["references"]["rh_thumb_cmc_yaw"]
elif fault == "missing":
del attempts["rh_thumb_ip"]
elif fault == "binding":
attempts["rh_thumb_ip"] = replace(attempts["rh_thumb_ip"], task_name="thumb_yaw_top")
kwargs["records"].append(attempts["rh_thumb_ip"].as_record())
else:
kwargs["records"].append({"kind": "joint_sample", "joint": "rh_thumb_ip"})
with pytest.raises(ValueError, match="diagnostic_"):
build_diagnostic_report(profile, plan, **kwargs)
@@ -1,203 +0,0 @@
"""Short diagnostic captures reuse the production motion and safety schedule."""
from dataclasses import replace
import pytest
from linkerhand_calibration.core.domain.reference import build_joint_zero_reference
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.runtime.diagnostic_capture import DiagnosticCapturePlan
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine
from linkerhand_calibration.runtime.execution import SessionExecution
from linkerhand_calibration.runtime.resume import ResumeDecision
from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit, observation_streams
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase, CalibrationSession
from test_joint_zero_runtime import reference_rows
THUMB_TASKS = ("thumb_yaw_top", "thumb_pitch_ip_front")
THUMB_JOINTS = {"rh_thumb_cmc_yaw", "rh_thumb_cmc_pitch", "rh_thumb_ip"}
def thumb_capture():
# Reverse the selection to prove that execution follows the Profile order.
return DiagnosticCapturePlan(mode="o6_thumb", task_keys=tuple(reversed(THUMB_TASKS)))
def sweep_rows(profile, unit, *, attempt=1, first_stamp=1):
task = next(task for task in profile.motion.tasks if task.key == unit.task_key)
rows = []
for field, joint, spec in observation_streams(profile, task):
for index in range(129):
rows.append({
field: joint, "view": spec.view, "task_name": task.key,
"cycle": unit.cycle, "direction": unit.direction, "attempt": attempt,
"image_stamp_ns": first_stamp + len(rows), "sample_phase": "sweep",
"feedback_u8": unit.start + (unit.end - unit.start) * index / 128,
})
return rows
@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"])
def test_formal_schedule_keeps_all_tasks_four_cycles_and_both_directions(layout):
profile = load_bundled_hand_profile(layout)
all_units = CalibrationEngine(profile).scan_units()
units = [unit for unit in all_units if unit.sample_phase == "sweep"]
steady = [unit for unit in all_units if unit.sample_phase == "steady"]
assert len(steady) == 4 * len(profile.motion.tasks)
assert {unit.cycle for unit in steady} == {4, 5}
expected = []
for task in profile.motion.tasks:
forward = "increasing" if task.end_value > task.start_value else "decreasing"
reverse = "decreasing" if forward == "increasing" else "increasing"
expected.extend((task.key, cycle, direction, start, end)
for cycle in (0, 1, 2, 3)
for direction, start, end in (
(forward, task.start_value, task.end_value),
(reverse, task.end_value, task.start_value),
))
assert [(unit.task_key, unit.cycle, unit.direction, unit.start, unit.end)
for unit in units] == expected
assert CalibrationSession(profile).status().task.cycle_count == 4
def test_diagnostic_selection_preserves_profile_order_range_and_speed():
profile = load_bundled_hand_profile("o6_right_8")
diagnostic = CalibrationEngine(profile, diagnostic_capture=thumb_capture()).scan_units()
formal = CalibrationEngine(profile).scan_units()
assert diagnostic == tuple(unit for unit in formal
if unit.task_key in THUMB_TASKS and unit.cycle == 0)
assert len(diagnostic) == 4
assert [unit.task_key for unit in diagnostic] == [THUMB_TASKS[0]] * 2 + [THUMB_TASKS[1]] * 2
@pytest.mark.parametrize("failed_attempts", [0, 1, 2])
def test_thumb_capture_freezes_only_its_zeros_scans_and_returns_without_fitting(failed_attempts):
profile = load_bundled_hand_profile("o6_right_8")
driver = SessionExecution(profile, diagnostic_capture=thumb_capture())
driver.session.device_ready()
driver.session.start()
command = profile.command.baseline_values
rows, trace, references = [], [], {}
forbidden = {Phase.FIT, Phase.HOLDOUT_VALIDATE, Phase.BUILD_ARTIFACTS,
Phase.VALIDATE_URDF, Phase.PUBLISH, Phase.COMPLETE}
for _ in range(100):
phase = driver.session.phase
assert phase not in forbidden
assert driver.session.status().task.cycle_count == 1
if phase in {Phase.DIAGNOSTIC_COMPLETE, Phase.PAUSED}:
break
if phase == Phase.REFERENCE_LOCKING:
# Lock all selected tasks' references once, including the front
# view needed by the later task; the unused side is not required.
assert driver.session.status().task.required_tag_ids_by_view == {
"front": (0,), "top": (6,)}
driver.session.reference_locked()
continue
if phase == Phase.EVALUATE:
driver.evaluate(rows)
continue
motion = driver.motion(command)
assert motion is not None, phase
assert motion.phase not in {"steady_prepare", "steady"}
command = motion.target
assert command[2:] == profile.command.baseline_values[2:]
trace.append(motion)
for name in motion.zero_joints:
assert name in THUMB_JOINTS
assert name not in references
reference = build_joint_zero_reference(profile, name, reference_rows(profile, name),
session_epoch=1, motion_version=len(trace))
references[name] = reference
driver.zero_references[name] = reference
if motion.phase == "sweep":
assert motion.task_key in THUMB_TASKS
assert motion.cycle == 0
unit = driver.action.scan_unit
task = next(task for task in profile.motion.tasks if task.key == unit.task_key)
assert set(task.joints) <= references.keys()
if len([item for item in trace if item.phase == "sweep"]) > failed_attempts:
rows.extend(sweep_rows(profile, unit, attempt=motion.attempt,
first_stamp=len(rows) + 1))
driver.motion_complete()
else:
pytest.fail("diagnostic executor failed to reach a terminal state")
sweeps = [motion for motion in trace if motion.phase == "sweep"]
assert trace[0].phase == "baseline"
assert driver.session.status().reference_locked
assert references == driver.zero_references
assert max(motion.attempt for motion in sweeps) <= 2
if failed_attempts:
assert sweeps[0].target == sweeps[1].target
assert sweeps[0].speed == sweeps[1].speed
assert sweeps[1].attempt == 2
if failed_attempts == 2:
assert driver.session.phase == Phase.PAUSED
assert driver.session.status().pause.code == "sweep_quality_failed"
assert len(sweeps) == 2
return
assert set(references) == THUMB_JOINTS
assert len(driver.completed_units) == 4
assert len(sweeps) == 4 + failed_attempts
assert trace[-1].phase == "return"
assert command == profile.command.baseline_values
status = driver.session.status()
assert status.state == status.phase == "DIAGNOSTIC_COMPLETE"
assert status.overall_progress_01 == 1.0
assert driver.motion(command) is None
with pytest.raises(RuntimeError, match="expected FIT"):
driver.session.fit_complete()
with pytest.raises(RuntimeError, match="expected PUBLISH"):
driver.session.published()
def test_diagnostic_sweep_quality_does_not_require_formal_steady_samples():
profile = load_bundled_hand_profile("o6_right_8")
# Legacy interleaved units require both evidence kinds; diagnostics only
# exercise the sweep. Separate production units have their own gates.
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="interleaved"))
unit = CalibrationEngine(profile).scan_units()[0]
rows = sweep_rows(profile, unit)
diagnostic = evaluate_capture_unit(profile, unit, 1, rows,
first_cycle_spans={}, include_steady=False)
formal = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={})
assert diagnostic.passed
assert not formal.passed
assert formal.failures and all(reason.startswith("steady:") for reason in formal.failures)
def test_diagnostic_capture_refuses_resume_reuse_and_restore_before_mutation():
profile = load_bundled_hand_profile("o6_right_8")
driver = SessionExecution(profile, diagnostic_capture=thumb_capture())
driver.session.device_ready()
with pytest.raises(ValueError, match="diagnostic capture cannot resume"):
driver.session.start(resume_requested=True)
assert driver.session.phase == Phase.READY
decision = ResumeDecision(reuse=True, reason="previous", completed_units=(
(THUMB_TASKS[0], 0, "decreasing"),))
for call in (
lambda: driver.session.resume_checked(decision),
lambda: driver.authorize_task_reuse(decision.completed_units),
lambda: driver.restore(decision, []),
):
with pytest.raises(ValueError, match="diagnostic capture cannot"):
call()
assert not driver.completed_units
assert not driver.first_cycle_spans
assert not driver.zero_references
assert driver.session.phase == Phase.READY
def test_formal_session_cannot_reuse_a_diagnostic_journal():
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
profile = load_bundled_hand_profile("o6_right_8")
start = {"profile_id": profile.key.profile_id,
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
"capture_schedule_version": CalibrationEngine(profile).capture_schedule_version,
"pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION}
formal = CalibrationEngine(profile)
assert formal.resume_compatible(start)
assert not formal.resume_compatible({**start, "diagnostic_capture": thumb_capture().as_dict()})
diagnostic = CalibrationEngine(profile, diagnostic_capture=thumb_capture())
assert not diagnostic.resume_compatible(start)
assert not diagnostic.resume_compatible({**start, "diagnostic_capture": thumb_capture().as_dict()})

Some files were not shown because too many files have changed in this diff Show More