diff --git a/config/o30_three_camera_extrinsics_20260923_181333.yaml b/config/o30_three_camera_extrinsics_20260923_181333.yaml new file mode 100644 index 0000000..da7e1e8 --- /dev/null +++ b/config/o30_three_camera_extrinsics_20260923_181333.yaml @@ -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 diff --git a/src/linkerhand_calibration/O30_AUTOMATIC_URDF_FIX.md b/src/linkerhand_calibration/O30_AUTOMATIC_URDF_FIX.md new file mode 100644 index 0000000..0ac85bd --- /dev/null +++ b/src/linkerhand_calibration/O30_AUTOMATIC_URDF_FIX.md @@ -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/` +分别保存来源核验、加载、精度、冻结输入和软件测试记录。 +本次未放宽精度/可观测性门限,未替换仿真默认模型,未发布正式版本。 diff --git a/src/linkerhand_calibration/O30_CURRENT_FLOW_REVIEW.md b/src/linkerhand_calibration/O30_CURRENT_FLOW_REVIEW.md new file mode 100644 index 0000000..1833754 --- /dev/null +++ b/src/linkerhand_calibration/O30_CURRENT_FLOW_REVIEW.md @@ -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`。 diff --git a/src/linkerhand_calibration/O30_OFFLINE_LOGIC_REVIEW.md b/src/linkerhand_calibration/O30_OFFLINE_LOGIC_REVIEW.md new file mode 100644 index 0000000..9fe765c --- /dev/null +++ b/src/linkerhand_calibration/O30_OFFLINE_LOGIC_REVIEW.md @@ -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`。本轮仅复核证据及更正说明,未修改生产代码或模型。 diff --git a/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md b/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md index 09ff7d2..1d976f7 100644 --- a/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md +++ b/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md @@ -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`。 diff --git a/src/linkerhand_calibration/O30_REMAINING_ISSUES.md b/src/linkerhand_calibration/O30_REMAINING_ISSUES.md new file mode 100644 index 0000000..4d8f5e9 --- /dev/null +++ b/src/linkerhand_calibration/O30_REMAINING_ISSUES.md @@ -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`,便于新批次做同项对照。 diff --git a/src/linkerhand_calibration/README.md b/src/linkerhand_calibration/README.md index e876153..890171d 100644 --- a/src/linkerhand_calibration/README.md +++ b/src/linkerhand_calibration/README.md @@ -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 基准指令下的像素/反馈差异及不可见情况 +列为“待离线复核”,不将回位姿态差异直接判成标签重装,不据此暂停采集。 +固定基准、配置身份及运动保护仍须通过。前后安装参考、差异和原补采预算均保留; +未核实差异只允许进入未认证修正草稿,正式发布仍拒绝未复核的安装关系。 +标签必须仍在原配置连杆上;若移到了其他指节,先修正连杆绑定。 diff --git a/src/linkerhand_calibration/TESTING.md b/src/linkerhand_calibration/TESTING.md index a1fafdd..1d8f192 100644 --- a/src/linkerhand_calibration/TESTING.md +++ b/src/linkerhand_calibration/TESTING.md @@ -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`。 diff --git a/src/linkerhand_calibration/config/o30_right_product.yaml b/src/linkerhand_calibration/config/o30_right_product.yaml index b6bd29f..86df987 100644 --- a/src/linkerhand_calibration/config/o30_right_product.yaml +++ b/src/linkerhand_calibration/config/o30_right_product.yaml @@ -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 diff --git a/src/linkerhand_calibration/config/profiles/o30_right_18.yaml b/src/linkerhand_calibration/config/profiles/o30_right_18.yaml index 6370e31..fbb535a 100644 --- a/src/linkerhand_calibration/config/profiles/o30_right_18.yaml +++ b/src/linkerhand_calibration/config/profiles/o30_right_18.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: diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py index b8acd7c..7a31511 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py @@ -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): diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py index 49526bf..2a81cc6 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_support.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_support.py new file mode 100644 index 0000000..8ecaf6d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_support.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_draft.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_draft.py new file mode 100644 index 0000000..8bf3294 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_draft.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_uncertainty.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_uncertainty.py index cab6a8b..8093541 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_uncertainty.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_uncertainty.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_update.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_update.py new file mode 100644 index 0000000..28274e4 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_update.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_validation.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_validation.py index 4367340..ad2b410 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_validation.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_validation.py @@ -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, diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/finger_chain_images.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/finger_chain_images.py index d512e85..18285bc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/finger_chain_images.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/finger_chain_images.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/motion_evidence.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/motion_evidence.py index 7c6a044..d749121 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/motion_evidence.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/motion_evidence.py @@ -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(): diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/urdf_command_images.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/urdf_command_images.py index 69062b9..02687df 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/urdf_command_images.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/urdf_command_images.py @@ -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]), diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/limits.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/limits.py index 1c24ca7..7a056bc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/limits.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/limits.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/linear_drive.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/linear_drive.py new file mode 100644 index 0000000..51cabfc --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/linear_drive.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py index d945ce9..39783a9 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py @@ -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: diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/scope.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/scope.py index 740b999..7027605 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/profiles/scope.py +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/scope.py @@ -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, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o30_ros.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o30_ros.py index 0e43a78..0fa6f26 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o30_ros.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o30_ros.py @@ -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", {}) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/capture_validation.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/capture_validation.py index 65371c4..f99095a 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/capture_validation.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/capture_validation.py @@ -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: diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_draft.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_draft.py new file mode 100644 index 0000000..0f4dd21 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_draft.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py index 9c2059b..1eb2b97 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py index e258799..6bca261 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py @@ -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, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py index 7949c85..cf2bd47 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/measured_v3.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/measured_v3.py index cb982ad..1acb950 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/measured_v3.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/measured_v3.py @@ -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') diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py index bc4a849..fddd212 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py @@ -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: diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py index dd73ba3..54b64c8 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/direction_progress.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/direction_progress.py new file mode 100644 index 0000000..9276235 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/direction_progress.py @@ -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}') diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py index 6369092..c29b52a 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py index 7d94551..c165c9b 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/observation_coverage.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/observation_coverage.py index 4e18b2b..3026e2c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/observation_coverage.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/observation_coverage.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_completion.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_completion.py new file mode 100644 index 0000000..4849d9f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_completion.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_evidence.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_evidence.py index 0ab2c91..428a09b 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_evidence.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_evidence.py @@ -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): diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_quality.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_quality.py index a56cc6e..692e615 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_quality.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_quality.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_remount.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_remount.py new file mode 100644 index 0000000..bb497f7 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_remount.py @@ -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 diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_resume.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_resume.py index 9b6bc1b..ff781bc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_resume.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/raw_resume.py @@ -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'}) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py index fec47cc..9cb4a58 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py @@ -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 秒,已停止。', diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py index 2618553..905e1b5 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py @@ -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, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py index 3a2fc15..d1bb45c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py @@ -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: diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py index 5a16c6c..f8936a6 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py @@ -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"]) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py index 2c14926..a22462a 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py @@ -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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py index 64e0569..5406fed 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py @@ -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', diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py index 5a70a6a..681ee17 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py @@ -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") diff --git a/src/linkerhand_calibration/test/fixtures/o30_recorded_motion_initialization.json.gz b/src/linkerhand_calibration/test/fixtures/o30_recorded_motion_initialization.json.gz new file mode 100644 index 0000000..4e65ba3 Binary files /dev/null and b/src/linkerhand_calibration/test/fixtures/o30_recorded_motion_initialization.json.gz differ diff --git a/src/linkerhand_calibration/test/fixtures/o30_recorded_root_directions.json.gz b/src/linkerhand_calibration/test/fixtures/o30_recorded_root_directions.json.gz new file mode 100644 index 0000000..394626a Binary files /dev/null and b/src/linkerhand_calibration/test/fixtures/o30_recorded_root_directions.json.gz differ diff --git a/src/linkerhand_calibration/test/o30_capture_fixture.py b/src/linkerhand_calibration/test/o30_capture_fixture.py index 4b03891..956065d 100644 --- a/src/linkerhand_calibration/test/o30_capture_fixture.py +++ b/src/linkerhand_calibration/test/o30_capture_fixture.py @@ -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)) diff --git a/src/linkerhand_calibration/test/test_acquisition.py b/src/linkerhand_calibration/test/test_acquisition.py deleted file mode 100644 index fa340d0..0000000 --- a/src/linkerhand_calibration/test/test_acquisition.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_adaptive_command_sampling.py b/src/linkerhand_calibration/test/test_adaptive_command_sampling.py deleted file mode 100644 index dc8194d..0000000 --- a/src/linkerhand_calibration/test/test_adaptive_command_sampling.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_adaptive_training.py b/src/linkerhand_calibration/test/test_adaptive_training.py deleted file mode 100644 index 97bc697..0000000 --- a/src/linkerhand_calibration/test/test_adaptive_training.py +++ /dev/null @@ -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()) diff --git a/src/linkerhand_calibration/test/test_affine_mimic_export.py b/src/linkerhand_calibration/test/test_affine_mimic_export.py deleted file mode 100644 index 994b3c9..0000000 --- a/src/linkerhand_calibration/test/test_affine_mimic_export.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_alignment_view.py b/src/linkerhand_calibration/test/test_alignment_view.py deleted file mode 100644 index 0fa8e82..0000000 --- a/src/linkerhand_calibration/test/test_alignment_view.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_all_model_artifact_pipeline.py b/src/linkerhand_calibration/test/test_all_model_artifact_pipeline.py deleted file mode 100644 index afbe185..0000000 --- a/src/linkerhand_calibration/test/test_all_model_artifact_pipeline.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_all_view_image_acceptance.py b/src/linkerhand_calibration/test/test_all_view_image_acceptance.py deleted file mode 100644 index aabe1f9..0000000 --- a/src/linkerhand_calibration/test/test_all_view_image_acceptance.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_all_view_image_capture.py b/src/linkerhand_calibration/test/test_all_view_image_capture.py deleted file mode 100644 index cc7c8c1..0000000 --- a/src/linkerhand_calibration/test/test_all_view_image_capture.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_architecture.py b/src/linkerhand_calibration/test/test_architecture.py deleted file mode 100644 index 366a6c0..0000000 --- a/src/linkerhand_calibration/test/test_architecture.py +++ /dev/null @@ -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}) diff --git a/src/linkerhand_calibration/test/test_artifact_publication.py b/src/linkerhand_calibration/test/test_artifact_publication.py deleted file mode 100644 index c1a733f..0000000 --- a/src/linkerhand_calibration/test/test_artifact_publication.py +++ /dev/null @@ -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('', 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('', 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('') - 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() diff --git a/src/linkerhand_calibration/test/test_axis_line_gauge.py b/src/linkerhand_calibration/test/test_axis_line_gauge.py deleted file mode 100644 index 5be1367..0000000 --- a/src/linkerhand_calibration/test/test_axis_line_gauge.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_bounded_mimic_approximation.py b/src/linkerhand_calibration/test/test_bounded_mimic_approximation.py deleted file mode 100644 index 9889515..0000000 --- a/src/linkerhand_calibration/test/test_bounded_mimic_approximation.py +++ /dev/null @@ -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)) diff --git a/src/linkerhand_calibration/test/test_branch_initialization.py b/src/linkerhand_calibration/test/test_branch_initialization.py deleted file mode 100644 index cfa0a35..0000000 --- a/src/linkerhand_calibration/test/test_branch_initialization.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_branch_worker.py b/src/linkerhand_calibration/test/test_branch_worker.py deleted file mode 100644 index a785f3c..0000000 --- a/src/linkerhand_calibration/test/test_branch_worker.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_cad_image_geometry.py b/src/linkerhand_calibration/test/test_cad_image_geometry.py deleted file mode 100644 index 2120210..0000000 --- a/src/linkerhand_calibration/test/test_cad_image_geometry.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_calibrated_joint_state_bridge.py b/src/linkerhand_calibration/test/test_calibrated_joint_state_bridge.py deleted file mode 100644 index 5ca160b..0000000 --- a/src/linkerhand_calibration/test/test_calibrated_joint_state_bridge.py +++ /dev/null @@ -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" - ) diff --git a/src/linkerhand_calibration/test/test_calibrated_motion_limits.py b/src/linkerhand_calibration/test/test_calibrated_motion_limits.py deleted file mode 100644 index 2e17bc7..0000000 --- a/src/linkerhand_calibration/test/test_calibrated_motion_limits.py +++ /dev/null @@ -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") diff --git a/src/linkerhand_calibration/test/test_calibration_coordinator.py b/src/linkerhand_calibration/test/test_calibration_coordinator.py deleted file mode 100644 index 2b0f0e0..0000000 --- a/src/linkerhand_calibration/test/test_calibration_coordinator.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_camera_preflight.py b/src/linkerhand_calibration/test/test_camera_preflight.py deleted file mode 100644 index 3c06c92..0000000 --- a/src/linkerhand_calibration/test/test_camera_preflight.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_camera_timing.py b/src/linkerhand_calibration/test/test_camera_timing.py deleted file mode 100644 index 1fa8191..0000000 --- a/src/linkerhand_calibration/test/test_camera_timing.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_capture_journal.py b/src/linkerhand_calibration/test/test_capture_journal.py deleted file mode 100644 index e708b60..0000000 --- a/src/linkerhand_calibration/test/test_capture_journal.py +++ /dev/null @@ -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)] diff --git a/src/linkerhand_calibration/test/test_capture_metadata_concurrency.py b/src/linkerhand_calibration/test/test_capture_metadata_concurrency.py deleted file mode 100644 index f87795c..0000000 --- a/src/linkerhand_calibration/test/test_capture_metadata_concurrency.py +++ /dev/null @@ -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} diff --git a/src/linkerhand_calibration/test/test_capture_plan.py b/src/linkerhand_calibration/test/test_capture_plan.py deleted file mode 100644 index c8be5eb..0000000 --- a/src/linkerhand_calibration/test/test_capture_plan.py +++ /dev/null @@ -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)} diff --git a/src/linkerhand_calibration/test/test_capture_provenance.py b/src/linkerhand_calibration/test/test_capture_provenance.py deleted file mode 100644 index d38b066..0000000 --- a/src/linkerhand_calibration/test/test_capture_provenance.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_capture_runtime_index.py b/src/linkerhand_calibration/test/test_capture_runtime_index.py deleted file mode 100644 index 01cb1fe..0000000 --- a/src/linkerhand_calibration/test/test_capture_runtime_index.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_chain_candidate_audit.py b/src/linkerhand_calibration/test/test_chain_candidate_audit.py deleted file mode 100644 index 5436bce..0000000 --- a/src/linkerhand_calibration/test/test_chain_candidate_audit.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_chain_capture.py b/src/linkerhand_calibration/test/test_chain_capture.py deleted file mode 100644 index f8e39b1..0000000 --- a/src/linkerhand_calibration/test/test_chain_capture.py +++ /dev/null @@ -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, ()) diff --git a/src/linkerhand_calibration/test/test_chain_capture_runtime.py b/src/linkerhand_calibration/test/test_chain_capture_runtime.py deleted file mode 100644 index b8a926b..0000000 --- a/src/linkerhand_calibration/test/test_chain_capture_runtime.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_checkpoint_extension.py b/src/linkerhand_calibration/test/test_checkpoint_extension.py deleted file mode 100644 index a81b5cc..0000000 --- a/src/linkerhand_calibration/test/test_checkpoint_extension.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_checkpoint_observer_upgrade.py b/src/linkerhand_calibration/test/test_checkpoint_observer_upgrade.py deleted file mode 100644 index d289b8a..0000000 --- a/src/linkerhand_calibration/test/test_checkpoint_observer_upgrade.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_checkpoint_startup.py b/src/linkerhand_calibration/test/test_checkpoint_startup.py deleted file mode 100644 index 3c3af53..0000000 --- a/src/linkerhand_calibration/test/test_checkpoint_startup.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_command_motion.py b/src/linkerhand_calibration/test/test_command_motion.py deleted file mode 100644 index 8e2417b..0000000 --- a/src/linkerhand_calibration/test/test_command_motion.py +++ /dev/null @@ -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()) diff --git a/src/linkerhand_calibration/test/test_compact_calibration_json.py b/src/linkerhand_calibration/test/test_compact_calibration_json.py deleted file mode 100644 index 74ae63a..0000000 --- a/src/linkerhand_calibration/test/test_compact_calibration_json.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_conditioned_witness.py b/src/linkerhand_calibration/test/test_conditioned_witness.py deleted file mode 100644 index 91c7d04..0000000 --- a/src/linkerhand_calibration/test/test_conditioned_witness.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_config.py b/src/linkerhand_calibration/test/test_config.py deleted file mode 100644 index 040e06a..0000000 --- a/src/linkerhand_calibration/test/test_config.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_core.py b/src/linkerhand_calibration/test/test_core.py deleted file mode 100644 index 056a1ef..0000000 --- a/src/linkerhand_calibration/test/test_core.py +++ /dev/null @@ -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 - ) diff --git a/src/linkerhand_calibration/test/test_declared_byte_direction.py b/src/linkerhand_calibration/test/test_declared_byte_direction.py deleted file mode 100644 index 59da0b5..0000000 --- a/src/linkerhand_calibration/test/test_declared_byte_direction.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_declared_witness_path.py b/src/linkerhand_calibration/test/test_declared_witness_path.py deleted file mode 100644 index 0f40015..0000000 --- a/src/linkerhand_calibration/test/test_declared_witness_path.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_detection_dispatch.py b/src/linkerhand_calibration/test/test_detection_dispatch.py deleted file mode 100644 index 828accc..0000000 --- a/src/linkerhand_calibration/test/test_detection_dispatch.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_diagnostic_analysis.py b/src/linkerhand_calibration/test/test_diagnostic_analysis.py deleted file mode 100644 index b76ce91..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_analysis.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_diagnostic_coordinator.py b/src/linkerhand_calibration/test/test_diagnostic_coordinator.py deleted file mode 100644 index f8f2fe5..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_coordinator.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_diagnostic_observation_scope.py b/src/linkerhand_calibration/test/test_diagnostic_observation_scope.py deleted file mode 100644 index a54e3f5..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_observation_scope.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py b/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py deleted file mode 100644 index 87f0257..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py +++ /dev/null @@ -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 diff --git a/src/linkerhand_calibration/test/test_diagnostic_provenance.py b/src/linkerhand_calibration/test/test_diagnostic_provenance.py deleted file mode 100644 index 68fc51e..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_provenance.py +++ /dev/null @@ -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('', 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 diff --git a/src/linkerhand_calibration/test/test_diagnostic_quality.py b/src/linkerhand_calibration/test/test_diagnostic_quality.py deleted file mode 100644 index 9a6579c..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_quality.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_diagnostic_reference_flow.py b/src/linkerhand_calibration/test/test_diagnostic_reference_flow.py deleted file mode 100644 index 7992245..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_reference_flow.py +++ /dev/null @@ -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() diff --git a/src/linkerhand_calibration/test/test_diagnostic_report.py b/src/linkerhand_calibration/test/test_diagnostic_report.py deleted file mode 100644 index 4ed44e2..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_report.py +++ /dev/null @@ -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) diff --git a/src/linkerhand_calibration/test/test_diagnostic_session.py b/src/linkerhand_calibration/test/test_diagnostic_session.py deleted file mode 100644 index 1f7490b..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_session.py +++ /dev/null @@ -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()}) diff --git a/src/linkerhand_calibration/test/test_diagnostic_thumb_cli.py b/src/linkerhand_calibration/test/test_diagnostic_thumb_cli.py deleted file mode 100644 index 06cb29c..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_thumb_cli.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Exercise the short capture through the real CLI and process boundary, offline.""" - -from dataclasses import replace -import importlib.util -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest -import yaml - -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.runtime import runner -from linkerhand_calibration.runtime.ros.parameters import load_runtime_parameters, parameter_defaults -from linkerhand_calibration.runtime.runner_support import launch_command, protected_inputs - - -PACKAGE = Path(__file__).resolve().parents[1] - - -def product(model="o6"): - return load_product_config(PACKAGE / f"config/{model}_right_product.yaml", - workspace=PACKAGE.parents[1], check_can=False) - - -@pytest.mark.parametrize("flag,mode", [ - ("--diagnostic-thumb", "o6_thumb"), ("--diagnostic-yaw-hold", "o6_yaw_hold"), -]) -def test_cli_selects_diagnostic_capture_and_disables_resume(monkeypatch, flag, mode): - calls = [] - config = product() - monkeypatch.setattr(runner, "load_product_config", lambda *args, **kwargs: config) - monkeypatch.setattr(runner, "run_online", lambda *args, **kwargs: calls.append(kwargs) or 0) - with pytest.raises(SystemExit) as result: - runner.main([flag, "--record-bag"]) - assert result.value.code == 0 - assert calls == [{"record_bag": True, "commands_enabled": True, - "allow_resume": False, "diagnostic_capture": mode}] - - -@pytest.mark.parametrize("arguments", [ - ["--offline-raw", "old.jsonl"], ["--offline-output", "output"], - ["--publish-offline"], ["--commands-disabled"], -]) -@pytest.mark.parametrize("flag", ["--diagnostic-thumb", "--diagnostic-yaw-hold"]) -def test_cli_rejects_incompatible_modes_before_loading_devices(monkeypatch, arguments, flag): - monkeypatch.setattr(runner, "load_product_config", lambda *args, **kwargs: pytest.fail("loaded hardware config")) - with pytest.raises(SystemExit) as result: - runner.main([flag, *arguments]) - assert result.value.code == 2 - - -@pytest.mark.parametrize("model", ["l6", "o12", "g20"]) -@pytest.mark.parametrize("flag", ["--diagnostic-thumb", "--diagnostic-yaw-hold"]) -def test_thumb_cli_rejects_other_models_without_starting_stack(monkeypatch, model, flag): - config = product(model) - monkeypatch.setattr(runner, "load_product_config", lambda *args, **kwargs: config) - monkeypatch.setattr(runner, "run_online", lambda *args, **kwargs: pytest.fail("started stack")) - with pytest.raises(SystemExit) as result: - runner.main([flag, "--validate-only"]) - assert result.value.code == 2 - - -@pytest.mark.parametrize("flag,mode", [ - ("--diagnostic-thumb", "o6_thumb"), ("--diagnostic-yaw-hold", "o6_yaw_hold"), -]) -def test_validate_only_exposes_diagnostic_scope_without_starting_stack(monkeypatch, capsys, flag, mode): - monkeypatch.setattr(runner, "run_online", lambda *args, **kwargs: pytest.fail("started stack")) - runner.main(["--config", str(PACKAGE / "config/o6_right_product.yaml"), - "--workspace", str(PACKAGE.parents[1]), flag, "--validate-only"]) - output = capsys.readouterr().out - report, _ = json.JSONDecoder().raw_decode(output) - assert report["diagnostic_capture"]["mode"] == mode - assert report["diagnostic_capture"]["publication_allowed"] is False - if mode == "o6_yaw_hold": - assert report["diagnostic_capture"]["hold_command_values"] == [64., 128., 192.] - assert report["diagnostic_capture"]["hold_seconds"] == 10. - - -def test_cli_diagnostic_presets_are_mutually_exclusive_before_device_loading(monkeypatch): - monkeypatch.setattr(runner, "load_product_config", lambda *a, **kw: pytest.fail("loaded devices")) - with pytest.raises(SystemExit) as result: - runner.main(["--diagnostic-thumb", "--diagnostic-yaw-hold"]) - assert result.value.code == 2 - - -def test_runner_recognizes_diagnostic_completion_as_separate_terminal(): - status = {"state": "DIAGNOSTIC_COMPLETE"} - monitor = SimpleNamespace(status=status) - result = runner.drive_session(monitor, None, spin=lambda: None, ok=lambda: True, - request_start=lambda: pytest.fail("unexpected start")) - assert result == status - - -@pytest.mark.parametrize("terminal,report_change,expected,analysis_outcome", [ - ("DIAGNOSTIC_COMPLETE", {}, 0, "complete"), - ("DIAGNOSTIC_COMPLETE", {}, 0, "failed"), - ("DIAGNOSTIC_COMPLETE", {}, 130, "cancelled"), - ("DIAGNOSTIC_COMPLETE", {"missing_joint_zero_references": ["rh_thumb_ip"], - "accepted_pose_quality_incomplete_attempts": 2}, 0, "complete"), - ("DIAGNOSTIC_COMPLETE", {"mode": "other"}, 2, "complete"), - ("DIAGNOSTIC_COMPLETE", {"publication_allowed": True}, 2, "complete"), - ("DIAGNOSTIC_COMPLETE", {"publication_allowed": 0}, 2, "complete"), - ("DIAGNOSTIC_COMPLETE", {"result": "COMPLETE"}, 2, "complete"), - ("DIAGNOSTIC_COMPLETE", None, 2, "complete"), - ("COMPLETE", {}, 3, "complete"), - ("PAUSED", {}, 3, "complete"), -]) -@pytest.mark.parametrize("diagnostic_mode", ["o6_thumb", "o6_yaw_hold"]) -def test_online_diagnostic_never_enters_artifact_completion( - monkeypatch, tmp_path, capsys, terminal, report_change, expected, analysis_outcome, diagnostic_mode, -): - import rclpy - import linkerhand_calibration.hikrobot_camera as camera - from linkerhand_calibration.runtime import diagnostic_analysis, resume, runner_support - from linkerhand_calibration.runtime.artifacts import completion - - config = replace(product(), output_root=tmp_path) - session = tmp_path / "session" - session.mkdir() - if report_change is not None: - report = {"mode": diagnostic_mode, "result": "DIAGNOSTIC_COMPLETE", - "publication_allowed": False, **report_change} - (session / "diagnostic_capture.json").write_text(json.dumps(report)) - now = [0.0] - launched = [] - stopped = [] - analyzed = [] - monitor = SimpleNamespace( - count_publishers=lambda topic: 0, - status_topic="/offline/status", - destroy_node=lambda: stopped.append("monitor"), - ) - monkeypatch.setattr(rclpy, "ok", lambda: True) - monkeypatch.setattr(rclpy, "spin_once", lambda *args, **kwargs: None) - - def clock(): - now[0] += 1.0 - return now[0] - - monkeypatch.setattr(runner.time, "monotonic", clock) - monkeypatch.setattr(runner_support, "create_session_directory", lambda root: session) - monkeypatch.setattr(runner_support, "CalibrationMonitor", lambda **kwargs: monitor) - monkeypatch.setattr(runner_support, "stop_stack", lambda process: stopped.append("stack")) - def analyze_after_shutdown(profile, directory): - assert stopped == ["stack"] - assert directory == session - analyzed.append(profile.key.profile_id) - if analysis_outcome == "cancelled": - raise KeyboardInterrupt - return {"status": analysis_outcome, "accuracy_validated": False, "publication_allowed": False} - monkeypatch.setattr(diagnostic_analysis, "finish_diagnostic_analysis", analyze_after_shutdown) - monkeypatch.setattr(camera, "configure_fastdds_large_image_transport", lambda: None) - monkeypatch.setattr(resume, "discover_resume_candidate", - lambda *args, **kwargs: pytest.fail("diagnostic reused old samples")) - monkeypatch.setattr(completion, "finish_online_artifacts", - lambda *args, **kwargs: pytest.fail("diagnostic attempted publication")) - monkeypatch.setattr(runner.subprocess, "Popen", lambda command, **kwargs: launched.append(command) or object()) - monkeypatch.setattr(runner, "drive_session", lambda *args, **kwargs: {"state": terminal}) - - assert runner.run_online(config, diagnostic_capture=diagnostic_mode) == expected - assert len(launched) == 1 - assert f"diagnostic_capture:={diagnostic_mode}" in launched[0] - assert not any(arg.startswith("resume_raw_samples_path:=") for arg in launched[0]) - assert stopped == ["stack", "monitor"] - assert analyzed == ([config.profile_key.profile_id] if expected in {0, 130} else []) - if expected in {0, 130}: - output = capsys.readouterr().out - assert "诊断数据已采完" in output - assert "本次未进行精度验收,未发布 JSON 或修正 URDF" in output - if diagnostic_mode == "o6_yaw_hold": - assert "拇指yaw方向差保持诊断" in output - if report_change.get("missing_joint_zero_references"): - assert "零位未确认,仅保留原始观测:rh_thumb_ip" in output - assert "未生成这些关节的零位或角度样本" in output - assert "2 次方向采集的可信姿态覆盖不足" in output - if expected == 130: - assert "诊断采集已完成;已取消后续分析" in output - assert "已请求中止" not in output - assert not (session / "runner_diagnostic.json").exists() - else: - assert (session / "runner_diagnostic.json").is_file() - - -def runtime_values(config, tmp_path): - profile = config.calibration_contract.typed_profile - declared = next(iter(yaml.safe_load(config.calibration_config.read_text()).values()))["ros__parameters"] - return {**parameter_defaults(profile), **declared, - "serial_number": "OFFLINE_DIAGNOSTIC", "session_dir": str(tmp_path), - "source_urdf_path": str(config.source_urdf), - "camera_extrinsics_file": str(config.camera_extrinsics), - **{key.replace("_sha256", "_expected_sha256"): value - for key, value in protected_inputs(config).items()}} - - -@pytest.mark.parametrize("mode", ["o6_thumb", "o6_yaw_hold"]) -def test_ros_parameters_load_explicit_diagnostic_mode(tmp_path, mode): - config = product() - values = runtime_values(config, tmp_path) - assert values["diagnostic_capture"] == "" - values["diagnostic_capture"] = mode - resolved = load_runtime_parameters(config.calibration_contract.typed_profile, values.__getitem__) - assert resolved.diagnostic_capture == mode - assert resolved.resume_raw_samples_path is None - - -@pytest.mark.parametrize("extra", [ - {"commands_enabled": False}, {"resume_raw_samples_path": "old.jsonl"}, - {"diagnostic_capture": "unknown"}, -]) -@pytest.mark.parametrize("mode", ["o6_thumb", "o6_yaw_hold"]) -def test_direct_ros_entry_cannot_bypass_diagnostic_constraints(tmp_path, extra, mode): - config = product() - values = {**runtime_values(config, tmp_path), "diagnostic_capture": mode, **extra} - with pytest.raises(ValueError): - load_runtime_parameters(config.calibration_contract.typed_profile, values.__getitem__) - - -@pytest.mark.parametrize("mode", ["o6_thumb", "o6_yaw_hold"]) -def test_launch_forwards_diagnostic_string_to_owner_and_suppresses_resume(tmp_path, monkeypatch, mode): - from launch import LaunchContext - from launch.actions import DeclareLaunchArgument - from launch_ros.actions import Node - from launch_ros.utilities import evaluate_parameters - from ros2launch.api.api import parse_launch_arguments - - monkeypatch.setenv("ROS_LOG_DIR", str(tmp_path / "ros_logs")) - config = product() - arguments = dict(parse_launch_arguments(launch_command(config, tmp_path / "session", - record_bag=False, commands_enabled=True, resume_from=tmp_path / "old", - diagnostic_capture=mode)[4:])) - assert "resume_raw_samples_path" not in arguments - assert arguments["diagnostic_capture"] == mode - spec = importlib.util.spec_from_file_location("diagnostic_launch", - PACKAGE / "launch/three_camera_calibration.launch.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - context = LaunchContext() - context.launch_configurations.update(arguments) - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - nodes = [action for action in module._launch_stack(context) if isinstance(action, Node)] - owner = next(node for node in nodes if node.node_executable == "three_camera_calibration_node") - resolved = evaluate_parameters(context, owner._Node__parameters) - assert any(isinstance(item, dict) and item.get("diagnostic_capture") == mode for item in resolved) diff --git a/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_coordinator.py b/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_coordinator.py deleted file mode 100644 index a9bb1fc..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_coordinator.py +++ /dev/null @@ -1,139 +0,0 @@ -"""The real coordinator holds six declared poses without publishing artifacts. - -Projected image inputs exercise the public geometry/capture path. This test -checks schedule duration, raw observation gates and publication rules; its -synthetic Tags do not constitute a hardware accuracy certificate. -""" - -from collections import defaultdict -import hashlib -import json - -import numpy as np -import pytest - -from linkerhand_calibration.acquisition import StateSample -from linkerhand_calibration.runtime.diagnostic_capture import diagnostic_hold_timing -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from runtime_host_fixture import coordinator_fixture, ready -from test_diagnostic_coordinator import advance, assert_terminal_is_idle, TERMINAL_PHASES - - -def test_hold_timing_uses_only_this_segment_commands_at_or_before_image(): - target = (255., 192.) - history = [StateSample(90, target), StateSample(100, (255., 220.)), - StateSample(115, target), StateSample(120, target), StateSample(140, target)] - early = diagnostic_hold_timing(history, target=target, image_stamp_ns=110, - session_epoch=2, motion_version=7, motion_started_ns=100, feedback_stable_since_ns=None) - assert early == {"hold_id": "2:7", "target_first_command_stamp_ns": None, - "feedback_stable_since_ns": None} - current = diagnostic_hold_timing(history, target=target, image_stamp_ns=125, - session_epoch=2, motion_version=7, motion_started_ns=100, feedback_stable_since_ns=120) - assert current["target_first_command_stamp_ns"] == 115 - assert current["feedback_stable_since_ns"] == 120 - next_segment = diagnostic_hold_timing(history, target=target, image_stamp_ns=145, - session_epoch=2, motion_version=8, motion_started_ns=130, feedback_stable_since_ns=None) - assert next_segment["hold_id"] == "2:8" and next_segment["target_first_command_stamp_ns"] == 140 - - -@pytest.mark.parametrize("stable_stamp", [90, 126]) -def test_hold_timing_does_not_import_stability_from_previous_segment_or_future(stable_stamp): - result = diagnostic_hold_timing([StateSample(110, (255., 192.))], target=(255., 192.), - image_stamp_ns=125, session_epoch=2, motion_version=7, motion_started_ns=100, - feedback_stable_since_ns=stable_stamp) - assert result["target_first_command_stamp_ns"] == 110 - assert result["feedback_stable_since_ns"] is None - - -def test_yaw_hold_completes_six_full_holds_and_keeps_pre_stable_raw_images(tmp_path, monkeypatch): - host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_yaw_hold") - ready(host, clock) - assert set(host.required_observation_views) == {"top"} - 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 - intervals, order, cursor, checked_three = {}, [], 0, set() - accepted = defaultdict(int) - previous = None - try: - for _ in range(9000): - if host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE: - break - if host.execution.session.phase in TERMINAL_PHASES: - pytest.fail(f"premature diagnostic exit: {host.state}: {host.reason}") - advance(host, clock) - motion = host._motion - key = None - if motion is not None and motion.phase == "steady": - key = (motion.direction, motion.steady_index, motion.target[motion.command_index]) - if key not in intervals: - order.append(key) - intervals[key] = {"start": clock.now, "target_first": None, "end": None} - if tuple(host.last_command) == motion.target and intervals[key]["target_first"] is None: - intervals[key]["target_first"] = clock.now - if previous is not None and previous != key: - intervals[previous]["end"] = clock.now - previous = key - for row in host.raw_records[cursor:]: - if row.get("kind") != "joint_sample" or row.get("sample_phase") != "steady": - continue - sample_key = (row["direction"], row["steady_index"], row["steady_target"]) - accepted[sample_key] += 1 - if accepted[sample_key] == 3: - # Three good frames must not terminate a ten-second hold. - assert key == sample_key - assert host.segment is not None and not host.segment.minimum_hold_complete(clock.now) - checked_three.add(sample_key) - cursor = len(host.raw_records) - else: - pytest.fail(f"virtual yaw-hold capture timed out: {host.state}: {host.reason}") - assert host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE - expected = [("decreasing", i, value) for i, value in enumerate((192., 128., 64.))] - expected += [("increasing", i, value) for i, value in enumerate((64., 128., 192.))] - assert order == expected - assert checked_three == set(expected) - for key in expected: - interval = intervals[key] - assert interval["target_first"] is not None and interval["end"] is not None - assert interval["end"] - interval["target_first"] >= 10. - 1e-9 - assert accepted[key] > 3 - - holds = defaultdict(list) - for row in host.raw_records: - if row.get("kind") == "diagnostic_observation_frame" and row.get("sample_phase") == "steady": - holds[(row["direction"], row["steady_index"], row["steady_target"])].append(row) - assert set(holds) == set(expected) - hold_ids, versions = set(), set() - for key, rows in holds.items(): - assert {row["minimum_hold_seconds"] for row in rows} == {10.} - assert any(not row["accepted_sample_window_open"] for row in rows) - assert any(row["accepted_sample_window_open"] for row in rows) - assert all(row["is_accepted_joint_sample"] is False for row in rows) - assert all(row["command_vector"] is not None and row["feedback_vector"] is not None for row in rows) - assert len({row["hold_id"] for row in rows}) == 1 - assert len({row["session_epoch"] for row in rows}) == 1 - assert len({row["motion_version"] for row in rows}) == 1 - assert len({row["motion_started_ns"] for row in rows}) == 1 - hold_ids.add(rows[0]["hold_id"]) - versions.add(rows[0]["motion_version"]) - assert all(row["image_stamp_ns"] >= row["motion_started_ns"] for row in rows) - target_stamps = {row["target_first_command_stamp_ns"] for row in rows - if row["target_first_command_stamp_ns"] is not None} - assert len(target_stamps) == 1 - assert max(row["image_stamp_ns"] for row in rows) - next(iter(target_stamps)) >= 9.9e9 - assert any(row["feedback_stable_since_ns"] is None for row in rows) - assert any(row["feedback_stable_since_ns"] is not None for row in rows) - assert len(hold_ids) == len(versions) == 6 - references = [row for row in host.raw_records if row.get("kind") == "joint_zero_reference"] - assert [row["joint"] for row in references] == ["rh_thumb_cmc_yaw"] - assert len(host.execution.completed_units) == 2 - assert not host.finalization.started and not host.final_json and not host.final_urdf - assert not list(tmp_path.rglob("*.urdf")) - assert all(tuple(command[i] for i in (0, 2, 3, 4, 5)) == (255.,) * 5 for command in clock.positions) - report = json.loads((host.parameters.session_dir / "diagnostic_capture.json").read_text()) - assert not report["publication_allowed"] and not report["accuracy_validated"] - assert report["raw_samples_sha256"] == hashlib.sha256(host.raw_path.read_bytes()).hexdigest() - assert_terminal_is_idle(host, clock, monkeypatch) - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_plan.py b/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_plan.py deleted file mode 100644 index 25077c1..0000000 --- a/src/linkerhand_calibration/test/test_diagnostic_yaw_hold_plan.py +++ /dev/null @@ -1,59 +0,0 @@ -"""The directional-hold preset has bounded motion and diagnostic-only scope.""" - -from dataclasses import replace - -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.observation_scope import required_observation_views -from linkerhand_calibration.runtime.status import render_status_zh - - -def test_yaw_hold_scope_and_legacy_serialization(): - profile = load_bundled_hand_profile("o6_right_8") - plan = resolve_diagnostic_capture(profile, "o6_yaw_hold") - assert plan.as_dict() == {"mode": "o6_yaw_hold", "task_keys": ("thumb_yaw_top",), - "cycles": (0,), "hold_command_values": (64., 128., 192.), "hold_seconds": 10.} - assert required_observation_views(profile, plan) == ("top",) - legacy = resolve_diagnostic_capture(profile, "o6_thumb") - assert legacy.hold_command_values == () and legacy.hold_seconds == 0. - assert legacy.as_dict() == {"mode": "o6_thumb", "cycles": (0,), - "task_keys": ("thumb_yaw_top", "thumb_pitch_ip_front")} - text = render_status_zh({"phase": "steady", "diagnostic_capture": plan.as_dict()}) - assert "拇指yaw方向差保持诊断" in text - assert "方向差保持采集(每点至少 10 秒)" in text - assert "不发布标定 JSON/URDF" in text - complete = render_status_zh({"phase": "DIAGNOSTIC_COMPLETE", "diagnostic_capture": plan.as_dict()}) - assert "诊断采集完成(未进行精度验收)" in complete - - -@pytest.mark.parametrize("values,seconds", [ - ((64., 128.), 0.), ((64.,), 10.01), ((64.,), float("nan")), - ((), 10.), ((64., float("inf")), 10.), ((64., 64.), 10.), - ((128., 64.), 10.), (("64",), 10.), -]) -def test_invalid_hold_contract_is_rejected(values, seconds): - with pytest.raises(ValueError, match="diagnostic_hold_"): - DiagnosticCapturePlan("invalid", ("task",), hold_command_values=values, hold_seconds=seconds) - - -@pytest.mark.parametrize("change", ["scan_endpoint", "channel_envelope", "joint_binding"]) -def test_yaw_hold_refuses_changed_profile_motion_contract(change): - profile = load_bundled_hand_profile("o6_right_8") - task = next(task for task in profile.motion.tasks if task.key == "thumb_yaw_top") - if change == "scan_endpoint": - tasks = tuple(replace(item, end_u8=64) if item == task else item for item in profile.motion.tasks) - profile = replace(profile, motion=replace(profile.motion, tasks=tasks)) - elif change == "channel_envelope": - upper = list(profile.command.maximum_values) - upper[task.command_index] = 192. - profile = replace(profile, command=replace(profile.command, upper_bounds=tuple(upper))) - else: - bindings = dict(profile.command.command_index_by_joint) - bindings["rh_thumb_cmc_yaw"] = (task.command_index + 1) % profile.command.command_count - profile = replace(profile, command=replace(profile.command, command_index_by_joint=bindings)) - with pytest.raises(ValueError, match="diagnostic_(hold_outside_task_domain|thumb_task_binding_changed)"): - resolve_diagnostic_capture(profile, "o6_yaw_hold") diff --git a/src/linkerhand_calibration/test/test_diagnostics.py b/src/linkerhand_calibration/test/test_diagnostics.py deleted file mode 100644 index 8f46766..0000000 --- a/src/linkerhand_calibration/test/test_diagnostics.py +++ /dev/null @@ -1,105 +0,0 @@ -from linkerhand_calibration.acquisition import TagQuality -from linkerhand_calibration.diagnostics import ( - build_tag_quality_diagnostics, - render_status_text_zh, - status_guidance_zh, -) - - -def _tag_config() -> dict[str, dict]: - return { - role: {"id": tag_id, "frame": role, "size_m": 0.01} - for role, tag_id in zip(("t0", "t3", "t4", "t5"), range(4)) - } - - -def test_invalid_t0_edge_has_specific_chinese_guidance() -> None: - qualities = { - role: TagQuality( - hamming=0, - decision_margin=100.0, - edge_pixels=29.4 if role == "t0" else 34.0, - ) - for role in _tag_config() - } - diagnostics = build_tag_quality_diagnostics( - _tag_config(), - qualities, - {"t0": "tag_quality_invalid"}, - {}, - maximum_hamming=0, - minimum_decision_margin=30.0, - minimum_edge_pixels=30.0, - maximum_reprojection_error_px=1.5, - ) - - assert diagnostics["t0"]["individual_valid"] is False - assert diagnostics["t0"]["edge_pixels"] == 29.4 - assert "边长29.4px" in diagnostics["t0"]["summary_zh"] - assert diagnostics["t3"]["individual_valid"] is True - - reason_zh, action_zh = status_guidance_zh( - "PAUSED", - "point_capture_failed:invalid_tag_frame", - diagnostics, - ) - assert "掌心T0" in reason_zh - assert "边长29.4px" in reason_zh - assert "相机稍微靠近" in action_zh - assert "resume" in action_zh - text = render_status_text_zh( - "标定已暂停", - reason_zh, - action_zh, - diagnostics, - ) - assert "状态:标定已暂停" in text - assert "掌心T0(ID 0):异常,边长29.4px" in text - assert "拇指根部T3(ID 1):正常" in text - - -def test_missing_tag_is_reported_without_manual_topic_parsing() -> None: - diagnostics = build_tag_quality_diagnostics( - _tag_config(), - {}, - {"t5": "tag_not_detected"}, - {}, - maximum_hamming=0, - minimum_decision_margin=30.0, - minimum_edge_pixels=30.0, - maximum_reprojection_error_px=1.5, - ) - - assert diagnostics["t5"]["detected"] is False - assert "未检测到" in diagnostics["t5"]["summary_zh"] - reason_zh, action_zh = status_guidance_zh( - "PAUSED", - "point_capture_failed:invalid_tag_frame", - diagnostics, - ) - assert "拇指末节T5" in reason_zh - assert "四张标签同时可见" in action_zh - - -def test_low_detection_frequency_has_direct_chinese_action() -> None: - reason_zh, action_zh = status_guidance_zh( - "PREFLIGHT", - "detection_hz_too_low:11.29", - {}, - ) - - assert "11.29Hz" in reason_zh - assert "额外订阅" in action_zh - - -def test_synchronised_timeout_explains_resume_not_start() -> None: - reason_zh, action_zh = status_guidance_zh( - "PAUSED", - "continuous_sweep_failed:synchronised_tag_state_timeout", - {}, - ) - - assert "运动过程中连续3秒" in reason_zh - assert "当前画面恢复正常" in reason_zh - assert "resume" in action_zh - assert "不要调用start" in action_zh diff --git a/src/linkerhand_calibration/test/test_directional_command_mapping.py b/src/linkerhand_calibration/test/test_directional_command_mapping.py deleted file mode 100644 index 0a81708..0000000 --- a/src/linkerhand_calibration/test/test_directional_command_mapping.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Recorded major-loop hysteresis cannot be hidden by a single command table.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.domain.reference import read_joint_zero_references -from linkerhand_calibration.core.domain.result import CalibrationResult, JointMapping -from linkerhand_calibration.core.fitting.observed_motion import fit_native_observed_rotation -from linkerhand_calibration.core.fitting.command_mapping import fit_command_mappings -from linkerhand_calibration.core.urdf.acceptance import SerializedJointMapping -from linkerhand_calibration.core.urdf.measured_acceptance import validate_measured_joint_holdout -from linkerhand_calibration.runtime.acquisition import accepted_joint_records -from linkerhand_calibration.runtime.artifacts.direction import next_input_directions -from linkerhand_calibration.runtime.artifacts.reader import UnifiedCommandMapper -from linkerhand_calibration.runtime.artifacts.serializers.measured_v3 import serialize_report, from_report - -JOINT = "rh_thumb_cmc_yaw" - - -@pytest.fixture(scope="module") -def recorded(): - payload = json.loads(gzip.decompress((Path(__file__).parent / "fixtures" / - "o6_yaw_directional_114156.json.gz").read_bytes())) - profile = load_bundled_hand_profile("o6_right_8") - # This recording used nine training nodes; later profiles may sample more. - profile = replace(profile, acquisition=replace(profile.acquisition, steady_training_nodes=9, - command_capture_mode="interleaved", steady_extra_training_nodes={})) - profile = replace(profile, measurement=replace(profile.measurement, - measurements={JOINT: profile.measurement.measurements[JOINT]}, - transferred_motion_sources={}, cross_view_sources={})) - rows = payload["records"] - reference = read_joint_zero_references(profile, rows)[JOINT] - sweep = accepted_joint_records(profile, rows)[JOINT] - steady = accepted_joint_records(profile, rows, sample_phase="steady") - curve, errors = fit_native_observed_rotation(sweep, input_domain="feedback_u8", - input_to_joint_direction=-1, reference_xyzw=reference.quaternion("top")) - mapping = JointMapping(JOINT, 1, "feedback_u8", tuple(curve.circle["input_knots"]), - curve.angle_rad, curve.increasing_rad, curve.decreasing_rad) - fit = CalibrationResult({JOINT: curve}, {JOINT: mapping}, {JOINT: 0.}, - {JOINT: "test_only_geometry"}, {}, {JOINT: errors}, None, - reference_inputs={JOINT: reference.input_reference("top", feedback=True)}, - joint_zero_references={JOINT: reference}) - return profile, fit, steady - - -def directional_payload(recorded): - profile, fit, steady = recorded - fitted = fit_command_mappings(profile, fit, steady) - prepared = SimpleNamespace(mappings=fitted.output_mappings, plan=SimpleNamespace(mimic_output={})) - report = serialize_report(profile, fitted, prepared, serial_number="TEST_ONLY", - protected_inputs={}) - return from_report(report), fitted, report - - -def test_recorded_single_table_fails_both_serialized_directional_branches_pass(recorded): - profile, fit, steady = recorded - single = replace(profile, artifacts=replace(profile.artifacts, directional_command_joints=frozenset())) - with pytest.raises(ValueError, match="steady_command_holdout_failed"): - fit_command_mappings(single, fit, steady) - payload, fitted, _ = directional_payload(recorded) - validation = [row for row in steady[JOINT] if row["cycle"] == 3] - metrics = validate_measured_joint_holdout(payload, fit.curves, validation) - for direction, value in metrics[JOINT].items(): - assert value["mae_deg"] < .3 - assert value["p95_deg"] < 2 - assert value["maximum_deg"] < 3 - mapping = SerializedJointMapping(payload) - baseline = profile.command.baseline_values - for direction in ("", "increasing", "decreasing"): - assert mapping.evaluate(baseline, direction, active_joints=(JOINT,))[JOINT] == 0 - values = list(baseline); values[1] = 128 - with pytest.raises(ValueError, match="requires_direction"): - mapping.evaluate(values, "", active_joints=(JOINT,)) - angles = [mapping.evaluate(values, d, active_joints=(JOINT,))[JOINT] - for d in ("increasing", "decreasing")] - assert np.degrees(abs(angles[0]-angles[1])) > 2 - assert fitted.command_applicability[JOINT]["interior_reversals_validated"] is False - - -def test_bad_branch_or_direction_declaration_cannot_pass(recorded): - payload, fit, _ = directional_payload(recorded) - bad = deepcopy(payload) - bad["joints"][JOINT]["increasing_rad"] = bad["joints"][JOINT]["decreasing_rad"] - with pytest.raises(ValueError, match="serialized_measured_mapping_inaccurate"): - validate_measured_joint_holdout(bad, fit.curves, - [r for r in recorded[2][JOINT] if r["cycle"] == 3]) - for mutation in ("missing", "undeclared", "unknown"): - bad = deepcopy(payload) - if mutation == "missing": del bad["joints"][JOINT]["decreasing_rad"] - if mutation == "undeclared": del bad["directional_command_joints"] - if mutation == "unknown": bad["directional_command_joints"].append("unknown") - with pytest.raises(ValueError): SerializedJointMapping(bad) - - -def test_runtime_direction_initialization_hold_endpoint_turn_and_interior_reversal(): - channels = {0: (255., 0., 255.)} - advance = lambda value, previous, direction: next_input_directions([value], - None if previous is None else [previous], [direction], resolution=.5, channels=channels) - with pytest.raises(ValueError, match="baseline_initialization"): advance(128, None, "") - assert advance(255, None, "") == ["increasing"] - assert advance(128, 255, "increasing") == ["decreasing"] - assert advance(128, 128, "decreasing") == ["decreasing"] - assert advance(128, 0, "decreasing") == ["increasing"] - with pytest.raises(ValueError, match="interior_reversal"): advance(140, 128, "decreasing") - - -def test_reader_uses_recorded_branches_and_commits_state_only_on_success(recorded, tmp_path): - payload, _, report = directional_payload(recorded) - urdf = tmp_path / "reader_only.urdf" - urdf.write_text(f''' - - - - - ''') - mapper = UnifiedCommandMapper(payload, urdf, report=report) - values = list(recorded[0].command.baseline_values) - values[1] = 128 - with pytest.raises(ValueError, match="baseline_initialization"): - mapper.map_positions(values) - assert mapper.previous is None - values[1] = 255 - assert mapper.map_positions(values) == (0.,) - values[1] = 254.6 - assert mapper.map_positions(values) == (0.,) - values[1] = 254.2 - assert mapper.map_positions(values) == (payload["joints"][JOINT]["decreasing_rad"][254],) - assert mapper.directions[1] == "decreasing" - values[1] = 128 - descending = mapper.map_positions(values) - assert descending == (payload["joints"][JOINT]["decreasing_rad"][128],) - assert mapper.map_positions(values) == descending - values[1] = 128.4 - assert mapper.map_positions(values) == descending - values[1] = 128.8 - with pytest.raises(ValueError, match="interior_reversal"): - mapper.map_positions(values) - assert mapper.previous[1] == 128.4 - values[1] = 140 - with pytest.raises(ValueError, match="interior_reversal"): - mapper.map_positions(values) - assert mapper.previous[1] == 128.4 - assert mapper.directions[1] == "decreasing" - values[1] = 0 - mapper.map_positions(values) - values[1] = 128 - ascending = mapper.map_positions(values) - assert ascending == (payload["joints"][JOINT]["increasing_rad"][128],) - assert np.degrees(abs(ascending[0]-descending[0])) > 2 diff --git a/src/linkerhand_calibration/test/test_dual_mapping.py b/src/linkerhand_calibration/test/test_dual_mapping.py deleted file mode 100644 index bbdbf0d..0000000 --- a/src/linkerhand_calibration/test/test_dual_mapping.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Command calibration uses visual truth, never feedback relabelled as command.""" - -from dataclasses import replace -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.domain.measurement import JointCurveFit -from linkerhand_calibration.core.domain.result import CalibrationResult, JointMapping -from linkerhand_calibration.core.fitting.command_mapping import fit_command_mappings -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.steady import steady_targets -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.core.domain.reference import build_joint_zero_reference -from test_joint_zero_runtime import reference_rows - - -def independent_steady_fixture(layout): - profile = load_bundled_hand_profile(layout) - # Explicit legacy partitions: the negative cases alter cycles 1 and 3. - profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="interleaved")) - task = profile.motion.tasks[0] - name = task.joints[0] - channel = task.command_index - lo, hi = sorted((task.start_value, task.end_value)) - native = profile.command.unit - # Deliberately different coordinate systems. The synthetic camera observes - # actual q(command); the SDK returns feedback = 0.8*command+offset. - offset = 10.0 if native == "u8" else .02 - scale = -.002 if native == "u8" else .7*profile.command.joint_directions[channel] - baseline = profile.command.baseline_values[channel] - q = lambda u: scale*(u-baseline) - knots = (.8*lo+offset, .8*hi+offset) - angles = (q(lo), q(hi)) - curve = JointCurveFit(angles, angles, angles, - {"space": "relative_rotation_3d", "reference_quaternion_xyzw": [0, 0, 0, 1], - "axis_xyz": [0, 0, 1], "input_knots": knots}, 0., 0., {}) - feedback = JointMapping(name, channel, f"feedback_{native}", knots, angles, angles, angles) - zero_rows = reference_rows(profile, name) - for row in zero_rows: - state = list(row[f"state_{native}"]) - state[channel] = .8*baseline+offset - row[f"state_{native}"] = state - reference = build_joint_zero_reference(profile, name, zero_rows, session_epoch=1, motion_version=1) - fit = CalibrationResult({name: curve}, {name: feedback}, {name: 0.}, {}, {}, {}, None, - reference_inputs={name: .8*baseline+offset}, joint_zero_references={name: reference}) - rows = [] - for unit in CalibrationEngine(profile).scan_units(): - if unit.task_key != task.key: - continue - for index, command in enumerate(steady_targets(profile, unit)): - for frame in range(3): - vector = list(profile.command.baseline_values) - for i, v in task.auxiliary_commands: - vector[i] = v - vector[channel] = command - rows.append({"joint": name, "task_name": task.key, "view": task.view, - "sample_id": f"{unit.cycle}:{unit.direction}:{index}:{frame}", - "sample_phase": "steady", "steady_target": command, "steady_index": index, - "cycle": unit.cycle, "direction": unit.direction, - f"command_{native}": command, f"feedback_{native}": .8*command+offset, - f"command_vector_{native}": vector, - "relative_quaternion_xyzw": Rotation.from_rotvec([0, 0, q(command)]).as_quat()}) - return profile, fit, name, rows, q - - -@pytest.mark.parametrize("layout", ["l6_right_8", "o12_right_16"]) -def test_command_map_is_measured_not_feedback_relabelled(layout): - profile, fit, name, rows, q = independent_steady_fixture(layout) - result = fit_command_mappings(profile, fit, {name: rows}) - mapping = result.command_mappings[name] - for command in np.linspace(mapping.knots[0], mapping.knots[-1], 30): - if profile.command.unit == "u8": - command = round(command) - assert mapping.evaluate(command, "increasing") == pytest.approx(q(command), abs=1e-9) - assert result.output_mappings[name] is fit.output_mappings[name] - assert result.command_mappings[name].knots != result.output_mappings[name].knots - - -def test_holdout_cannot_update_a_command_fit_or_tag_mount(): - profile, fit, name, rows, _ = independent_steady_fixture("o12_right_16") - for row in rows: - if row["cycle"] == 3: - 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: rows}) - assert not fit.command_mappings - - -def test_missing_steady_nodes_and_changed_auxiliary_are_not_silently_interpolated(): - profile, fit, name, rows, _ = independent_steady_fixture("o12_right_16") - with pytest.raises(ValueError, match="steady_training_incomplete"): - fit_command_mappings(profile, fit, {name: [r for r in rows if r["cycle"] != 1]}) - rows[0]["command_vector_rad"][1] = -.1 - with pytest.raises(ValueError, match="scan_changed_multiple"): - fit_command_mappings(profile, fit, {name: rows}) - - -def test_steady_motion_is_bounded_and_stability_is_not_tracking_equality(): - profile = load_bundled_hand_profile("o12_right_16") - initial = profile.command.baseline_values - target = list(initial); target[0] = .1 - segment = MotionExecution(profile, MotionCommand("steady", tuple(target), .1, - command_index=0, direction="increasing", steady_index=1), initial_command=initial, - initial_feedback=initial, now=0, identity="steady") - ready = False - for index in range(500): - now = index*.02 - command = segment.sample(now) - feedback = list(command); feedback[0] -= .02 - segment.observe(feedback, stamp=index, now=now) - ready |= segment.steady_ready(now) - if segment.arrived(now): - break - assert ready and segment.arrived(now) - assert now-segment.finished_at < 2.03 - - -def test_scan_and_steady_images_have_separate_input_semantics(): - from linkerhand_calibration.runtime.acquisition import accepted_joint_records - profile = load_bundled_hand_profile("l6_right_8") - task = profile.motion.tasks[0] - row = {"joint": task.joints[0], "task_name": task.key, "cycle": 0, "direction": "increasing", - "view": task.view, "image_stamp_ns": 1, "relative_quaternion_xyzw": [0, 0, 0, 1], - "command_u8": 10., "feedback_u8": 1.2} - original = dict(row) - result = accepted_joint_records(profile, [row])[task.joints[0]][0] - assert result["command_u8"] == 10. and result["feedback_u8"] == 1.2 and row == original - # A partially captured new retry must not borrow the old steady attempt. - steady = dict(row, sample_phase="steady", attempt=1) - retry = dict(row, attempt=2, image_stamp_ns=2) - assert not accepted_joint_records(profile, [steady, retry], sample_phase="steady")[task.joints[0]] diff --git a/src/linkerhand_calibration/test/test_extrinsics_node.py b/src/linkerhand_calibration/test/test_extrinsics_node.py deleted file mode 100644 index ef7eae8..0000000 --- a/src/linkerhand_calibration/test/test_extrinsics_node.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Focused tests for checkerboard frame pairing.""" - -import cv2 -import numpy as np -from scipy.spatial.transform import Rotation - -import linkerhand_calibration.extrinsics_node as extrinsics_node -from linkerhand_calibration.extrinsics_node import ( - BoardPose, - StereoCapture, - _fit_stereo_robust, - _individual_reprojection_passes, - _minimum_history_skew_ns, - _pose_is_novel, - _select_latest_synchronised_pair, - _summarize_transform_repeatability, -) - - -def _pose(stamp_ns: int) -> BoardPose: - return BoardPose( - stamp_ns=stamp_ns, - camera_from_board_candidates=(), - reprojection_rms_px=0.1, - ) - - -def test_pairing_uses_recent_history_instead_of_mismatched_latest_frames(): - front = [_pose(1_000_000_000), _pose(1_200_000_000)] - side = [_pose(1_030_000_000), _pose(1_370_000_000)] - - pair = _select_latest_synchronised_pair(front, side, 100_000_000) - - assert pair is not None - selected_front, selected_side, skew = pair - assert selected_front.stamp_ns == 1_000_000_000 - assert selected_side.stamp_ns == 1_030_000_000 - assert skew == 30_000_000 - - -def test_pairing_prefers_newest_valid_common_pair(): - front = [_pose(1_000_000_000), _pose(1_200_000_000)] - side = [_pose(1_010_000_000), _pose(1_240_000_000)] - - pair = _select_latest_synchronised_pair(front, side, 100_000_000) - - assert pair is not None - selected_front, selected_side, skew = pair - assert selected_front.stamp_ns == 1_200_000_000 - assert selected_side.stamp_ns == 1_240_000_000 - assert skew == 40_000_000 - - -def test_pairing_reports_minimum_skew_when_no_pair_passes(): - front = [_pose(1_000_000_000), _pose(1_200_000_000)] - side = [_pose(1_370_000_000)] - - assert _select_latest_synchronised_pair( - front, side, 100_000_000 - ) is None - assert _minimum_history_skew_ns(front, side) == 170_000_000 - - -def _transform(rotation_deg: float, translation_m: float) -> np.ndarray: - value = np.eye(4) - value[:3, :3] = Rotation.from_euler( - "z", rotation_deg, degrees=True - ).as_matrix() - value[0, 3] = translation_m - return value - - -def test_repeatability_summary_accepts_consistent_capture_set(): - captures = [ - [_transform(-0.1, -0.0005)], - [_transform(0.0, 0.0)], - [_transform(0.1, 0.0005)], - ] - - _, selected, rotation_error, translation_error = ( - _summarize_transform_repeatability(captures) - ) - - assert len(selected) == 3 - np.testing.assert_allclose( - np.degrees(rotation_error), 0.1, atol=1.0e-6 - ) - np.testing.assert_allclose(translation_error, 0.0005, atol=1.0e-9) - - -def test_repeatability_summary_exposes_current_capture_outlier(): - captures = [ - [_transform(0.0, 0.0)], - [_transform(0.1, 0.0005)], - [_transform(1.0, 0.008)], - ] - - _, _, rotation_error, translation_error = ( - _summarize_transform_repeatability(captures) - ) - - assert np.degrees(rotation_error) > 0.3 - assert translation_error > 0.0015 - - -def test_each_camera_must_pass_reprojection_gate_independently(): - assert _individual_reprojection_passes(0.8, 1.1, 1.2) - assert not _individual_reprojection_passes(0.5, 1.4, 1.2) - assert _individual_reprojection_passes(0.5, 1.4, 1.5) - assert not _individual_reprojection_passes(0.5, 1.6, 1.5) - assert not _individual_reprojection_passes(float("nan"), 0.5, 1.2) - - -def test_pose_novelty_is_checked_against_all_previous_captures(): - previous = [_transform(0.0, 0.0), _transform(10.0, 0.10)] - - assert not _pose_is_novel( - _transform(10.5, 0.105), - previous, - minimum_rotation_rad=np.deg2rad(2.0), - minimum_translation_m=0.015, - ) - assert _pose_is_novel( - _transform(14.0, 0.13), - previous, - minimum_rotation_rad=np.deg2rad(2.0), - minimum_translation_m=0.015, - ) - - -def test_joint_fit_trims_moderate_bad_views_without_relaxing_gate( - monkeypatch, -): - captures = [ - StereoCapture( - front_points_px=np.zeros((40, 2), dtype=np.float32), - other_points_px=np.zeros((40, 2), dtype=np.float32), - transform_candidates=(np.eye(4),), - pair_reprojection_rms_px=1.0, - skew_ns=1, - ) - for _ in range(20) - ] - - def fake_fit_once( - captures_arg, - indices, - object_points, - front_matrix, - other_matrix, - image_size, - ): - del ( - captures_arg, - object_points, - front_matrix, - other_matrix, - image_size, - ) - errors = np.asarray( - [1.1 if index < 15 else 1.5 for index in indices], - dtype=float, - ) - return float(np.sqrt(np.mean(np.square(errors)))), np.eye(4), errors - - monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once) - result = _fit_stereo_robust( - captures, - np.zeros((40, 3), dtype=np.float32), - np.eye(3), - np.eye(3), - (640, 480), - minimum_inliers=15, - maximum_rms_px=1.2, - maximum_rotation_stability_rad=np.deg2rad(0.3), - maximum_translation_stability_m=0.0015, - ) - - assert result.passed - assert result.stereo_rms_px <= 1.2 - assert len(result.inlier_indices) >= 15 - assert result.rejected_indices - assert set(result.rejected_indices) <= set(range(15, 20)) - - -def test_joint_fit_reports_finite_provisional_metrics_before_minimum( - monkeypatch, -): - captures = [ - StereoCapture( - front_points_px=np.zeros((40, 2), dtype=np.float32), - other_points_px=np.zeros((40, 2), dtype=np.float32), - transform_candidates=(np.eye(4),), - pair_reprojection_rms_px=0.8, - skew_ns=1, - ) - for _ in range(10) - ] - - def fake_fit_once( - captures_arg, - indices, - object_points, - front_matrix, - other_matrix, - image_size, - ): - del ( - captures_arg, - object_points, - front_matrix, - other_matrix, - image_size, - ) - return 0.8, np.eye(4), np.full(len(indices), 0.8) - - monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once) - result = _fit_stereo_robust( - captures, - np.zeros((40, 3), dtype=np.float32), - np.eye(3), - np.eye(3), - (640, 480), - minimum_inliers=15, - maximum_rms_px=1.2, - maximum_rotation_stability_rad=np.deg2rad(0.3), - maximum_translation_stability_m=0.0015, - ) - - assert not result.passed - assert result.stereo_rms_px == 0.8 - assert len(result.inlier_indices) == 10 - - -def test_joint_stereo_fit_recovers_transform_and_rejects_bad_view(): - random = np.random.default_rng(7) - object_points = np.zeros((40, 3), dtype=np.float32) - object_points[:, :2] = ( - np.mgrid[0:8, 0:5].T.reshape(-1, 2) * 0.027 - ) - matrix = np.asarray( - [[1800.0, 0.0, 812.0], [0.0, 1795.0, 620.0], [0.0, 0.0, 1.0]] - ) - other_from_front = np.eye(4) - other_from_front[:3, :3] = Rotation.from_euler( - "xyz", [2.0, 18.0, -1.0], degrees=True - ).as_matrix() - other_from_front[:3, 3] = [0.20, -0.01, 0.04] - expected_front_from_other = np.linalg.inv(other_from_front) - captures = [] - for index in range(21): - front_from_board = np.eye(4) - front_from_board[:3, :3] = Rotation.from_euler( - "xyz", - [ - -8.0 + index * 0.7, - 5.0 + (index % 5) * 2.0, - -5.0 + (index % 4) * 3.0, - ], - degrees=True, - ).as_matrix() - front_from_board[:3, 3] = [ - -0.08 + (index % 5) * 0.035, - -0.04 + (index % 4) * 0.025, - 0.75 + (index % 3) * 0.08, - ] - other_from_board = other_from_front @ front_from_board - front_rvec = Rotation.from_matrix( - front_from_board[:3, :3] - ).as_rotvec() - other_rvec = Rotation.from_matrix( - other_from_board[:3, :3] - ).as_rotvec() - front_points, _ = cv2.projectPoints( - object_points, - front_rvec, - front_from_board[:3, 3], - matrix, - np.zeros(5), - ) - other_points, _ = cv2.projectPoints( - object_points, - other_rvec, - other_from_board[:3, 3], - matrix, - np.zeros(5), - ) - front_points = front_points.reshape(-1, 2) - other_points = other_points.reshape(-1, 2) - front_points += random.normal(0.0, 0.12, front_points.shape) - other_points += random.normal(0.0, 0.12, other_points.shape) - if index == 20: - other_points += random.normal(0.0, 4.0, other_points.shape) - captures.append( - StereoCapture( - front_points_px=front_points.astype(np.float32), - other_points_px=other_points.astype(np.float32), - transform_candidates=(expected_front_from_other.copy(),), - pair_reprojection_rms_px=0.2, - skew_ns=10_000_000, - ) - ) - - result = _fit_stereo_robust( - captures, - object_points, - matrix, - matrix, - (1624, 1240), - minimum_inliers=15, - maximum_rms_px=1.2, - maximum_rotation_stability_rad=np.deg2rad(0.3), - maximum_translation_stability_m=0.0015, - ) - - assert result.passed - assert 20 in result.rejected_indices - assert len(result.inlier_indices) >= 15 - rotation_error = Rotation.from_matrix( - result.front_from_other[:3, :3] - ).inv() * Rotation.from_matrix(expected_front_from_other[:3, :3]) - assert np.degrees(rotation_error.magnitude()) < 0.05 - assert np.linalg.norm( - result.front_from_other[:3, 3] - - expected_front_from_other[:3, 3] - ) < 0.001 diff --git a/src/linkerhand_calibration/test/test_feedback_capture_timing.py b/src/linkerhand_calibration/test/test_feedback_capture_timing.py deleted file mode 100644 index 549978e..0000000 --- a/src/linkerhand_calibration/test/test_feedback_capture_timing.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Timer starvation cannot discard independently received SDK observations.""" - -import pytest - -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from runtime_host_fixture import coordinator_fixture - - -@pytest.mark.parametrize("pulse", [False, True]) -def test_sdk_feedback_between_slow_ticks_defines_the_stable_window(tmp_path, pulse): - host, clock = coordinator_fixture(tmp_path) - try: - profile = host.profile - baseline = profile.command.baseline_values - command = MotionCommand("joint_zero", baseline, 40., command_index=1) - segment = MotionExecution(profile, command, initial_command=baseline, - initial_feedback=baseline, now=clock.now, identity="slow_timer") - host.segment = segment - segment.sample(clock.now) - start = clock.now - for index in range(16): - clock.now = start+index*.02 - feedback = list(baseline) - if pulse and index == 9: - feedback[0] -= 5 # A held channel moves between control ticks. - host.receive_feedback(profile.command.names, feedback, host.ports.clock_ns()) - if index in {0, 15}: - segment.sample(clock.now) - segment.observe(host.latest_feedback, stamp=host.state_history[-1].stamp_ns, now=clock.now) - assert len(segment.stamped_history) == 16 - assert segment.steady_ready(clock.now) is not pulse - # Replayed feedback identities do not manufacture a larger sample set. - before = len(segment.stamped_history) - host.receive_feedback(profile.command.names, baseline, host.ports.clock_ns()) - assert len(segment.stamped_history) == before - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_feedback_reversal_progress.py b/src/linkerhand_calibration/test/test_feedback_reversal_progress.py deleted file mode 100644 index 1e194a9..0000000 --- a/src/linkerhand_calibration/test/test_feedback_reversal_progress.py +++ /dev/null @@ -1,131 +0,0 @@ -"""A delayed reversal must prove directional travel without inventing an endpoint.""" - -from dataclasses import replace -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.adapters import HardwareHealth -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.safety import SafetyPolicy, SafetySample - - -@pytest.fixture -def profile(): - return load_bundled_hand_profile("o30_right_18", scope="full") - - -def segment(profile, *, increasing=False, references=()): - start = list(profile.command.baseline_values) - target, feedback = start.copy(), start.copy() - start[0], target[0], feedback[0] = (0., 32., 34.) if increasing else (255., 223., 221.) - motion = MotionCommand("sweep", tuple(target), 200., command_index=0, - defer_settling_to_hold=True, feedback_targets=references) - return MotionExecution(profile, motion, initial_command=start, initial_feedback=feedback, - now=0., identity="reversal") - - -def observe(motion, safety, now, value, stamp): - command = motion.sample(now) - feedback = list(motion.start_feedback) - feedback[0] = value - motion.observe(feedback, stamp=stamp, now=now) - return safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=motion.motion_expected, motion_id=motion.identity, motion_goals=motion.goals)) - - -def test_recorded_reversal_returns_to_initial_feedback_and_completes(profile): - path = Path(__file__).parent / "fixtures/o30_roll_delayed_feedback.json" - trace = json.loads(path.read_text()) - motion, safety = segment(profile), SafetyPolicy(profile) - arrived_at = None - for stamp, (now, command, feedback) in enumerate(trace["frames"]): - assert observe(motion, safety, now, feedback, stamp).safe - # The independent logged command agrees with the configured trajectory. - assert abs(motion.sample(now)[0]-command) <= 2 - if motion.arrived(now) and arrived_at is None: - arrived_at = now - assert arrived_at is not None and arrived_at < 3. - assert motion.history[-1][0] == motion.start_feedback[0] == 221. - assert motion.feedback_motion_complete() - - -@pytest.mark.parametrize("increasing", [False, True]) -def test_delayed_feedback_reversal_is_symmetric(profile, increasing): - motion, safety = segment(profile, increasing=increasing), SafetyPolicy(profile) - original = motion.start_feedback[0] - direction = 1 if increasing else -1 - for stamp, now in enumerate([0., .3, .6, 1., 2., 3., 4., 5.]): - # Finish the preceding direction, reverse, and settle at the initial - # reading. Net displacement is zero, but requested travel is real. - feedback = original - direction*([0., 26., 18., 10., 0., 0., 0., 0.][stamp]) - assert observe(motion, safety, now, feedback, stamp).safe - assert motion.feedback_motion_complete() and motion.arrived(now) - - -@pytest.mark.parametrize("increasing", [False, True]) -@pytest.mark.parametrize("opposite_only", [False, True]) -def test_no_motion_and_only_opposite_motion_still_stop(profile, increasing, opposite_only): - motion, safety = segment(profile, increasing=increasing), SafetyPolicy(profile) - direction = 1 if increasing else -1 - first_expected = None - for stamp in range(240): - now = stamp/30 - excursion = min(26., now*10) if opposite_only else 0. - result = observe(motion, safety, now, motion.start_feedback[0]-direction*excursion, stamp) - if motion.motion_expected and first_expected is None: - first_expected = now - assert not motion.feedback_motion_complete() - assert not motion.arrived(now) - if not result.safe: - assert result.code == "mechanical_stall" - assert now-first_expected == pytest.approx(profile.acquisition.stall_timeout_seconds, abs=1/30) - break - else: - pytest.fail("the feedback turning point restarted the stall timeout") - - -def test_duplicate_feedback_cannot_supply_a_turning_point(profile): - motion, safety = segment(profile), SafetyPolicy(profile) - assert observe(motion, safety, 0., 221., 1).safe - assert observe(motion, safety, .3, 247., 1).safe - assert observe(motion, safety, 2., 221., 2).safe - assert not motion.feedback_motion_complete() - - -@pytest.mark.parametrize("mapped,reference", [(True, None), (False, 3.)]) -def test_absolute_travel_and_measured_return_contracts_are_preserved(profile, mapped, reference): - profile = replace(profile, acquisition=replace(profile.acquisition, - feedback_travel_matches_command=mapped)) - motion = segment(profile, references=() if reference is None else ((0, reference),)) - safety = SafetyPolicy(profile) - for stamp, value in enumerate([221., 247., 240., 230., 221.]): - observe(motion, safety, stamp*.5, value, stamp) - assert not motion.feedback_motion_complete() - expected = 221.-32.*.8 if mapped else reference - assert motion.goals[0].end_feedback == pytest.approx(expected) - - -def test_other_channel_motion_does_not_release_a_stalled_channel(profile): - first = segment(profile) - target = list(first.command.target) - target[6] = 32. - motion = MotionExecution(profile, replace(first.command, target=tuple(target)), - initial_command=first.start, initial_feedback=first.start_feedback, now=0., identity="group") - safety = SafetyPolicy(profile) - for stamp in range(240): - now = stamp/30 - command = motion.sample(now) - feedback = list(motion.start_feedback) - feedback[0] = 247. if .1 < now < .5 else 221. - motion.observe(feedback, stamp=stamp, now=now) - result = safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=motion.motion_expected, motion_id=motion.identity, motion_goals=motion.goals)) - assert not motion.arrived(now) - if not result.safe: - assert result.code == "mechanical_stall" and "channel=6" in result.details - break - else: - pytest.fail("one moving channel released the whole group") diff --git a/src/linkerhand_calibration/test/test_final_image_holdout.py b/src/linkerhand_calibration/test/test_final_image_holdout.py deleted file mode 100644 index 6600365..0000000 --- a/src/linkerhand_calibration/test/test_final_image_holdout.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Final pixels are independent of the intermediate reconstructed Tag poses.""" - -from dataclasses import replace -import json - -import cv2 -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points -from linkerhand_calibration.core.urdf.image_acceptance import TagImageObservation, validate_serialized_image_holdout -from linkerhand_calibration.runtime.artifacts.publisher import FrozenTagArtifactValidator -from test_frozen_tag_replay import fixture - - -def independent_pixels(observations): - camera = ((1200., 0., 640.), (0., 1200., 480.), (0., 0., 1.)) - # Distinct camera view; the generator starts from independent physical poses. - view = np.eye(4) - view[:3, :3] = Rotation.from_euler("xyz", (.08, -.1, .04)).as_matrix() - view[:3, 3] = (.02, -.03, .5) - images = [] - for row in observations: - pose = view @ np.asarray(row.common_from_tag) - corners, _ = cv2.projectPoints(square_object_points(.016), Rotation.from_matrix(pose[:3, :3]).as_rotvec(), - pose[:3, 3], np.asarray(camera), np.zeros(5)) - images.append(TagImageObservation(row.sample_id, row.role, row.cycle, row.sdk_values, - tuple(sorted(row.sdk_directions_by_joint.items())), "independent_view", .016, - tuple(map(tuple, corners.reshape(4, 2))), camera, tuple(map(tuple, view)))) - return tuple(images) - - -@pytest.fixture -def scene(tmp_path): - source, plan, base, _, mounts, holdout, payload = fixture(tmp_path) - written = plan.write(source, tmp_path / "corrected.urdf") - images = independent_pixels(holdout) - kwargs = dict(corrected_urdf=written, payload=payload, common_from_base=base, installations=mounts, - observations=images, independent_observations=holdout, - required_roles=("a", "b"), input_kind="feedback", holdout_cycle=3, minimum_samples=40) - return source, plan, holdout, kwargs - - -def test_written_json_urdf_predict_original_corners_without_pose_refitting(scene): - *_, kwargs = scene - report = validate_serialized_image_holdout(**kwargs) - assert set(report) == {"a", "b"} - assert max(value["maximum_px"] for value in report.values()) < 1e-6 - - -@pytest.mark.parametrize("mutation", ["pixels", "camera", "extrinsics", "size", "overlap", "duplicate", "missing"]) -def test_wrong_raw_pixels_or_camera_cannot_hide_behind_good_3d_poses(scene, mutation): - *_, kwargs = scene - images = kwargs["observations"] - row = images[0] - if mutation == "pixels": - row = replace(row, corners_xy=tuple((x+8, y) for x, y in row.corners_xy)) - elif mutation == "camera": - camera = np.asarray(row.camera_matrix).copy(); camera[0, 0] *= 1.2 - row = replace(row, camera_matrix=tuple(map(tuple, camera))) - elif mutation == "extrinsics": - camera = np.asarray(row.view_from_common).copy(); camera[0, 3] += .01 - row = replace(row, view_from_common=tuple(map(tuple, camera))) - elif mutation == "size": - row = replace(row, tag_size_m=.03) - elif mutation == "overlap": - row = replace(row, cycle=0) - elif mutation == "duplicate": - images += (row,) - elif mutation == "missing": - images = tuple(item for item in images if item.role == "a") - with pytest.raises(ValueError, match="final_image_holdout"): - validate_serialized_image_holdout(**{**kwargs, "observations": (row, *images[1:])}) - - -def test_publisher_requires_pixels_after_3d_passes(scene, tmp_path): - source, plan, holdout, kwargs = scene - calibration = tmp_path / "calibration.json" - calibration.write_text(json.dumps(kwargs["payload"])) - validator = FrozenTagArtifactValidator(kwargs["payload"]["profile_id"], source, plan.source_sha256, - plan.authorized_fields, plan.zero_offsets_rad, kwargs["common_from_base"], kwargs["installations"], - tuple(holdout), ("a", "b"), lambda _: None, require_image_holdout=True) - with pytest.raises(ValueError, match="final_image_holdout_missing"): - validator(calibration, kwargs["corrected_urdf"]) diff --git a/src/linkerhand_calibration/test/test_final_image_sources.py b/src/linkerhand_calibration/test/test_final_image_sources.py deleted file mode 100644 index 1b20a84..0000000 --- a/src/linkerhand_calibration/test/test_final_image_sources.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Actual camera metadata/corners bind to the configured protected file.""" - -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.geometry.extrinsics import transform_matrix -from linkerhand_calibration.core.urdf.tag_acceptance import TagHoldout -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.runtime.artifacts.image_evidence import prepare_image_holdout - - -def scene(): - config = load_product_config(Path(__file__).resolve().parents[1]/"config/o6_right_product.yaml", check_can=False) - raw = json.loads(gzip.decompress((Path(__file__).parent/"fixtures/o6_final_image_sources_160040.json.gz").read_bytes())) - assert raw["source_raw_sha256"] == "57ebe3d1987e14ca48911c8db1dddb680133d3edf436e59da7677bbf6931a236" - profile = config.calibration_contract.typed_profile - # This immutable historical capture used 16 mm Tags. Bind its original - # measurement contract instead of relabeling pixels with today's 16.5 mm - # product setting; mismatch rejection remains part of the tests below. - assert {tag['tag_size_m'] for frame in raw['frames'] for tag in frame['roles'].values()} == {.016} - profile = replace(profile, vision=replace(profile.vision, - views=tuple(replace(view, tags=tuple(replace(tag, size_m=.016) for tag in view.tags)) - for view in profile.vision.views))) - config = replace(config, calibration_contract=replace(config.calibration_contract, declarative=profile)) - observations = [] - for row in raw["samples"]: - role = profile.measurement.measurements[row["joint"]].child_role - pose = row["child_pose_common"] - observations.append(TagHoldout(f"{row['view']}:{row['image_stamp_ns']}", role, 3, - tuple(row["state_u8"]), {}, tuple(map(tuple, transform_matrix(pose["translation_xyz_m"], pose["quaternion_xyzw"]))))) - return config, [*raw["models"], *raw["frames"]], observations - - -def test_raw_pixels_bind_without_using_intermediate_tag_poses(): - config, records, observations = scene() - images = prepare_image_holdout(config.calibration_contract.typed_profile, records, observations, - camera_extrinsics_file=config.camera_extrinsics, - protected_inputs={"camera_extrinsics_sha256": config.camera_extrinsics_sha256}) - assert len(images) == len(observations) == 2 - assert {image.view for image in images} == {"top", "front"} - assert all(len(image.corners_xy) == 4 for image in images) - - -@pytest.mark.parametrize("mutation", ["missing", "intrinsics", "matrix", "size", "duplicate", "file_hash"]) -def test_missing_or_changed_camera_identity_cannot_enter_final_check(mutation): - config, records, observations = scene() - records = deepcopy(records) - matrix = next(row for row in records if row.get("kind") == "rectified_camera_model" and row["view"] == "top") - frame = next(row for row in records if row.get("kind") == "pnp_candidate_frame" and row["view"] == "top") - digest = config.camera_extrinsics_sha256 - if mutation == "missing": records.remove(frame) - elif mutation == "intrinsics": matrix["raw_k"][0] += 10 - elif mutation == "matrix": frame["camera_matrix"][0][0] += 10 - elif mutation == "size": next(iter(frame["roles"].values()))["tag_size_m"] = .02 - elif mutation == "duplicate": records.append({**frame, "camera_matrix": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}) - else: digest = "f"*64 - with pytest.raises(ValueError, match="final_image"): - prepare_image_holdout(config.calibration_contract.typed_profile, records, observations, - camera_extrinsics_file=config.camera_extrinsics, protected_inputs={"camera_extrinsics_sha256": digest}) diff --git a/src/linkerhand_calibration/test/test_finalization_worker.py b/src/linkerhand_calibration/test/test_finalization_worker.py deleted file mode 100644 index ff861ba..0000000 --- a/src/linkerhand_calibration/test/test_finalization_worker.py +++ /dev/null @@ -1,58 +0,0 @@ -from threading import Event - -import pytest - -from linkerhand_calibration.runtime.artifacts.worker import FinalizationWorker - - -def test_fit_does_not_block_control_and_abort_cannot_publish(): - entered, release, finished = Event(), Event(), Event() - def fit(*, publish, cancelled): - assert not publish - entered.set() - assert release.wait(2) - assert cancelled() - finished.set() - return "not publishable" - worker = FinalizationWorker() - commits = [] - try: - worker.start(fit) - assert entered.wait(2) - # A deterministic blocked fit must not block any controller poll. - for _ in range(100): - assert worker.finish_if_ready(commits.append) is None - worker.cancel() - release.set() - assert finished.wait(2) - assert worker.finish_if_ready(commits.append) is None - assert commits == [] - finally: - release.set() - worker.close() - - -def test_success_commits_once_and_failure_never_retries(): - for fail in (False, True): - worker = FinalizationWorker() - commits = [] - try: - def fit(**kwargs): - assert kwargs["publish"] is False - return ("json", "fit", "staged") - worker.start(fit) - worker._future.result(timeout=2) - def commit(value): - commits.append(value) - if fail: - raise ValueError("invalid staged hash") - if fail: - with pytest.raises(ValueError, match="hash"): - worker.finish_if_ready(commit) - else: - assert worker.finish_if_ready(commit) == ("json", "fit", "staged") - assert worker.finish_if_ready(commit) is None - assert len(commits) == 1 - finally: - worker.close() - diff --git a/src/linkerhand_calibration/test/test_finger_chain_diagnostic.py b/src/linkerhand_calibration/test/test_finger_chain_diagnostic.py deleted file mode 100644 index a373676..0000000 --- a/src/linkerhand_calibration/test/test_finger_chain_diagnostic.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Three-stage known-truth recovery without frozen upstream geometry.""" -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.finger_chain_diagnostic import ( - FingerChainImages, chain_candidate_seeds) -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points - - -@pytest.fixture(scope='module') -def chain_fit(): - return make_chain() - - -def make_chain(*, parallel=False, full_parallel=False, stage_changes=False): - matrix = np.array([[1600., 0, 640.], [0, 1600., 480.], [0, 0, 1.]]) - parent = Rotation.from_euler('xy', [8., -12.], degrees=True) - origin = np.array([.01, .01, .5]) - installation = Rotation.from_euler('xy', [5., 8.], degrees=True) - translation = np.array([.005, -.04, .003]) - axes = np.array([[.04, -.02, 1.], [-.02, .01, 1.], [0., 0., 1.]]) - axes /= np.linalg.norm(axes, axis=1)[:, None] - if parallel: - axes[2] = axes[1] - if full_parallel: - axes[:] = axes[1] - pivots = np.array([[0., .06, 0.], [0., .015, 0.], [0., -.025, 0.]]) - objects = square_object_points(.016) - rows, phases = [], [] - for phase in range(3): - for theta in np.linspace(0., 1.3, 12): - turn = Rotation.from_rotvec(axes[phase]*theta) - proximal, distal = objects, installation.apply(objects)+translation - if phase < 2: - proximal = turn.apply(proximal-pivots[phase])+pivots[phase] - distal = turn.apply(distal-pivots[phase])+pivots[phase] - tags = {} - for role, points in [('parent', proximal), ('child', distal)]: - camera = parent.apply(points)+origin - if stage_changes: - camera = Rotation.from_rotvec([phase*.006, phase*.004, 0.]).apply(camera) - camera += [phase*.0003, 0., phase*.0004] - projected = camera@matrix.T - tags[role] = dict(tag_size_m=.016, - corners_xy=(projected[:, :2]/projected[:, 2:]).tolist()) - rows.append(dict(image_stamp_ns=len(rows)+1, camera_matrix=matrix.tolist(), tags=tags)) - phases.append(phase) - vector = pivots[2]-pivots[1] - distance = np.linalg.norm(vector-axes[1]*(vector@axes[1])) if parallel else None - proximal_vector = pivots[1]-pivots[0] - proximal_distance = (np.linalg.norm(proximal_vector-axes[0]*(proximal_vector@axes[0])) - if full_parallel else None) - bundle = FingerChainImages(rows, phases, ('parent', 'child'), pip_dip_distance_m=distance, - mcp_pip_distance_m=proximal_distance, - independent_stage_references=stage_changes) - fit = bundle.fit((0,)*6) - assert fit.success - return bundle, fit, axes, pivots - - -def test_full_chain_recovers_known_geometry(chain_fit): - bundle, fit, axes, pivots = chain_fit - assert np.max(np.abs(fit.fun)) < 1e-4 - physical = bundle.physical_geometry(fit.x[:24]) - assert np.all(np.abs(np.sum(physical[:, :3]*axes, axis=1)) > .99999) - pivots = pivots-axes*np.sum(axes*pivots, axis=1)[:, None] - assert np.max(np.linalg.norm(physical[:, 3:]-pivots, axis=1)) < 1e-5 - - -def test_chain_covariance_marginalizes_all_angles(chain_fit): - bundle, fit, *_ = chain_fit - jac = fit.jac*bundle.scale[None, :] - expected = np.linalg.inv(jac.T@jac)[:24, :24]*.03**2 - expected *= bundle.scale[:24, None]*bundle.scale[None, :24] - assert np.allclose(bundle.covariance(fit), expected, atol=1e-10, rtol=1e-4) - assert np.all(np.isfinite(bundle.bounds(fit))) - - -def test_validation_does_not_refit_chain_geometry(chain_fit): - bundle, fit, *_ = chain_fit - previous = fit.x.copy() - residual, _ = bundle.validate(fit, bundle) - assert np.max(np.abs(residual)) < 1e-4 - assert np.array_equal(previous, fit.x) - - -def test_cartesian_seed_enumeration_and_phase_contract(chain_fit): - bundle, *_ = chain_fit - assert len(set(chain_candidate_seeds())) == 64 - with pytest.raises(ValueError, match='three_phases_required'): - FingerChainImages(bundle.rows, np.zeros(bundle.count), ('parent', 'child')) - - -def test_every_phase_tag_branch_contributes_to_initial_state(chain_fit): - original, *_ = chain_fit - bundle = FingerChainImages(original.rows, original.phases, ('parent', 'child')) - baseline = bundle.initialize((0,)*6) - for index in range(6): - branches = [0]*6 - branches[index] = 1 - alternate = bundle.initialize(branches) - assert np.linalg.norm(alternate-baseline) > 1e-6 - - -def test_validation_exposes_held_parent_drift(chain_fit): - import copy - bundle, fit, *_ = chain_fit - rows = copy.deepcopy(bundle.rows) - for row, phase in zip(rows, bundle.phases): - if phase == 2: - row['tags']['parent']['corners_xy'] = ( - np.asarray(row['tags']['parent']['corners_xy'])+[4., 0.]).tolist() - other = FingerChainImages(rows, bundle.phases, ('parent', 'child')) - residuals, _ = bundle.validate(fit, other) - errors = np.sqrt(np.mean(np.sum(residuals.reshape(-1, 2, 4, 2)**2, axis=3), axis=2)) - assert np.min(errors[bundle.phases == 2, 0]) > 3.99 - - -def test_parallel_chain_estimates_shared_axis_and_propagates_covariance(): - bundle, fit, axes, pivots = make_chain(parallel=True) - assert bundle.static_width == 21 - assert np.max(np.abs(fit.fun)) < 1e-4 - geometry = bundle.physical_geometry(fit.x[:21]) - assert abs(geometry[1, :3]@geometry[2, :3]) > 1-1e-12 - assert np.all(np.abs(np.sum(geometry[:, :3]*axes, axis=1)) > .99999) - vector = geometry[2, 3:]-geometry[1, 3:] - assert abs(np.linalg.norm(vector)-bundle.pip_dip_distance_m) < 1e-9 - jac = fit.jac*bundle.scale[None, :] - expected = np.linalg.inv(jac.T@jac)[:21, :21]*.03**2 - expected *= bundle.scale[:21, None]*bundle.scale[None, :21] - assert np.allclose(bundle.covariance(fit), expected, atol=1e-10, rtol=1e-4) - assert np.all(np.isfinite(bundle.bounds(fit))) - residuals, _ = bundle.validate(fit, bundle) - assert np.max(np.abs(residuals)) < 1e-4 - - -def test_full_parallel_chain_has_no_redundant_geometry_parameters(): - bundle, fit, axes, _ = make_chain(parallel=True, full_parallel=True) - assert bundle.static_width == 18 - assert np.max(np.abs(fit.fun)) < 1e-4 - geometry = bundle.physical_geometry(fit.x[:18]) - assert np.all(np.abs(np.sum(geometry[:, :3]*axes, axis=1)) > .99999) - for phase in (1, 2): - assert abs(np.linalg.norm(geometry[phase, 3:]-geometry[phase-1, 3:]) - -bundle.distances[phase]) < 1e-9 - assert np.all(np.isfinite(bundle.bounds(fit))) - - -def test_stage_references_are_observed_and_uncertainty_is_marginalized(): - bundle, fit, axes, _ = make_chain(parallel=True, full_parallel=True, stage_changes=True) - assert bundle.static_width == 30 - assert len(bundle.angle_columns) == bundle.count-2 - assert np.max(np.abs(fit.fun)) < 1e-4 - geometry = bundle.physical_geometry(fit.x[:bundle.static_width]) - assert np.all(np.abs(np.sum(geometry[:, :3]*axes, axis=1)) > .99999) - width = bundle.static_width - jac = fit.jac*bundle.scale[None, :] - expected = np.linalg.inv(jac.T@jac)[:width, :width]*.03**2 - expected *= bundle.scale[:width, None]*bundle.scale[None, :width] - assert np.allclose(bundle.covariance(fit), expected, atol=1e-10, rtol=1e-4) - assert np.all(np.isfinite(bundle.bounds(fit))) - - -def test_common_tag_scale_diagnostic_recovers_known_print_size(): - import copy - truth, _, _, _ = make_chain(parallel=True, full_parallel=True) - rows = copy.deepcopy(truth.rows) - for row in rows: - for tag in row['tags'].values(): - tag['tag_size_m'] = .0165 # Images were generated using .016 m. - bundle = FingerChainImages(rows, truth.phases, ('parent', 'child'), - pip_dip_distance_m=truth.distances[2], mcp_pip_distance_m=truth.distances[1], - estimate_tag_scale=True) - fit = bundle.fit((0,)*6) - assert fit.success and np.max(np.abs(fit.fun)) < 1e-4 - assert abs(np.exp(fit.x[bundle.tag_scale_column])*.0165-.016) < 1e-6 - assert np.all(np.isfinite(bundle.bounds(fit))) - with pytest.raises(ValueError, match='requires_two_structural_lengths'): - FingerChainImages(rows, truth.phases, ('parent', 'child'), estimate_tag_scale=True) - - -def test_continuous_angles_do_not_create_false_motion_bins(): - from linkerhand_calibration.core.geometry.tag_pose.finger_chain_diagnostic import continuous_phase_angles - phases = [0, 0, 0, 1, 1, 1] - angles = [.1, .2+2*np.pi, .3, -.1, -.2-2*np.pi, -.3] - assert np.allclose(continuous_phase_angles(angles, phases), [.1, .2, .3, -.1, -.2, -.3]) - - -def test_output_angle_comparison_removes_sign_and_wrap_not_zero(): - from linkerhand_calibration.core.geometry.tag_pose.finger_chain_diagnostic import compare_chain_output_angles - geometry = np.tile([0., 0., 1., 0., 0., 0.], (3, 1)) - other = geometry.copy() - other[1, :3] *= -1 - phases = np.repeat(np.arange(3), 3) - angles = np.tile([.1, .2, .3], 3) - alternative = angles.copy()+.04 - alternative[3:6] *= -1 - alternative[1] += 2*np.pi - differences = compare_chain_output_angles(geometry, angles, other, alternative, phases) - assert np.allclose(differences, .04) - with pytest.raises(ValueError, match='comparison_invalid'): - compare_chain_output_angles(geometry, angles[:-1], other, alternative, phases) - - -def test_validation_angle_covariance_keeps_shared_uncertainty(chain_fit): - import copy - from types import SimpleNamespace - bundle, fit, *_ = chain_fit - rows = copy.deepcopy(bundle.rows) - for row in rows: - row['image_stamp_ns'] += 1000 - other = FingerChainImages(rows, bundle.phases, ('parent', 'child')) - _, angles = bundle.validate(fit, other) - previous = fit.x.copy() - covariance = bundle.validation_angle_covariance(fit, other, angles) - assert np.allclose(covariance, covariance.T) - assert np.linalg.eigvalsh(covariance)[0] > 0 - assert np.max(np.abs(covariance-np.diag(np.diag(covariance)))) > 1e-9 - # Independent finite difference: refit only the held-out angles as each - # frozen training parameter changes, never re-estimate the training fit. - sensitivity = np.empty((other.count, bundle.static_width)) - for column in range(bundle.static_width): - delta = 1e-4*bundle.scale[column] - perturbed = [] - for sign in (1, -1): - vector = fit.x.copy() - vector[column] += sign*delta - _, values = bundle.validate(SimpleNamespace(x=vector), other) - perturbed.append(values) - sensitivity[:, column] = (perturbed[0]-perturbed[1])/(2*delta) - propagated = sensitivity@bundle.covariance(fit)@sensitivity.T - remainder = covariance-propagated - assert np.all(np.diag(remainder) > 0) - assert np.max(np.abs(remainder-np.diag(np.diag(remainder)))) < 1e-9 - assert np.array_equal(previous, fit.x) - with pytest.raises(ValueError, match='requires_independent_images'): - bundle.validation_angle_covariance(fit, bundle, angles) - - -@pytest.mark.parametrize('field', ['matrix', 'objects', 'roles']) -def test_validation_refuses_different_pixel_geometry(chain_fit, field): - import copy - bundle, fit, *_ = chain_fit - other = copy.copy(bundle) - value = getattr(other, field) - setattr(other, field, tuple(reversed(value)) if field == 'roles' else value*1.001) - with pytest.raises(ValueError, match='validation_geometry_changed'): - bundle.validate(fit, other) - with pytest.raises(ValueError, match='validation_geometry_changed'): - bundle.validation_angle_covariance(fit, other, np.zeros(other.count)) diff --git a/src/linkerhand_calibration/test/test_fixed_reference_pose.py b/src/linkerhand_calibration/test/test_fixed_reference_pose.py deleted file mode 100644 index e927bbd..0000000 --- a/src/linkerhand_calibration/test/test_fixed_reference_pose.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Stationarity is checked against live corners, not changing IPPE rankings.""" - -from concurrent.futures import ThreadPoolExecutor -from dataclasses import replace -import json -from pathlib import Path -from threading import Event - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.pnp import SquareTagPose, SquareTagPoseTracker -from linkerhand_calibration.core.geometry.tag_pose import tracking -from linkerhand_calibration.core.geometry.tag_pose.branch_confirmation import BranchState -from linkerhand_calibration.core.geometry.tag_pose.fixed_reference import FixedTagReference -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.pose_evidence import reference_branch_revisions -from test_observation_capture import frame, make_capture - - -REAL = json.loads((Path(__file__).parent / 'fixtures' / - 'o6_front_base_20260914_branch_events.json').read_text()) -ROLE = REAL['tag_role'] -STEP_NS = 40_000_000 - - -def recorded_reference(): - pose = REAL['reference_pose'] - return FixedTagReference(SquareTagPose(tuple(pose['rotation_xyzw']), - tuple(pose['translation_xyz_m']), 0.), 1) - - -def seeded_tracker(reference, stamp): - # Successful preparation frames were not logged. Start at the recorded - # frozen reference boundary; this is explicitly not an end-to-end replay. - tracker = SquareTagPoseTracker() - tracker._branches.states[ROLE] = BranchState(observed_ns=stamp, pose=reference.pose, - revision=reference.branch_revision, frozen=True, status='tracking', reason='') - return tracker - - -def observe(tracker, stamp, corners, reference): - return tracker.estimate(ROLE, corners, tag_size_m=REAL['tag_size_m'], - camera_matrix=REAL['camera_matrix'], stamp_ns=stamp, fixed_reference=reference) - - -def test_recorded_id0_conflict_is_resolved_only_with_declared_stationarity(): - reference = recorded_reference() - first = REAL['events'][0]['image_stamp_ns'] - moving_tracker = seeded_tracker(reference, first - STEP_NS) - fixed_tracker = seeded_tracker(reference, first - STEP_NS) - errors = [] - for event in REAL['events']: - stamp = event['image_stamp_ns'] - corners = event['pnp_observation_evidence']['corners_xy'] - rejected, reason = observe(moving_tracker, stamp, corners, None) - assert rejected is None and reason == 'pose_branch_ambiguous' - pose, reason = observe(fixed_tracker, stamp, corners, reference) - assert reason == '' and pose is not None - assert pose.quaternion_xyzw == reference.pose.quaternion_xyzw - assert pose.translation_xyz_m == reference.pose.translation_xyz_m - errors.append(pose.reprojection_error_px) - candidates, diagnostics, corrections = fixed_tracker.observation_snapshot(ROLE, stamp) - assert len(candidates) == 2 and corrections == 0 - assert diagnostics['branch_revision'] == 1 and diagnostics['branch_frozen'] - assert diagnostics['fixed_reference_verified'] is True - assert max(errors) == pytest.approx(.665772, abs=1e-5) - assert not moving_tracker._branches.states[ROLE].conflict - assert not fixed_tracker._branches.states[ROLE].conflict - - -@pytest.mark.parametrize('disturbance', ['translation', 'one_bad_corner', 'invalid_corners']) -def test_bad_fixed_images_are_never_samples_and_cannot_relocate_the_base(disturbance): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - good = np.asarray(REAL['reference_corners_xy']) - bad = good.copy() - if disturbance == 'translation': - bad += [6., 0.] - elif disturbance == 'one_bad_corner': - bad[0] += [0., 4.] - else: - bad[0] = np.nan - for index in range(1, 10): - pose, reason = observe(tracker, 1_000_000_000 + index*STEP_NS, bad, reference) - assert pose is None - assert reason == 'fixed_reference_pose_unverified' - assert not tracker.freeze_roles({ROLE: 1}) - # Image inconsistency is missing measurement evidence, not proof of a - # different physical branch. Real corner drift has its own capture gate. - assert not tracker._branches.states[ROLE].conflict - assert observe(tracker, 2_000_000_000, good, reference) == (None, 'pose_branch_unconfirmed') - assert tracker._branches.states[ROLE].pose == reference.pose - assert tracker.branch_correction_counts == {} - - -def test_short_bad_image_discards_sample_then_recovers_without_refreezing(): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - corners = np.asarray(REAL['reference_corners_xy']) - assert observe(tracker, 1_040_000_000, corners + 6, reference)[0] is None - assert not tracker.freeze_roles({ROLE: 1}) # Cannot cache the rejected base. - for index in range(1, 6): - pose, reason = observe(tracker, 1_040_000_000 + index*STEP_NS, corners, reference) - if index < 5: - assert pose is None and reason == 'pose_branch_unconfirmed' - assert not tracker.freeze_roles({ROLE: 1}) - else: - assert pose is not None and not reason - assert pose.translation_xyz_m == reference.pose.translation_xyz_m - assert tracker.freeze_roles({ROLE: 1}) - - -def test_recorded_161416_images_remain_rejected_without_latching_a_branch_conflict(): - data = json.loads((Path(__file__).parent / 'fixtures' / - 'o6_front_base_20260914_161416_quality_events.json').read_text()) - payload = data['reference_pose'] - reference = FixedTagReference(SquareTagPose(tuple(payload['rotation_xyzw']), - tuple(payload['translation_xyz_m']), 0.), 1) - first = data['events'][0]['image_stamp_ns'] - tracker = seeded_tracker(reference, first - STEP_NS) - for event in data['events']: - pose, reason = tracker.estimate(ROLE, event['pnp_observation_evidence']['corners_xy'], - tag_size_m=data['tag_size_m'], camera_matrix=data['camera_matrix'], - stamp_ns=event['image_stamp_ns'], fixed_reference=reference) - assert pose is None and reason == 'fixed_reference_pose_unverified' - error = tracker.observation_snapshot(ROLE, event['image_stamp_ns'])[1]['fixed_reference_reprojection_error_px'] - assert error > 1.5 - assert error == pytest.approx(event['pnp_observation_evidence']['candidate_diagnostics'][ - 'fixed_reference_reprojection_error_px'], abs=1e-10) - assert not tracker.freeze_roles({ROLE: 1}) - assert tracker._branches.states[ROLE].pose == reference.pose - assert not tracker._branches.states[ROLE].conflict - assert tracker.branch_correction_counts == {} - - -def test_interrupted_recovery_never_counts_failed_or_duplicate_frames(): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - corners = np.asarray(REAL['reference_corners_xy']) - for stamp, bad in ((1, True), (2, False), (3, False), (4, False), (5, True)): - assert observe(tracker, 1_000_000_000 + stamp*STEP_NS, corners + (6 if bad else 0), reference)[0] is None - for _ in range(6): - assert observe(tracker, 1_240_000_000, corners, reference)[0] is None - for index in range(7, 11): - pose, _ = observe(tracker, 1_000_000_000 + index*STEP_NS, corners, reference) - assert (pose is not None) is (index == 10) - - -def test_long_absence_reconfirms_same_frozen_pose_and_never_counts_bad_frames(): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - corners = np.asarray(REAL['reference_corners_xy']) - assert observe(tracker, 7_000_000_000, corners + 6, reference)[0] is None - for index in range(1, 6): - pose, reason = observe(tracker, 7_000_000_000 + index*STEP_NS, corners, reference) - if index < 5: - assert pose is None and reason == 'pose_branch_unconfirmed' - else: - assert pose is not None and not reason - assert tracker._branches.states[ROLE].revision == 1 - - -@pytest.mark.parametrize('revision', [True, 2]) -def test_wrong_reference_version_is_rejected(revision): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - assert observe(tracker, 1_040_000_000, REAL['reference_corners_xy'], - replace(reference, branch_revision=revision)) == (None, 'pose_branch_reference_invalid') - - -@pytest.mark.parametrize('operation', ['reset', 'newer_image']) -def test_late_fixed_projection_cannot_commit_across_reset_or_a_newer_image(monkeypatch, operation): - reference = recorded_reference() - tracker = seeded_tracker(reference, 1_000_000_000) - entered, release = Event(), Event() - project = tracking.fixed_pose_reprojection_error - calls = 0 - - def blocked(*args, **kwargs): - nonlocal calls - calls += 1 - if calls == 1: - entered.set() - assert release.wait(3) - return project(*args, **kwargs) - - monkeypatch.setattr(tracking, 'fixed_pose_reprojection_error', blocked) - with ThreadPoolExecutor(1) as pool: - pending = pool.submit(observe, tracker, 1_040_000_000, REAL['reference_corners_xy'], reference) - try: - assert entered.wait(3) - if operation == 'reset': - tracker.reset() - else: - assert observe(tracker, 1_080_000_000, REAL['reference_corners_xy'], reference)[0] - finally: - release.set() - pose, reason = pending.result(timeout=3) - assert pose is None - if operation == 'reset': - assert reason == 'pose_observation_superseded' - assert not tracker.last_candidate_diagnostics_by_role - else: - assert reason == 'pose_observation_not_new' - assert tracker.observation_snapshot(ROLE, 1_080_000_000)[1]['branch_status'] == 'tracking' - - -def test_live_fixed_evidence_keeps_corners_and_frozen_geometry_through_zero_and_sweep(): - profile, lock, capture = make_capture() - capture.profile = profile = replace(profile, measurement=replace(profile.measurement, - candidate_selection_tasks=frozenset({'thumb_pitch_dip_front'}))) - lock.begin_session() - lock.start_locking() - for stamp in range(1, 15): - capture.consume(frame(profile, stamp)) - original = capture.locked_reference('front') - zero = profile.motion.joint_zero_references['rh_thumb_cmc_pitch'] - for stamp, phase in ((20, 'joint_zero'), (21, 'sweep')): - motion = MotionCommand(phase, zero.command, 40, task_key=zero.task_key, - command_index=0, cycle=0, zero_joints=('rh_thumb_cmc_pitch',)) - rows, movement = capture.consume(frame(profile, stamp), motion) - assert movement is None - row = next(r for r in rows if r.get('joint') == 'rh_thumb_cmc_pitch') - parent, child = (row['pnp_observation_evidence'][side] for side in ('parent', 'child')) - assert parent['pose_source'] == 'verified_fixed_reference' - assert parent['corners_xy'] and parent['reprojection_valid_candidates'] - assert child['pose_source'] == 'current_image' # Moving role is never constrained. - assert parent['selected_pose']['quaternion_xyzw'] == list(original[0].quaternion_xyzw) - assert reference_branch_revisions([row], profile=profile)[('front', 'front_base')] == original[1] - candidate = next(r for r in rows if r['kind'] == 'pnp_candidate_frame') - assert candidate['roles']['front_base']['locked_reference'] is True - assert candidate['roles']['front_base']['corners_xy'] == parent['corners_xy'] - assert capture.locked_reference('front') == original diff --git a/src/linkerhand_calibration/test/test_frozen_candidate_selection.py b/src/linkerhand_calibration/test/test_frozen_candidate_selection.py deleted file mode 100644 index 3c80a6f..0000000 --- a/src/linkerhand_calibration/test/test_frozen_candidate_selection.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Articulated scoring cannot exchange an online-frozen planar solution.""" - -from copy import deepcopy -import json - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.candidate_selection import resolve_chain_observations -from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION -from test_o12_observation_resolution import build_data, module - - -TASK = "thumb_mcp_dip_front" -PAIRS = {"thumb_mcp": module.THUMB_ROLES[:2], "thumb_dip": module.THUMB_ROLES[1:]} - - -def frozen_capture(monkeypatch): - """Use the existing independent articulated trajectory, recording its good branch.""" - rows = build_data(monkeypatch) - frames = {row["image_stamp_ns"]: row for row in rows if "roles" in row} - for frame in frames.values(): - frame["view"] = "front" - for role in module.THUMB_ROLES: - item = frame["roles"][role] - pose = module.solve_square_tag_ippe(item["corners_xy"])[-1] - item["selected"] = {"quaternion_xyzw": list(pose.quaternion_xyzw), - "translation_xyz_m": list(pose.translation_xyz_m), - "reprojection_error_px": pose.reprojection_error_px} - for row in rows: - if "joint" not in row: - continue - row["view"] = "front" - frame = frames[row["image_stamp_ns"]] - parent, child = (deepcopy(frame["roles"][role]["selected"]) for role in PAIRS[row["joint"]]) - quaternion, translation = module._relative(parent, child) - row.update(parent_pose_common=parent, child_pose_common=child, - relative_quaternion_xyzw=quaternion.tolist(), relative_translation_xyz_m=translation.tolist()) - row["pnp_observation_evidence"] = { - field: {"tag_role": role, "tag_id": index, "pose_source": "current_image", - "selected_pose": deepcopy(frame["roles"][role]["selected"]), - "candidate_diagnostics": {"observation_stamp_ns": row["image_stamp_ns"], - "branch_status": "tracking", "branch_revision": 1, "branch_frozen": True}} - for index, (field, role) in enumerate(zip(("parent", "child"), PAIRS[row["joint"]]))} - return [{"kind": "session_start", "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION}, *rows] - - -def resolve(rows, **kwargs): - return resolve_chain_observations(rows, task_key=TASK, role_pairs=PAIRS, - solve_pose=module.solve_square_tag_ippe, **kwargs) - - -def test_same_frozen_branch_preserves_training_and_holdout(monkeypatch): - rows = frozen_capture(monkeypatch) - before = deepcopy(rows) - result, report = resolve(rows) - assert rows == before - assert report["status"] == "resolved" - assert report["training_frames"] == 72 and report["holdout_frames"] == 24 - for original, resolved in zip(rows, result): - if "joint" in original: - assert np.allclose(original["relative_quaternion_xyzw"], resolved["relative_quaternion_xyzw"], atol=1e-12) - - -@pytest.mark.parametrize("cycle", [0, 3]) -@pytest.mark.parametrize("field", ["parent", "child"]) -def test_any_frozen_role_reselection_is_rejected_before_rewriting(monkeypatch, cycle, field): - rows = frozen_capture(monkeypatch) - row = next(row for row in rows if row.get("joint") == "thumb_dip" and row["cycle"] == cycle) - role = row["pnp_observation_evidence"][field]["tag_role"] - frame = next(item for item in rows if "roles" in item and item["image_stamp_ns"] == row["image_stamp_ns"]) - pose = row["pnp_observation_evidence"][field]["selected_pose"] - pose["quaternion_xyzw"] = (Rotation.from_euler("x", 10, degrees=True) - * Rotation.from_quat(pose["quaternion_xyzw"])).as_quat().tolist() - frame["roles"][role]["selected"] = deepcopy(pose) - # The same physical role can be the adjacent joint's child or parent. - for other in rows: - if other.get("image_stamp_ns") != row["image_stamp_ns"] or "joint" not in other: - continue - for item in other["pnp_observation_evidence"].values(): - if item["tag_role"] == role: - item["selected_pose"] = deepcopy(pose) - before = deepcopy(rows) - with pytest.raises(ValueError, match=rf"frozen_branch_reselection_forbidden:.*cycle={cycle}.*role={role}"): - resolve(rows) - assert rows == before - - -@pytest.mark.parametrize("mutation", ["missing", "not_frozen", "no_revision", "wrong_stamp", "role_changed", "candidate_pose_changed"]) -def test_incomplete_frozen_evidence_never_authorizes_reselection(monkeypatch, mutation): - rows = frozen_capture(monkeypatch) - row = next(row for row in rows if row.get("joint") == "thumb_dip") - item = row["pnp_observation_evidence"]["child"] - if mutation == "missing": - row.pop("pnp_observation_evidence") - elif mutation == "not_frozen": - item["candidate_diagnostics"]["branch_frozen"] = False - elif mutation == "no_revision": - item["candidate_diagnostics"].pop("branch_revision") - elif mutation == "wrong_stamp": - item["candidate_diagnostics"]["observation_stamp_ns"] += 1 - elif mutation == "role_changed": - item["tag_role"] = "another_tag" - else: - frame = next(frame for frame in rows if "roles" in frame and frame["image_stamp_ns"] == row["image_stamp_ns"]) - frame["roles"][item["tag_role"]]["selected"]["translation_xyz_m"][0] += .01 - before = deepcopy(rows) - with pytest.raises(ValueError, match="frozen_branch_"): - resolve(rows) - assert rows == before - - -def test_numerical_reconstruction_noise_is_not_a_branch_switch(monkeypatch): - rows = frozen_capture(monkeypatch) - for row in rows: - for item in row.get("pnp_observation_evidence", {}).values(): - pose = item["selected_pose"] - pose["quaternion_xyzw"] = (Rotation.from_rotvec([1e-9, 0, 0]) - * Rotation.from_quat(pose["quaternion_xyzw"])).as_quat().tolist() - pose["translation_xyz_m"][0] += 1e-10 - assert resolve(rows)[1]["status"] == "resolved" - - -def test_cached_fixed_pose_uses_reference_revision_without_fabricated_image_diagnostics(monkeypatch): - rows = frozen_capture(monkeypatch) - base_role = module.THUMB_ROLES[0] - for row in rows: - if "roles" in row: - row["roles"][base_role].update(locked_reference=True, corners_xy=None) - for item in row.get("pnp_observation_evidence", {}).values(): - if item["tag_role"] == base_role: - item.update(pose_source="cached_fixed_reference", reference_branch_revision=1, - candidate_diagnostics={}) - assert resolve(rows, fixed_roles=(base_role,))[1]["frozen_branches_preserved"] is True - - -def verified_fixed_capture(monkeypatch): - rows = frozen_capture(monkeypatch) - role = module.THUMB_ROLES[0] - frames = {row['image_stamp_ns']: row for row in rows if 'roles' in row} - # Give the stationary role independent pinhole image evidence; the - # existing moving-role fixture still supplies its articulated candidates. - half = .016 / 2 - points = np.array([[-half, half, 0], [half, half, 0], [half, -half, 0], [-half, -half, 0]]) - for frame in frames.values(): - item = frame['roles'][role] - pose = item['selected'] - xyz = Rotation.from_quat(pose['quaternion_xyzw']).apply(points) + pose['translation_xyz_m'] - pixels = xyz @ np.asarray(frame['camera_matrix']).T - item.update(locked_reference=True, corners_xy=(pixels[:, :2] / pixels[:, 2:]).tolist()) - pose['reprojection_error_px'] = 0. - for row in rows: - for item in row.get('pnp_observation_evidence', {}).values(): - if item['tag_role'] != role: - continue - data = frames[row['image_stamp_ns']]['roles'][role] - item.update(pose_source='verified_fixed_reference', reference_branch_revision=1, - corners_xy=deepcopy(data['corners_xy']), selected_pose=deepcopy(data['selected'])) - item['candidate_diagnostics']['fixed_reference_verified'] = True - return rows, role - - -def test_verified_fixed_pose_preserves_current_corners_and_frozen_training_and_holdout(monkeypatch): - rows, role = verified_fixed_capture(monkeypatch) - before = deepcopy(rows) - result, report = resolve(rows, fixed_roles=(role,)) - assert rows == before and report['frozen_branches_preserved'] - assert report['training_frames'] == 72 and report['holdout_frames'] == 24 - for first, second in zip(rows, result): - if 'joint' in first: - np.testing.assert_allclose(first['relative_quaternion_xyzw'], second['relative_quaternion_xyzw'], atol=1e-12) - - -@pytest.mark.parametrize('cycle', [0, 3]) -@pytest.mark.parametrize('mutation', ['unverified', 'revision', 'corners_changed', 'bad_projection', 'not_fixed']) -def test_fixed_pose_marker_cannot_bypass_live_evidence_checks(monkeypatch, cycle, mutation): - rows, role = verified_fixed_capture(monkeypatch) - row = next(row for row in rows if row.get('joint') == 'thumb_mcp' and row['cycle'] == cycle) - item = row['pnp_observation_evidence']['parent'] - frame = next(row2 for row2 in rows if 'roles' in row2 and row2['image_stamp_ns'] == row['image_stamp_ns']) - if mutation == 'unverified': - item['candidate_diagnostics']['fixed_reference_verified'] = False - elif mutation == 'revision': - item['reference_branch_revision'] += 1 - elif mutation in {'corners_changed', 'bad_projection'}: - item['corners_xy'][0][0] += 10 - if mutation == 'bad_projection': - frame['roles'][role]['corners_xy'] = deepcopy(item['corners_xy']) - with pytest.raises(ValueError, match='frozen_branch_|cached fixed reference'): - resolve(rows, fixed_roles=() if mutation == 'not_fixed' else (role,)) - - -def test_previous_frozen_policy_still_preserves_recorded_branches_when_read_offline(monkeypatch): - rows = frozen_capture(monkeypatch) - rows[0]['pose_tracking_policy_version'] = 'confirmed_branch_v1' - assert resolve(rows)[1]['frozen_branches_preserved'] - - -def test_old_direct_diagnostic_selection_remains_available(monkeypatch): - rows = build_data(monkeypatch) - result, report = resolve(rows) - assert report["changed_joint_rows"] > 0 - assert result != rows - - -@pytest.mark.parametrize("policy", [None, "old_branch_policy", POSE_TRACKING_POLICY_VERSION]) -def test_formal_finalizer_cannot_remove_header_to_bypass_frozen_branch_gate(monkeypatch, tmp_path, policy): - from pathlib import Path - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.artifacts import finalization - rows = build_data(monkeypatch) - if policy is not None: - rows.insert(0, {"kind": "session_start", "pose_tracking_policy_version": policy}) - original_resolver = finalization.resolve_chain_observations - monkeypatch.setattr(finalization, "resolve_chain_observations", - lambda records, **kwargs: original_resolver(records, solve_pose=module.solve_square_tag_ippe, **kwargs)) - monkeypatch.setattr(finalization, "fit_profile_calibration", lambda *args, **kwargs: pytest.fail("fit after branch rejection")) - profile = load_bundled_hand_profile("o12_right_16") - source = Path(__file__).resolve().parents[1]/"urdf/o12_right/linkerhand_o12_t3_right-0703.urdf" - directory = tmp_path / "session" - with pytest.raises(ValueError, match="frozen_branch_"): - finalization.finalize_profile_session(profile=profile, records=rows, - session_dir=directory, serial_number="TEST", source_urdf=source, protected_inputs={}) - report = json.loads((directory / "pose_selection_diagnostics.json").read_text()) - assert report[TASK]["publication_allowed"] is False - assert not list(directory.glob("*.urdf")) - assert not (tmp_path / "latest_passed").exists() diff --git a/src/linkerhand_calibration/test/test_frozen_chain_validation.py b/src/linkerhand_calibration/test/test_frozen_chain_validation.py deleted file mode 100644 index 0df06f9..0000000 --- a/src/linkerhand_calibration/test/test_frozen_chain_validation.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Independent forward prediction cannot absorb wrong commands or zero gauges.""" -from copy import deepcopy -from dataclasses import replace - -import numpy as np -import pytest - -from test_kinematic_chain_diagnostic import linked_fit -from linkerhand_calibration.core.domain.result import JointMapping -from linkerhand_calibration.core.geometry.tag_pose.frozen_chain_validation import FrozenChainCommands - - -@pytest.fixture(scope='module') -def frozen_fixture(linked_fit): - original, fit, _ = linked_fit - bundle = deepcopy(original) - tasks = ('mcp', 'pip', 'dip') - for row, phase in zip(bundle.rows, bundle.phases): - row.update(view='side', task_name=tasks[phase], cycle=0, - command_vector=[0, 0, 0], sample_phase='sweep') - increasing = (0., .5, 1.) - decreasing = (0., .55, 1.) - mappings = tuple(JointMapping(task, phase, 'command_u8', (0., 127., 255.), - increasing, increasing, decreasing) for phase, task in enumerate(tasks)) - shared = fit.x[:bundle.static_width].copy() - references = (.015, -.02, .007) - rows = [] - for phase, task in enumerate(tasks): - for direction in ('increasing', 'decreasing'): - for target in (0., 127., 255.): - row = deepcopy(bundle.rows[0]) - row.update(image_stamp_ns=10000+len(rows), task_name=task, cycle=3, - sample_phase='steady', direction=direction, steady_target=target, - command_vector=[0., 0., 0.]) - row['command_vector'][phase] = target - angle = mappings[phase].evaluate(target, direction)+references[phase] - pixels = bundle.project(shared, np.array([angle]), np.array([phase]))[0] - for role, corners in zip(bundle.roles, pixels.reshape(2, 4, 2)): - row['tags'][role]['corners_xy'] = corners.tolist() - rows.append(row) - def freeze(offsets=references, dependencies=(), first_knots=None): - curves = mappings if first_knots is None else ( - replace(mappings[0], knots=first_knots), *mappings[1:]) - return FrozenChainCommands(bundle, shared, curves, offsets, tasks, - dependency_images=dependencies) - return freeze, rows, bundle, shared - - -def test_frozen_directional_mapping_predicts_without_angle_fit(frozen_fixture, monkeypatch): - freeze, rows, bundle, shared = frozen_fixture - frozen = freeze() - previous = deepcopy(rows) - # The validator must not use either inverse-angle or geometric fitting. - def forbidden(*args, **kwargs): - raise AssertionError('validation attempted to refit') - monkeypatch.setattr(type(bundle), 'validate', forbidden) - monkeypatch.setattr(type(bundle), 'fit', forbidden) - result = frozen.evaluate(rows) - assert max(np.asarray(result.tag_rms_px).ravel()) < 1e-10 - assert rows == previous - assert result.predicted_angles_rad[1] != result.predicted_angles_rad[4] - - -def test_wrong_mapping_or_zero_remains_visible_in_pixels(frozen_fixture): - freeze, rows, _, _ = frozen_fixture - wrong = deepcopy(rows) - # The target changes, but actual pixels do not; inverse-angle fitting - # would hide this disagreement. Forward prediction must retain it. - wrong[1]['command_vector'][0] = wrong[1]['steady_target'] = 180. - assert max(np.asarray(freeze().evaluate(wrong).tag_rms_px).ravel()) > 3. - assert max(np.asarray(freeze(offsets=(.15, -.02, .007)).evaluate(rows).tag_rms_px).ravel()) > 3. - - -@pytest.mark.parametrize('change,reason', [ - (lambda r: r[0].update(cycle=0), 'independent_steady_cycle'), - (lambda r: r[0].update(sample_phase='sweep'), 'independent_steady_cycle'), - (lambda r: r[0].update(view='front'), 'view_changed'), - (lambda r: r[0].update(steady_target=1), 'steady_target_changed'), - (lambda r: r[0]['command_vector'].__setitem__(1, 10), 'held_commands_changed'), - (lambda r: r[0]['camera_matrix'][0].__setitem__(0, 1700.), 'camera_changed'), - (lambda r: r[0]['tags']['parent'].update(tag_size_m=.0165), 'tag_geometry_changed'), - (lambda r: r[0]['command_vector'].__setitem__(0, float('nan')), 'wire_command_invalid'), - (lambda r: r[0]['command_vector'].__setitem__(0, .5), 'wire_command_invalid'), - (lambda r: r[0]['tags']['parent'].update(corners_xy=[[0., 0.]]), 'pixels_invalid'), -]) -def test_frozen_validation_contracts(frozen_fixture, change, reason): - freeze, rows, _, _ = frozen_fixture - changed = deepcopy(rows) - change(changed) - with pytest.raises(ValueError, match=reason): - freeze().evaluate(changed) - - -def test_rejects_selection_leakage_duplicates_and_missing_direction(frozen_fixture): - freeze, rows, _, _ = frozen_fixture - with pytest.raises(ValueError, match='not_independent'): - freeze(dependencies=[rows[0]]).evaluate(rows) - with pytest.raises(ValueError, match='not_independent'): - freeze().evaluate([*rows, rows[0]]) - with pytest.raises(ValueError, match='directions_missing'): - freeze().evaluate([row for row in rows if row['direction'] == 'increasing']) - - -def test_snapshot_isolated_from_later_training_changes(frozen_fixture): - freeze, rows, bundle, shared = frozen_fixture - frozen = freeze() - previous = shared.copy() - shared[3] += .02 - try: - assert max(np.asarray(frozen.evaluate(rows).tag_rms_px).ravel()) < 1e-10 - finally: - shared[:] = previous - - -def test_stage_image_shift_is_not_registered_away(frozen_fixture): - freeze, rows, _, _ = frozen_fixture - changed = deepcopy(rows) - for row in changed: - if row['task_name'] == 'pip': - for tag in row['tags'].values(): - tag['corners_xy'] = (np.asarray(tag['corners_xy'])+[0., 5.]).tolist() - error = np.asarray(freeze().evaluate(changed).tag_rms_px) - assert np.allclose(error[6:12], 5.) - assert np.max(np.abs(error[:6])) < 1e-10 - - -def test_training_holdout_and_missing_mapping_support_rejected(frozen_fixture): - freeze, rows, bundle, _ = frozen_fixture - changed = deepcopy(rows) - changed[0]['image_stamp_ns'] = bundle.rows[0]['image_stamp_ns'] - with pytest.raises(ValueError, match='not_independent'): - freeze().evaluate(changed) - # SDK values outside actual fitted knots cannot be silently clipped. - with pytest.raises(ValueError, match='outside fitted support'): - freeze(first_knots=(10., 127., 250.)).evaluate(rows) diff --git a/src/linkerhand_calibration/test/test_frozen_evidence_storage.py b/src/linkerhand_calibration/test/test_frozen_evidence_storage.py deleted file mode 100644 index 0cd294d..0000000 --- a/src/linkerhand_calibration/test/test_frozen_evidence_storage.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Frozen model sharing must preserve bytes, integrity and image isolation.""" -from copy import deepcopy -from dataclasses import dataclass -import hashlib -import json -import pickle - -import pytest - -from linkerhand_calibration.core.geometry.frozen_evidence import EvidenceDecoder, image_model_evidence - - -@dataclass(frozen=True) -class Model: - camera: tuple = ((1., 0., 0.), (0., 1., 0.), (0., 0., 1.)) - stamps: tuple = tuple(range(100)) - - -def test_live_snapshots_share_only_immutable_model(): - payload = image_model_evidence(Model()) - a = dict(frozen_image_model=payload, image_stamp_ns=1, errors=[.1]) - b = deepcopy(a) - assert b['frozen_image_model'] is payload - b['errors'].append(.2) - assert a['errors'] == [.1] - with pytest.raises(TypeError): payload['stamps'] = () - with pytest.raises(TypeError): payload['camera'][0][0] = 9 - assert pickle.loads(pickle.dumps(payload)) == payload - - -def test_journal_decoder_shares_validated_models_without_changing_json(): - payload = json.loads(json.dumps(image_model_evidence(Model()))) - digest = hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',', ':')).encode()).hexdigest() - row = dict(frozen_image_model=payload, image_model_sha256=digest, stamp=1) - decoder = EvidenceDecoder() - original = json.dumps(row,sort_keys=True) - first, second = decoder.loads(original), decoder.loads(original) - assert first is not second - assert first['frozen_image_model'] is second['frozen_image_model'] - assert json.dumps(first,sort_keys=True) == original - assert len(decoder.models) == 1 - row['frozen_image_model']['stamps'][0] = 999 - with pytest.raises(ValueError,match='model_hash_invalid'): - decoder.loads(json.dumps(row)) - assert first['frozen_image_model']['stamps'][0] == 0 - - -def test_legacy_plain_records_remain_plain_and_independent(): - decoder = EvidenceDecoder() - one = decoder.loads('{"kind":"joint_sample","state":[1,2]}') - two = decoder.loads('{"kind":"joint_sample","state":[1,2]}') - one['state'][0] = 4 - assert two['state'] == [1,2] - - -def test_passed_resume_defers_hash_checks_without_replacing_conflicting_content(): - decoder = EvidenceDecoder(verify_hashes=False) - row = {'frozen_image_model': {'values': [1, 2]}, 'image_model_sha256': 'same-claim'} - first = decoder.loads(json.dumps(row)) - assert decoder.loads(json.dumps(row))['frozen_image_model'] is first['frozen_image_model'] - row['frozen_image_model']['values'][0] = 9 - changed = decoder.loads(json.dumps(row)) - assert changed['frozen_image_model']['values'] == (9, 2) - assert first['frozen_image_model']['values'] == (1, 2) - with pytest.raises(ValueError, match='model_hash_invalid'): - EvidenceDecoder().loads(json.dumps(changed)) - - -def test_cached_model_is_content_bound_even_after_a_mutable_input_changes(): - from linkerhand_calibration.core.geometry.frozen_evidence import freeze_evidence - from linkerhand_calibration.core.geometry.image_motion_replay import frozen_image_model, image_model_sha256 - from test_image_motion_provenance import recorded_image - - frame, _ = recorded_image() - item = frame['roles']['moving'] - model = frozen_image_model(item) - frozen = freeze_evidence(item['frozen_image_model']) - assert frozen_image_model({**item, 'frozen_image_model': frozen}) is model - assert image_model_sha256(frozen) == item['image_model_sha256'] - item['frozen_image_model']['geometry'][0]['pivot_parent_xyz_m'] = (.2, .2, .2) - with pytest.raises(ValueError, match='model_hash_invalid'): - frozen_image_model(item) - item['image_model_sha256'] = image_model_sha256(item['frozen_image_model']) - assert frozen_image_model(item) != model diff --git a/src/linkerhand_calibration/test/test_frozen_rotation_reference.py b/src/linkerhand_calibration/test/test_frozen_rotation_reference.py deleted file mode 100644 index 601d6fa..0000000 --- a/src/linkerhand_calibration/test/test_frozen_rotation_reference.py +++ /dev/null @@ -1,47 +0,0 @@ -import math - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.spatial import ( - fit_rotation_joint_curve, rotation_curve_holdout_errors, -) - - -def rows(cycles, *, slip=0.0, transverse=False): - mounting = Rotation.from_euler("xyz", (0.2, -0.3, 0.1)) - result = [] - for cycle in cycles: - for direction in ("decreasing", "increasing"): - for command in range(256): - angle = (255 - command)/255 + (0.02 if direction == "increasing" else 0) - rotation = mounting * Rotation.from_euler("z", angle) - if slip: - rotation = rotation * Rotation.from_euler("x" if transverse else "z", slip) - result.append({"cycle": cycle, "direction": direction, "command_u8": command, - "relative_quaternion_xyzw": rotation.as_quat().tolist()}) - return result - - -@pytest.mark.parametrize("transverse", [False, True]) -def test_live_shared_fitter_does_not_rezero_a_slipped_holdout_tag(transverse): - fit = fit_rotation_joint_curve(rows((0, 1, 2)), zero_command_u8=255, - canonical_zero_direction="decreasing") - clean = rotation_curve_holdout_errors(fit, rows((3,)), zero_command_u8=255) - assert np.max(np.abs(clean)) < 1e-7 - slipped = rotation_curve_holdout_errors(fit, - rows((3,), slip=math.radians(5), transverse=transverse), zero_command_u8=255) - assert np.min(np.abs(slipped)) > math.radians(4.9) - - -def test_live_shared_fitter_rejects_training_as_holdout(): - fit = fit_rotation_joint_curve(rows((0, 1, 2)), zero_command_u8=255) - with pytest.raises(ValueError, match="training cycles"): - rotation_curve_holdout_errors(fit, rows((2,)), zero_command_u8=255) - - -def test_two_directions_keep_the_same_training_reference(): - fit = fit_rotation_joint_curve(rows((0, 1, 2)), zero_command_u8=255) - assert fit.increasing_rad[-1] - fit.decreasing_rad[-1] == pytest.approx(0.02, abs=1e-7) - assert max(abs(v) for v in rotation_curve_holdout_errors(fit, rows((3,)), zero_command_u8=255)) < 1e-7 diff --git a/src/linkerhand_calibration/test/test_frozen_tag_replay.py b/src/linkerhand_calibration/test/test_frozen_tag_replay.py deleted file mode 100644 index 0b7bfc8..0000000 --- a/src/linkerhand_calibration/test/test_frozen_tag_replay.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Independent rigid-body generator: never uses the fitted FK to make truth.""" - -from dataclasses import replace -import hashlib -import json - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.tag_installation import ( - TagTrainingPose, fit_tag_installations, matrix_tuple, -) -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan -from linkerhand_calibration.core.urdf.tag_acceptance import TagHoldout, validate_serialized_tag_holdout -from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher, FrozenTagArtifactValidator - - -def transform(rpy=(0, 0, 0), xyz=(0, 0, 0)): - value = np.eye(4) - value[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() - value[:3, 3] = xyz - return value - - -def fixture(tmp_path, unit="rad"): - source = tmp_path / "raw.urdf" - source.write_text(''' - - - - - - - - ''') - zeros = {"drive": 0.07, "passive": -0.03} - rights = {"drive": ("origin.rpy", "limit.lower", "limit.upper"), - "passive": ("origin.rpy", "limit.lower", "limit.upper", "mimic.offset")} - digest = hashlib.sha256(source.read_bytes()).hexdigest() - plan = build_standard_correction_plan(source_urdf=source, source_sha256=digest, - zero_offsets_rad=zeros, authorized_fields=rights) - base = transform((0.13, -0.22, 0.19), (0.1, 0.2, 0.3)) - mounts = {"a": transform((0.3, 0.2, -0.4), (0.003, 0.012, 0.018)), - "b": transform((-0.4, 0.3, 0.2), (-0.013, 0.009, 0.016))} - training, holdout = [], [] - for cycle in range(4): - for direction in ("increasing", "decreasing"): - for i, phase in enumerate(np.linspace(0, 1, 80)): - sdk = phase if unit == "rad" else round(phase*255) - x = sdk if unit == "rad" else sdk/255 - # Known independent coordinates including direction-dependent - # backlash. Neither source-model FK nor the fitted curve makes truth. - cad = 0.2 + 0.8*x + (0.006 if direction == "increasing" else -0.006) - child = 0.6*cad + 0.04 - proximal = transform((0.2, -0.1, 0.3), (0.02, -0.01, 0.03)) @ transform((0, cad, 0)) - distal = proximal @ transform((-0.1, 0.2, 0.1), (0.01, 0, 0.04)) @ transform((child, 0, 0)) - for role, pose in (("a", proximal), ("b", distal)): - image = f"camera:{cycle}:{direction}:{i}" - observed = matrix_tuple(base @ pose @ mounts[role]) - if cycle < 3: - training.append(TagTrainingPose(image, role, cycle, observed, {"drive": cad, "passive": child})) - else: - holdout.append(TagHoldout(image, role, cycle, (sdk,), {"drive": direction}, observed)) - frozen = fit_tag_installations(source_model=UrdfKinematicModel(source), common_from_base=base, - link_by_role={"a": "proximal", "b": "distal"}, observations=training) - knots = np.linspace(0, 1 if unit == "rad" else 255, 65 if unit == "rad" else 256) - angles = 0.2 + 0.8*knots/(1 if unit == "rad" else 255) - zeros["drive"] - row = {"urdf_joint": "drive", "motor_index": 0, "angle_rad": angles.tolist(), - "increasing_rad": (angles+0.006).tolist(), "decreasing_rad": (angles-0.006).tolist()} - if unit == "rad": - row["curve_input_knots_rad"] = knots.tolist() - payload = {"profile_id": "VIRTUAL/right/new/v1", "schema_version": 7 if unit == "rad" else 6, - "curve_input_domain": "feedback_" + unit, "joints": {"drive": row}} - return source, plan, base, training, frozen, holdout, payload - - -@pytest.mark.parametrize("unit", ["rad", "u8"]) -def test_written_standard_urdf_replays_independent_poses_and_publishes(tmp_path, unit): - source, plan, base, _, mounts, holdout, payload = fixture(tmp_path, unit) - session = tmp_path / "session" - session.mkdir() - urdf = plan.write(source, session / "corrected.urdf") - calibration = session / "calibration.json" - calibration.write_text(json.dumps(payload)) - loaded = [] - validator = FrozenTagArtifactValidator(payload["profile_id"], source, plan.source_sha256, - plan.authorized_fields, plan.zero_offsets_rad, matrix_tuple(base), mounts, - tuple(holdout), ("a", "b"), lambda path: loaded.append(path)) - release = ArtifactPublisher(tmp_path, "latest").publish(session_directory=session, - calibration_json=calibration, corrected_urdf=urdf, validate=validator) - assert release.pointer.resolve() == session - evidence = json.loads(release.manifest.read_text())["validation"] - assert evidence["final_file_spatial_replay"] == "passed" - assert evidence["tag_holdout"]["b"]["maximum_deg"] < 1e-6 - assert loaded == [urdf] - - -@pytest.mark.parametrize("corruption", ["mimic", "origin", "json_zero", "tag_slip"]) -def test_final_file_or_tag_errors_cannot_be_absorbed_into_new_mounts(tmp_path, corruption): - source, plan, base, _, mounts, holdout, payload = fixture(tmp_path) - corrected = plan.write(source, tmp_path / "corrected.urdf") - if corruption == "mimic": - corrected.write_text(corrected.read_text().replace('multiplier="0.6"', 'multiplier="0.8"')) - elif corruption == "origin": - import xml.etree.ElementTree as ET - tree = ET.parse(corrected) - tree.find("joint/origin").set("rpy", "0.4 0.1 0.3") - tree.write(corrected) - elif corruption == "json_zero": - for name in ("angle_rad", "increasing_rad", "decreasing_rad"): - payload["joints"]["drive"][name] = [v + 0.1 for v in payload["joints"]["drive"][name]] - else: - holdout = [replace(row, common_from_tag=matrix_tuple( - np.asarray(row.common_from_tag) @ transform((0.1, 0, 0)))) for row in holdout] - with pytest.raises(ValueError, match="holdout_failed"): - validate_serialized_tag_holdout(corrected_urdf=corrected, payload=payload, - common_from_base=base, installations=mounts, observations=holdout, required_roles=("a", "b")) - - -def test_installation_is_unique_across_cycles_and_holdout_never_fits(tmp_path): - source, _, base, training, mounts, holdout, _ = fixture(tmp_path) - with pytest.raises(ValueError, match="training"): - fit_tag_installations(source_model=UrdfKinematicModel(source), common_from_base=base, - link_by_role={"a": "proximal", "b": "distal"}, observations=[replace(training[0], cycle=3)]) - slipped = [replace(row, common_from_tag=matrix_tuple(np.asarray(row.common_from_tag) @ - transform((0.12, 0, 0)))) if row.cycle == 2 else row for row in training] - from linkerhand_calibration.core.fitting.tag_installation import TagInstallationFailure - with pytest.raises(TagInstallationFailure, match="tag_installation_not_rigid") as failure: - fit_tag_installations(source_model=UrdfKinematicModel(source), common_from_base=base, - link_by_role={"a": "proximal", "b": "distal"}, observations=slipped) - assert set(failure.value.diagnostics) == {"a", "b"} - assert all(not row["passed"] for row in failure.value.diagnostics.values()) - # Same physical Tag observed through two tasks is deduplicated, not fitted twice. - duplicate = fit_tag_installations(source_model=UrdfKinematicModel(source), common_from_base=base, - link_by_role={"a": "proximal", "b": "distal"}, observations=training+training) - assert duplicate == mounts diff --git a/src/linkerhand_calibration/test/test_full_hand.py b/src/linkerhand_calibration/test/test_full_hand.py deleted file mode 100644 index c6b5b6a..0000000 --- a/src/linkerhand_calibration/test/test_full_hand.py +++ /dev/null @@ -1,1092 +0,0 @@ -import math -from dataclasses import replace -from pathlib import Path - -import numpy as np -import pytest - -from linkerhand_calibration.full_hand import ( - ACTIVE_JOINTS, - IMAGE_TRAJECTORY_JOINTS, - JOINT_SPECS, - JointCurveFit, - LEFT_HAND_PROFILE, - MEASURED_JOINTS, - PASSIVE_JOINTS, - RIGHT_HAND_PROFILE, - RIGHT_19_HAND_PROFILE, - SPLAY_JOINTS, - SWEEP_SPECS, - THREE_CAMERA_BASELINE_COMMAND, - VIEW_TAGS, - build_calibration_motion_command, - build_calibration_preparation_waypoints, - build_calibration_return_waypoints, - build_calibration_speed_profile, - build_compact_payload, - build_full_hand_command, - clamp_runtime_fits_to_urdf_limits, - center_splay_curve, - compare_cross_view_roll_curves, - cross_view_roll_diagnostic_metrics, - derive_mimic_passive_fits, - fit_joint_center_curve, - fit_joint_image_curve, - fit_measured_joint_curve, - fit_projected_zero, - get_hand_calibration_profile, - measure_joint_observation, - validate_compact_payload, -) - - -def test_runtime_fit_saturates_at_source_urdf_limit() -> None: - values = tuple(np.linspace(0.0, 2.0, 256)) - fit = JointCurveFit( - angle_rad=values, - decreasing_rad=values, - increasing_rad=values, - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=0.0, - quality={}, - ) - - bounded = clamp_runtime_fits_to_urdf_limits( - Path(__file__).resolve().parents[1] - / "urdf/g20_right/linkerhand_g20_right.urdf", - {"thumb_cmc_yaw": fit}, - )["thumb_cmc_yaw"] - - assert max(bounded.angle_rad) == pytest.approx(1.57) - assert max(bounded.decreasing_rad) == pytest.approx(1.57) - assert bounded.circle["runtime_urdf_limit_clipped_bins"]["angle_rad"] > 0 - - -def test_right_19_profile_has_exact_layout_tasks_and_measured_dips() -> None: - profile = get_hand_calibration_profile("right", "g20_right_19") - assert profile is RIGHT_19_HAND_PROFILE - assert len(profile.sweep_specs) == 16 - assert len(profile.measured_joints) == 21 - assert len(profile.record_joints) == 25 - assert list(profile.view_tags["front"].values()) == [ - 0, 1, 2, 3, 10, 11, 12, 13 - ] - assert list(profile.view_tags["side"].values()) == [ - 4, 5, 6, 7, 14, 15, 16, 17, 18 - ] - assert list(profile.view_tags["top"].values()) == [8, 9] - assert profile.preflight_view_roles == { - "front": ("front_base",), - "side": ("side_base",), - "top": ("top_base",), - } - assert [spec.task_name for spec in profile.sweep_specs[:4]] == [ - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - "thumb_mcp_ip_front", - "thumb_cmc_yaw_top", - ] - assert [ - spec.task_name for spec in profile.sweep_specs[4:7] - ] == [ - "pinky_roll_multiview", - "pinky_pitch_side", - "pinky_pip_side", - ] - assert profile.sweep_specs[4].joints == ( - "pinky_mcp_roll", - "pinky_mcp_roll_side", - ) - assert { - ( - observer.source_name, - observer.task_name, - observer.view, - observer.model_joint, - observer.motor_index, - ) - for observer in profile.palm_axis_observers - } == { - ( - "thumb_cmc_pitch_top_axis", - "thumb_cmc_pitch_front", - "top", - "thumb_cmc_pitch", - 0, - ), - ( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll_front", - "top", - "thumb_cmc_roll", - 5, - ), - } - assert profile.minimum_palm_orientation_sources == 2 - assert profile.palm_orientation_minimum_arc_rad == pytest.approx( - math.radians(10.0) - ) - assert profile.palm_orientation_maximum_command_distance_u8 == 64 - assert profile.sweep_specs[6].joints == ("pinky_pip", "pinky_dip") - assert { - name: spec.source_joint - for name, spec in profile.joint_specs.items() - if spec.source_joint is not None - } == {} - - -def test_right_19_keeps_four_visual_dip_curves_independent() -> None: - profile = RIGHT_19_HAND_PROFILE - values = tuple(0.002 * (255 - command) for command in range(256)) - fit = JointCurveFit(values, values, values, {}, 0.0, 0.01, {}) - measured = {name: fit for name in profile.measured_joints} - dip_values = tuple(0.0013 * (255 - command) for command in range(256)) - for finger in ("index", "middle", "ring", "pinky"): - measured[f"{finger}_dip"] = JointCurveFit( - dip_values, - dip_values, - dip_values, - {"visual_measurement": True}, - 0.0, - 0.007, - {"visual_measurement": 1.0}, - ) - source_urdf = ( - Path(__file__).resolve().parents[1] - / "urdf/g20_right/linkerhand_g20_right.urdf" - ) - - runtime = derive_mimic_passive_fits( - source_urdf, measured, profile=profile - ) - - assert set(runtime) == set(profile.joint_specs) - for finger in ("index", "middle", "ring", "pinky"): - dip = runtime[f"{finger}_dip"] - assert dip.angle_rad == pytest.approx(dip_values) - assert dip.maximum_hysteresis_rad == pytest.approx(0.007) - assert dip.circle["visual_measurement"] is True - - -def _changed_motors( - previous: tuple[int, ...] | list[int], waypoint: tuple[int, ...] -) -> list[int]: - return [ - index - for index, (left, right) in enumerate(zip(previous, waypoint)) - if left != right - ] - - -def _phase_changes( - previous: tuple[int, ...] | list[int], - waypoints: tuple[tuple[int, ...], ...], -) -> list[list[int]]: - phases: list[list[int]] = [] - for waypoint in waypoints: - phases.append(_changed_motors(previous, waypoint)) - previous = waypoint - return phases - - -def test_right_19_side_clearance_waypoints_group_joint_classes() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs - if item.task_name == "index_roll_multiview" - ) - baseline = [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=baseline, - profile=profile, - ) - # Side clearance keeps all neighbouring roll axes neutral, flexes the - # neighbour PIPs together, then their pitches, and moves the index target - # last through the cleared swept volume. - assert _phase_changes(baseline, waypoints) == [ - [17, 18, 19], [2, 3, 4], [6], - ] - assert waypoints[-1][1] == 255 and waypoints[-1][16] == 255 - - returns = build_calibration_return_waypoints( - baseline, - current_command=waypoints[-1], - profile=profile, - ) - assert _phase_changes(waypoints[-1], returns) == [ - [6], [2, 3, 4], [17, 18, 19], - ] - assert list(returns[-1]) == baseline - - -def test_right_19_side_clearance_waypoints_move_one_motor_at_a_time() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs - if item.task_name == "index_roll_multiview" - ) - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=[255, 255, 255, 255, 255, 255, 127, 127, 127, 127, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], - profile=profile, - parallel=False, - ) - previous = tuple([255] * 6 + [127] * 4 + [255] * 10) - changed_motors = [] - for waypoint in waypoints: - changed = _changed_motors(previous, waypoint) - assert len(changed) == 1 - changed_motors.append(changed[0]) - previous = waypoint - # Side clearance keeps all neighbouring roll axes neutral, flexes them out - # of the swept volume, and moves the index target last. - assert changed_motors[:7] == [19, 4, 18, 3, 17, 2, 6] - assert previous[1] == 255 and previous[16] == 255 - - returns = build_calibration_return_waypoints( - [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], - current_command=previous, - profile=profile, - parallel=False, - ) - for waypoint in returns: - changed = sum(left != right for left, right in zip(previous, waypoint)) - assert changed == 1 - previous = waypoint - - -def test_right_15_ring_multiview_keeps_pinky_roll_neutral_and_flexes_it() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs - if item.task_name == "ring_roll_multiview" - ) - current = tuple(THREE_CAMERA_BASELINE_COMMAND) - - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=current, - profile=profile, - ) - - assert _phase_changes(current, waypoints) == [[6, 7], [19], [4], [8]] - assert all(waypoint[9] == 127 for waypoint in waypoints) - assert waypoints[-1][8] == 255 - assert waypoints[-1][19] == 0 - assert waypoints[-1][4] == 0 - - -def test_middle_multiview_recovers_neighbour_from_observed_58_not_zero() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs - if item.task_name == "middle_roll_multiview" - ) - # Session 20260819_160238 stopped here: the legacy front-only avoidance - # asked neighbouring ring roll (motor 8) for 0 and it saturated at 58. - current = [254] * 20 - current[6:10] = [254, 127, 58, 127] - current[10] = 252 - current[11:15] = [0, 0, 0, 0] - - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=current, - profile=profile, - ) - - # Both differing roll axes (parked motor 6 and the stuck motor 8) move in - # the single roll phase, still never commanded toward 0. - phases = _phase_changes(current, waypoints) - assert phases[0] == [6, 8] - assert waypoints[0][8] == 127 - assert all( - waypoint[motor] != 0 - for waypoint in waypoints - for motor in range(6, 10) - ) - assert waypoints[-1][6:10] == (255, 255, 127, 127) - - -def test_right_19_ring_pitch_keeps_target_neutral_during_clearance() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs - if item.task_name == "ring_pitch_side" - ) - current = tuple(THREE_CAMERA_BASELINE_COMMAND) - - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=current, - profile=profile, - ) - - assert _phase_changes(current, waypoints) == [[6, 7], [19], [4]] - assert all(waypoint[8] == 127 for waypoint in waypoints) - assert all(waypoint[9] == 127 for waypoint in waypoints) - assert waypoints[-1][9] == 127 - assert waypoints[-1][19] == 0 - assert waypoints[-1][4] == 0 - - -def test_right_19_waypoints_never_wait_for_reserved_feedback_channels() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = profile.sweep_specs[0] - # This is the real G20 feedback shape: reserved channels 11..14 report zero - # while active endpoint channels commonly settle one or two counts low. - current = [ - 255, 254, 254, 254, 254, 254, 127, 127, 127, 127, - 253, 0, 0, 0, 0, 255, 253, 253, 253, 253, - ] - waypoints = build_calibration_preparation_waypoints( - spec, - 255, - current_command=current, - profile=profile, - ) - assert waypoints - assert all(waypoint[11:15] == (0, 0, 0, 0) for waypoint in waypoints) - controlled = {joint.motor_index for joint in profile.joint_specs.values()} - for previous, waypoint in zip((tuple(current), *waypoints[:-1]), waypoints): - assert set(_changed_motors(previous, waypoint)) <= controlled - - returns = build_calibration_return_waypoints( - list(THREE_CAMERA_BASELINE_COMMAND), - current_command=waypoints[-1], - profile=profile, - ) - assert all(waypoint[11:15] == (0, 0, 0, 0) for waypoint in returns) - - -def test_right_19_parallel_return_moves_rolls_together_then_fingers() -> None: - # Full four-finger avoidance pose: rolls parked at 255 and every clearance - # finger flexed to 0. - current = list(THREE_CAMERA_BASELINE_COMMAND) - current[6:10] = [255, 255, 255, 255] - for motor in (1, 2, 3, 4, 16, 17, 18, 19): - current[motor] = 0 - returns = build_calibration_return_waypoints( - list(THREE_CAMERA_BASELINE_COMMAND), - current_command=current, - profile=RIGHT_19_HAND_PROFILE, - anchor_roll_motor=9, - ) - assert _phase_changes(current, returns) == [ - [6, 7, 8, 9], [1, 2, 3, 4], [16, 17, 18, 19], - ] - assert list(returns[-1]) == list(THREE_CAMERA_BASELINE_COMMAND) - - -def test_right_19_pinky_exit_returns_target_roll_before_other_fingers() -> None: - current = list(THREE_CAMERA_BASELINE_COMMAND) - current[6:10] = [255, 255, 255, 255] - returns = build_calibration_return_waypoints( - list(THREE_CAMERA_BASELINE_COMMAND), - current_command=current, - profile=RIGHT_19_HAND_PROFILE, - anchor_roll_motor=9, - parallel=False, - ) - phases = _phase_changes(current, returns) - assert all(len(phase) == 1 for phase in phases) - assert [motor for phase in phases for motor in phase][:4] == [9, 8, 7, 6] - - -def test_right_19_ring_exit_returns_ring_before_neighbours() -> None: - current = list(THREE_CAMERA_BASELINE_COMMAND) - current[6:10] = [255, 255, 255, 0] - returns = build_calibration_return_waypoints( - list(THREE_CAMERA_BASELINE_COMMAND), - current_command=current, - profile=RIGHT_19_HAND_PROFILE, - anchor_roll_motor=8, - parallel=False, - ) - - first = returns[0] - assert first[8] == 127 - assert first[6:8] == (255, 255) - assert first[9] == 0 - - -def test_cross_view_roll_curve_must_agree_before_fusion() -> None: - values = tuple(0.5 * (127 - command) / 127 for command in range(256)) - fit = JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - assert compare_cross_view_roll_curves(fit, fit)[ - "angle_rad_rms_difference_rad" - ] == 0.0 - bad_values = tuple(value + 0.03 * (command != 127) for command, value in enumerate(values)) - bad = replace(fit, angle_rad=bad_values, decreasing_rad=bad_values, - increasing_rad=bad_values) - with pytest.raises(ValueError, match="cross_view_roll_curve"): - compare_cross_view_roll_curves(fit, bad) - - reverse = replace( - fit, - increasing_rad=tuple(value + math.radians(0.5) for value in values), - ) - with pytest.raises(ValueError, match="branch_gap_difference"): - compare_cross_view_roll_curves(fit, reverse) - - -def test_cross_view_roll_curve_allows_bounded_projection_scale() -> None: - values = tuple(0.5 * (127 - command) / 127 for command in range(256)) - primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - projected = tuple(0.8 * value for value in values) - secondary = replace( - primary, - angle_rad=projected, - decreasing_rad=projected, - increasing_rad=projected, - ) - - with pytest.raises(ValueError, match="cross_view_roll_curve"): - compare_cross_view_roll_curves(primary, secondary) - - metrics = compare_cross_view_roll_curves( - primary, - secondary, - allow_projection_scale=True, - ) - - assert metrics["projection_scale"] == pytest.approx(1.25) - assert metrics["angle_rad_rms_difference_rad"] == pytest.approx(0.0) - assert metrics["raw_angle_rad_rms_difference_rad"] > 0.0 - - -def test_projected_cross_view_keeps_branch_gap_as_diagnostic() -> None: - values = tuple(0.5 * (127 - command) / 127 for command in range(256)) - primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - shifted_increasing = tuple( - value + math.radians(0.5) for value in values - ) - secondary = replace(primary, increasing_rad=shifted_increasing) - - metrics = compare_cross_view_roll_curves( - primary, - secondary, - allow_projection_scale=True, - ) - - assert metrics["baseline_branch_gap_difference_rad"] > math.radians(0.3) - assert metrics["baseline_branch_gap_gate_applied"] == 0.0 - - -def test_cross_view_roll_curve_rejects_implausible_projection_scale() -> None: - values = tuple(0.5 * (127 - command) / 127 for command in range(256)) - primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - collapsed = tuple(0.2 * value for value in values) - secondary = replace( - primary, - angle_rad=collapsed, - decreasing_rad=collapsed, - increasing_rad=collapsed, - ) - - with pytest.raises(ValueError, match="projection_scale_ratio"): - compare_cross_view_roll_curves( - primary, - secondary, - allow_projection_scale=True, - ) - - -def test_validation_only_cross_view_metrics_never_create_a_retry_gate() -> None: - values = tuple(0.5 * (127 - command) / 127 for command in range(256)) - primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - biased = tuple( - value - + math.radians(2.5) * math.sin(math.pi * command / 255.0) - for command, value in enumerate(values) - ) - secondary = replace( - primary, - angle_rad=biased, - decreasing_rad=biased, - increasing_rad=biased, - ) - - metrics = cross_view_roll_diagnostic_metrics(primary, secondary) - - assert metrics["angle_rad_rms_difference_rad"] > math.radians(1.0) - - reversed_secondary = replace( - secondary, - angle_rad=tuple(-value for value in secondary.angle_rad), - decreasing_rad=tuple(-value for value in secondary.decreasing_rad), - increasing_rad=tuple(-value for value in secondary.increasing_rad), - ) - assert cross_view_roll_diagnostic_metrics( - primary, reversed_secondary - ) == {"direction_disagrees": 1.0} - - -def _records() -> list[dict[str, object]]: - commands = list(range(0, 256, 16)) - if commands[-1] != 255: - commands.append(255) - records: list[dict[str, object]] = [] - centre = np.asarray([0.006, -0.004, 0.012]) - radius = 0.025 - image_centre = np.asarray([30.0, -12.0]) - image_radius = 100.0 - for cycle in range(3): - for direction, sequence in ( - ("decreasing", reversed(commands)), - ("increasing", commands), - ): - for command in sequence: - angle = 0.70 * (255.0 - command) / 255.0 - point = centre + np.asarray( - [radius * math.cos(angle), radius * math.sin(angle), 0.0] - ) - # At command 255 the inward vector points along image +x, so - # table_projected_zero_rad is exactly zero. - image_point = image_centre + np.asarray( - [ - -image_radius * math.cos(angle), - image_radius * math.sin(angle), - ] - ) - records.append( - { - "cycle": cycle, - "direction": direction, - "command_u8": command, - "relative_translation_xyz_m": point.tolist(), - "image_relative_xy_px": image_point.tolist(), - } - ) - return records - - -def test_joint_layout_covers_16_active_and_5_passive_joints() -> None: - assert len(JOINT_SPECS) == 21 - assert len(ACTIVE_JOINTS) == 16 - assert len(PASSIVE_JOINTS) == 5 - - -def test_right_profile_measures_pinky_and_inherits_to_other_fingers() -> None: - profile = get_hand_calibration_profile("right") - assert profile is RIGHT_HAND_PROFILE - assert profile.reference_finger == "pinky" - assert [spec.motor_index for spec in profile.sweep_specs] == [ - 0, 5, 15, 9, 4, 19, 10 - ] - assert profile.view_tags["front"]["pinky_roll"] == 10 - assert profile.view_tags["side"] == { - "side_base": 4, - "pinky_mcp": 5, - "pinky_pip": 6, - "pinky_dip": 7, - } - assert profile.preflight_view_roles["side"] == ( - "side_base", - "pinky_mcp", - "pinky_pip", - "pinky_dip", - ) - thumb_pitch = profile.joint_specs["thumb_cmc_pitch"] - assert thumb_pitch.view == "front" - assert thumb_pitch.parent_role == "front_base" - assert thumb_pitch.child_role == "thumb_cmc" - assert profile.joint_specs["index_mcp_roll"].source_joint == ( - "pinky_mcp_roll" - ) - assert profile.joint_specs["middle_mcp_pitch"].source_joint == ( - "pinky_mcp_pitch" - ) - assert profile.joint_specs["ring_pip"].source_joint == "pinky_pip" - assert profile.joint_specs["index_dip"].source_joint == "pinky_dip" - - roll = next(spec for spec in profile.sweep_specs if spec.motor_index == 9) - command = build_calibration_motion_command( - roll, 127, profile=profile - ) - assert command[9] == 127 - assert command[6:9] == [255, 255, 255] - speeds = build_calibration_speed_profile( - roll, - normal_speed=15, - index_roll_speed=5, - index_flex_speed=10, - profile=profile, - ) - assert speeds == [15, 15, 15, 15, 5] - assert {tag for tags in VIEW_TAGS.values() for tag in tags.values()} == set( - range(11) - ) - assert VIEW_TAGS["front"]["index_roll"] == 10 - assert VIEW_TAGS["side"] == { - "side_base": 4, - "index_mcp": 5, - "index_pip": 6, - "index_dip": 7, - } - assert VIEW_TAGS["top"] == {"top_base": 8, "thumb_yaw": 9} - assert [spec.motor_index for spec in SWEEP_SPECS] == [0, 5, 15, 6, 1, 16, 10] - - -def test_joint_trajectory_spaces_match_observation_geometry() -> None: - assert IMAGE_TRAJECTORY_JOINTS == { - "thumb_cmc_pitch", - "thumb_cmc_roll", - "thumb_mcp", - "thumb_ip", - "index_mcp_roll", - "index_mcp_pitch", - "index_pip", - } - records = _records() - for name in IMAGE_TRAJECTORY_JOINTS: - fit = fit_measured_joint_curve(name, records) - assert fit.circle["space"] == "image_2d" - for name in ("index_dip", "thumb_cmc_yaw"): - assert "space" not in fit_measured_joint_curve(name, records).circle - - -def test_full_hand_command_changes_exactly_one_controlled_motor() -> None: - result = build_full_hand_command(6, 27) - assert result[6] == 27 - assert result[:6] == [255] * 6 - with pytest.raises(ValueError, match="controlled"): - build_full_hand_command(11, 27) - - -def test_index_roll_motion_moves_other_three_roll_motors_out_of_view() -> None: - baseline = [255] * 20 - baseline[6:10] = [127, 127, 127, 127] - index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) - - result = build_calibration_motion_command(index_roll, 27, baseline) - - assert result[6:10] == [27, 0, 0, 0] - assert result[:6] == baseline[:6] - assert result[10:] == baseline[10:] - - -def test_right_pinky_roll_moves_other_three_fingers_camera_right() -> None: - profile = RIGHT_HAND_PROFILE - pinky_roll = next( - spec for spec in profile.sweep_specs if spec.motor_index == 9 - ) - - result = build_calibration_motion_command( - pinky_roll, 27, profile=profile - ) - - assert result[6:10] == [255, 255, 255, 27] - assert profile.roll_clearance_commands == {6: 255, 7: 255, 8: 255} - - -def test_non_index_roll_motion_keeps_clearance_motors_at_baseline() -> None: - baseline = [255] * 20 - baseline[6:10] = [127, 127, 127, 127] - thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0) - - result = build_calibration_motion_command(thumb_pitch, 17, baseline) - - assert result[0] == 17 - assert result[6:10] == [127, 127, 127, 127] - - -def test_right_thumb_pitch_uses_front_visible_yaw_and_roll_pose() -> None: - profile = RIGHT_HAND_PROFILE - thumb_pitch = next( - spec for spec in profile.sweep_specs if spec.motor_index == 0 - ) - - result = build_calibration_motion_command( - thumb_pitch, 17, profile=profile - ) - - assert result[0] == 17 - assert result[10] == 255 - assert result[5] == 255 - assert profile.thumb_pitch_clearance_commands == {10: 255, 5: 255} - - -def test_left_thumb_pitch_keeps_legacy_baseline_pose() -> None: - thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0) - - result = build_calibration_motion_command(thumb_pitch, 17) - - assert result[0] == 17 - assert result[5] == 255 - assert result[10] == 255 - assert LEFT_HAND_PROFILE.thumb_pitch_clearance_commands == {} - - -def test_thumb_yaw_motion_holds_thumb_roll_at_camera_clearance_pose() -> None: - baseline = [255] * 20 - baseline[6:10] = [127, 127, 127, 127] - thumb_yaw = next(spec for spec in SWEEP_SPECS if spec.motor_index == 10) - - result = build_calibration_motion_command(thumb_yaw, 27, baseline) - - assert result[5] == 145 - assert result[10] == 27 - assert result[:5] == baseline[:5] - assert result[6:10] == baseline[6:10] - assert result[11:] == baseline[11:] - - -def test_only_index_roll_uses_the_slow_index_finger_speed() -> None: - index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) - index_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 1) - - assert build_calibration_speed_profile( - index_roll, - normal_speed=15, - index_roll_speed=5, - index_flex_speed=10, - ) == [15, 5, 15, 15, 15] - assert build_calibration_speed_profile( - index_pitch, - normal_speed=15, - index_roll_speed=5, - index_flex_speed=10, - ) == [15, 10, 15, 15, 15] - - index_pip = next(spec for spec in SWEEP_SPECS if spec.motor_index == 16) - assert build_calibration_speed_profile( - index_pip, - normal_speed=15, - index_roll_speed=5, - index_flex_speed=10, - ) == [15, 10, 15, 15, 15] - - thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0) - assert build_calibration_speed_profile( - thumb_pitch, - normal_speed=15, - index_roll_speed=5, - index_flex_speed=10, - ) == [15, 15, 15, 15, 15] - - -def test_calibration_speed_profile_rejects_out_of_range_values() -> None: - index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) - with pytest.raises(ValueError, match="speeds"): - build_calibration_speed_profile( - index_roll, - normal_speed=15, - index_roll_speed=256, - index_flex_speed=10, - ) - - -def test_curve_fit_and_projected_zero_recover_synthetic_geometry() -> None: - records = _records() - fit = fit_joint_center_curve(records) - assert fit.angle_rad[255] == pytest.approx(0.0, abs=1.0e-8) - assert fit.angle_rad[0] == pytest.approx(0.70, abs=1.0e-4) - assert fit.maximum_hysteresis_rad == pytest.approx(0.0, abs=1.0e-8) - assert fit_projected_zero(records) == pytest.approx(0.0, abs=1.0e-6) - - -def test_thumb_image_curve_avoids_corrupted_pnp_depth() -> None: - records = _records() - for record in records: - command = int(record["command_u8"]) - record["relative_translation_xyz_m"] = [ - 0.001 * command, - 0.0, - 0.0, - ] - - fit = fit_measured_joint_curve("thumb_mcp", records) - - assert fit.circle["space"] == "image_2d" - assert fit.angle_rad[0] == pytest.approx(0.70, abs=0.02) - assert fit.angle_rad[255] == pytest.approx(0.0) - assert math.degrees(fit.maximum_monotonic_correction_rad) < 0.01 - assert math.degrees(fit.maximum_hysteresis_rad) < 0.01 - - command_zero = next( - record for record in records if int(record["command_u8"]) == 0 - ) - observed = measure_joint_observation( - fit, - vector_xyz_m=command_zero["relative_translation_xyz_m"], - image_vector_xy_px=command_zero["image_relative_xy_px"], - ) - assert observed == pytest.approx(0.70, abs=0.02) - - -def test_image_curve_rejects_insufficient_projected_arc() -> None: - records = _records() - for record in records: - command = float(record["command_u8"]) - angle = math.radians(2.0) * (255.0 - command) / 255.0 - record["image_relative_xy_px"] = [ - 100.0 * math.cos(angle), - 100.0 * math.sin(angle), - ] - - with pytest.raises( - ValueError, match="joint_image_trajectory_quality_failed:arc" - ): - fit_joint_image_curve(records) - - -def test_splay_uses_angular_midpoint_not_fixed_command_midpoint() -> None: - fit = fit_joint_center_curve(_records()) - centred, zero_command, midpoint = center_splay_curve(fit) - assert midpoint == pytest.approx(0.35, abs=1.0e-4) - assert centred.angle_rad[0] == pytest.approx(0.35, abs=1.0e-4) - assert centred.angle_rad[255] == pytest.approx(-0.35, abs=1.0e-4) - assert zero_command in {127, 128} - assert abs(centred.angle_rad[zero_command]) <= 0.002 - - -def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None: - base = fit_joint_center_curve(_records()) - splay, zero_command, _ = center_splay_curve(base) - splay = replace( - splay, - angle_rad=tuple( - value - splay.angle_rad[zero_command] - for value in splay.angle_rad - ), - ) - measured = { - name: splay if name == "index_mcp_roll" else base - for name in MEASURED_JOINTS - } - offsets = {name: 0.01 for name in ACTIVE_JOINTS} - baseline = [255] * 20 - baseline[6:10] = [zero_command] * 4 - payload = build_compact_payload( - serial_number="G20_LEFT_001", - measured_fits=measured, - urdf_zero_offsets_rad=offsets, - validation_errors_rad=[0.01, -0.02], - passed=True, - baseline=baseline, - ) - validate_compact_payload(payload) - assert set(payload) == { - "schema_version", - "model", - "side", - "serial_number", - "angle_unit", - "command_range", - "baseline_command_u8", - "joints", - "quality", - } - assert len(payload["joints"]) == 21 - assert all(len(joint["angle_rad"]) == 256 for joint in payload["joints"].values()) - assert sum("zero_command_u8" in joint for joint in payload["joints"].values()) == 16 - assert sum(joint.get("passive") is True for joint in payload["joints"].values()) == 5 - assert payload["joints"]["middle_mcp_roll"]["source_joint"] == "index_mcp_roll" - assert ( - payload["joints"]["middle_mcp_roll"]["angle_rad"] - == payload["joints"]["index_mcp_roll"]["angle_rad"] - ) - assert payload["joints"]["index_mcp_roll"]["zero_angles"] == { - "urdf_zero_offset_rad": pytest.approx(0.01) - } - for name in SPLAY_JOINTS: - assert payload["joints"][name]["zero_command_u8"] == zero_command - - -def test_compact_payload_allows_skipped_random_validation() -> None: - base = fit_joint_center_curve(_records()) - splay, zero_command, _ = center_splay_curve(base) - splay = replace( - splay, - angle_rad=tuple( - value - splay.angle_rad[zero_command] - for value in splay.angle_rad - ), - ) - measured = { - name: splay if name == "index_mcp_roll" else base - for name in MEASURED_JOINTS - } - offsets = {name: 0.0 for name in ACTIVE_JOINTS} - baseline = [255] * 20 - baseline[6:10] = [zero_command] * 4 - - payload = build_compact_payload( - serial_number="G20_LEFT_001", - measured_fits=measured, - urdf_zero_offsets_rad=offsets, - validation_errors_rad=[], - passed=True, - baseline=baseline, - ) - - validate_compact_payload(payload) - assert payload["quality"] == { - "passed": True, - "validation_mae_rad": None, - "validation_p95_rad": None, - } - - -def test_right_compact_payload_keeps_v4_shape_and_uses_pinky_sources() -> None: - profile = RIGHT_HAND_PROFILE - base = fit_joint_center_curve(_records()) - splay, zero_command, _ = center_splay_curve(base) - splay = replace( - splay, - angle_rad=tuple( - value - splay.angle_rad[zero_command] - for value in splay.angle_rad - ), - ) - measured = { - name: splay if name == "pinky_mcp_roll" else base - for name in profile.measured_joints - } - offsets = { - name: (0.01 if profile.joint_specs[name].source_joint is None else 0.0) - for name in profile.active_joints - } - baseline = [255] * 20 - baseline[6:10] = [zero_command] * 4 - payload = build_compact_payload( - serial_number="G20_RIGHT_001", - measured_fits=measured, - urdf_zero_offsets_rad=offsets, - validation_errors_rad=[0.01], - passed=True, - baseline=baseline, - side="right", - ) - - validate_compact_payload(payload) - assert payload["schema_version"] == 4 - assert payload["side"] == "right" - assert set(payload) == { - "schema_version", - "model", - "side", - "serial_number", - "angle_unit", - "command_range", - "baseline_command_u8", - "joints", - "quality", - } - for finger in ("index", "middle", "ring"): - assert payload["joints"][f"{finger}_mcp_roll"]["source_joint"] == ( - "pinky_mcp_roll" - ) - assert payload["joints"][f"{finger}_mcp_pitch"]["source_joint"] == ( - "pinky_mcp_pitch" - ) - assert payload["joints"][f"{finger}_pip"]["source_joint"] == ( - "pinky_pip" - ) - for suffix in ("mcp_roll", "mcp_pitch", "pip"): - joint = payload["joints"][f"{finger}_{suffix}"] - source = payload["joints"][joint["source_joint"]] - assert joint["angle_rad"] == source["angle_rad"] - assert joint["zero_angles"] == {"urdf_zero_offset_rad": 0.0} - assert source["zero_angles"] == {"urdf_zero_offset_rad": 0.01} - - -def test_right_19_schema_v4_records_visual_dips_and_16_static_zeros() -> None: - from linkerhand_calibration.urdf_zero import ( - get_zero_calibration_profile, - ) - - profile = RIGHT_19_HAND_PROFILE - zero = get_zero_calibration_profile("right", "g20_right_19") - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - fits = {} - diagnostics = {} - for name, spec in profile.joint_specs.items(): - zero_command = baseline[spec.motor_index] - values = tuple( - 0.002 * (zero_command - command) for command in range(256) - ) - fits[name] = JointCurveFit( - values, - values, - values, - {}, - 0.0, - 0.0, - {"rotation_orthogonal_rms_rad": 0.0, "arc_rad": 0.5}, - ) - diagnostics[name] = { - "cycle_travel_rad": [0.5, 0.5, 0.5, 0.5], - "cycle_travel_range_rad": 0.0, - "baseline_hysteresis_by_cycle_rad": [0.0, 0.0, 0.0, 0.0], - "holdout_cycle": 3, - "holdout_cycle_mae_rad": 0.0, - "holdout_cycle_max_rad": 0.0, - } - offsets = {name: 0.0 for name in profile.active_joints} - payload = build_compact_payload( - serial_number="G20_RIGHT_019", - measured_fits=fits, - urdf_zero_offsets_rad=offsets, - validation_errors_rad=[0.0], - passed=True, - baseline=baseline, - side="right", - layout_id="g20_right_19", - 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": {}, - }, - ) - - validate_compact_payload(payload) - assert payload["schema_version"] == 4 - assert set(payload) == { - "schema_version", "model", "side", "serial_number", "angle_unit", - "command_range", "baseline_command_u8", "joints", "quality", - } - assert len(zero.direct_zero_joints) == 16 - assert all( - f"{finger}_pip" in zero.direct_zero_joints - and f"{finger}_dip" not in zero.direct_zero_joints - for finger in ("index", "middle", "ring", "pinky") - ) - assert payload["joints"]["thumb_mcp"]["zero_angles"] == { - "urdf_zero_offset_rad": 0.0 - } - assert payload["joints"]["thumb_ip"]["passive"] is True - assert set(payload["joints"]["thumb_ip"]) == { - "motor_index", "angle_rad", "passive" - } - assert all( - "source_joint" not in joint for joint in payload["joints"].values() - ) diff --git a/src/linkerhand_calibration/test/test_g20_right_product.py b/src/linkerhand_calibration/test/test_g20_right_product.py deleted file mode 100644 index 1fe6fc0..0000000 --- a/src/linkerhand_calibration/test/test_g20_right_product.py +++ /dev/null @@ -1,985 +0,0 @@ -from dataclasses import replace -import json -import math -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -import yaml - -from linkerhand_calibration.full_hand import ( - G20_COMBINATION_REQUIRED_TARGET_KEYS, - G20_RIGHT_19_LAYOUT, - JointCurveFit, - build_compact_payload, - get_hand_calibration_profile, -) -from linkerhand_calibration.operator_report import ( - ProgressEstimator, - build_failure_report, - classify_error, - render_progress_zh, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.runner import ( - _automatic_resume_candidate, - _calibration_node_exited_before_status, - _launch_command, - _resolve_partial_base_session, - _status_timeout_seconds, -) -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.publication import ( - ACTIVE_ZERO_JOINTS, - PASSIVE_JOINTS, - atomic_session_pointer, -) -from linkerhand_calibration.storage import atomic_write_json -from linkerhand_calibration.urdf_zero import ( - RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, - UrdfKinematicModel, - write_zero_corrected_urdf, -) -from linkerhand_calibration.runtime import ACQUISITION_POLICY_VERSION - - -REPO = Path(__file__).resolve().parents[3] -PRODUCT = REPO / "src/linkerhand_calibration/config/g20_right_product.yaml" - - -def _config(tmp_path: Path, *, passes: int = 2): - value = load_product_config(PRODUCT, workspace=REPO, check_can=False) - return replace( - value, - output_root=tmp_path / "calibration_output", - required_independent_passes=passes, - ) - - -def _payload( - serial: str = "G20_RIGHT_001", - *, - zero_offsets: dict[str, float] | None = None, -) -> dict: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - offsets = zero_offsets or { - name: 0.01 for name in profile.active_joints - } - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - fits = {} - for name, spec in profile.joint_specs.items(): - zero = baseline[spec.motor_index] - decreasing = tuple(0.0011 * (zero - value) for value in range(256)) - increasing = tuple(0.0009 * (zero - value) for value in range(256)) - fits[name] = JointCurveFit( - angle_rad=decreasing, - decreasing_rad=decreasing, - increasing_rad=increasing, - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=math.radians(1.0), - quality={}, - ) - return build_compact_payload( - serial_number=serial, - measured_fits=fits, - urdf_zero_offsets_rad=offsets, - validation_errors_rad=[0.0, math.radians(0.5)], - passed=True, - baseline=baseline, - side="right", - layout_id=G20_RIGHT_19_LAYOUT, - ) - - -def _make_passed_session( - config, - stamp: str, - *, - zero_offsets: dict[str, float] | None = None, -) -> Path: - session = config.session_root / stamp - session.mkdir(parents=True) - offsets = zero_offsets or { - name: 0.01 for name in ACTIVE_ZERO_JOINTS - } - payload = _payload(config.serial_number, zero_offsets=offsets) - atomic_write_json( - session / f"g20_right_{config.serial_number}_calibration.json", payload - ) - write_zero_corrected_urdf( - source_urdf=config.source_urdf, - output_directory=session, - serial_number=config.serial_number, - offsets_rad=offsets, - endpoint_anchored_offsets_rad={ - name: offsets[name] - for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS - }, - timestamp=stamp, - ) - (session / "raw_samples.jsonl").write_text('{"kind":"session_start"}\n') - (session / "calibration.log").write_text("complete\n") - return session - - -def _passed_node_status() -> dict: - observation_counts = { - key: 2 for key in G20_COMBINATION_REQUIRED_TARGET_KEYS - } - validation_counts = { - key: 1 for key in G20_COMBINATION_REQUIRED_TARGET_KEYS - } - return { - "state": "COMPLETE", - "combination_validation": { - "enabled": True, - "completed": True, - "completed_poses": 8, - "total_poses": 8, - "position_p95_m": 0.001, - "orientation_p95_rad": math.radians(1.0), - "required_targets": list(G20_COMBINATION_REQUIRED_TARGET_KEYS), - "observation_counts": observation_counts, - "validation_counts": validation_counts, - }, - } - - -def test_product_config_locks_three_cameras_tags_and_artifact_hashes() -> None: - config = load_product_config(PRODUCT, workspace=REPO, check_can=False) - assert (config.model, config.side, config.tag_layout) == ( - "G20", - "right", - G20_RIGHT_19_LAYOUT, - ) - assert config.calibration_contract.profile.command_count == 20 - assert config.calibration_contract.typed_profile.artifacts.publish_corrected_urdf - assert ( - config.calibration_contract.typed_profile.measurement - .stable_cross_view_cone_bias - ) - hand_profile = config.calibration_contract.profile - typed_measurements = ( - config.calibration_contract.typed_profile.measurement.measurements - ) - assert hand_profile.record_specs["thumb_ip"].pose_axis_line_required is False - assert typed_measurements["thumb_ip"].pose_axis_line_required is False - assert all( - spec.pose_axis_line_required - for name, spec in hand_profile.record_specs.items() - if name != "thumb_ip" - ) - assert config.serial_number == "G20_RIGHT_001" - assert config.namespace == "/g20_calibration" - assert config.required_independent_passes == 1 - assert set(config.cameras) == {"front", "side", "top"} - assert len({camera["serial_number"] for camera in config.cameras.values()}) == 3 - assert config.source_urdf.is_file() - assert config.source_urdf == ( - REPO - / "src/linkerhand_calibration/urdf/g20_right/linkerhand_g20_right.urdf" - ) - assert config.camera_extrinsics.is_file() - assert config.calibration_config == ( - REPO - / "src/linkerhand_calibration/config/three_camera_calibration.yaml" - ) - assert config.tag_config == ( - REPO - / "src/linkerhand_calibration/config/three_camera_tags_g20_right_19.yaml" - ) - product_text = PRODUCT.read_text(encoding="utf-8") - assert "src/g20_thumb_apriltag_calibration/config/" in product_text - - -def test_schema_v2_loads_the_same_registered_product(tmp_path) -> None: - payload = yaml.safe_load(PRODUCT.read_text(encoding="utf-8")) - payload["schema_version"] = 2 - payload["profile_id"] = "G20/right/g20_right_19/v1" - payload.pop("model") - payload.pop("side") - payload.pop("tag_layout") - config_path = tmp_path / "product_v2.yaml" - config_path.write_text( - yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), - encoding="utf-8", - ) - - config = load_product_config(config_path, workspace=REPO, check_can=False) - - assert config.schema_version == 2 - assert config.profile_key.profile_id == "G20/right/g20_right_19/v1" - assert config.calibration_contract.typed_profile.key == config.profile_key - - -_FIXED_BASE_CORNERS_BY_VIEW = { - "front": ( - (100.0, 100.0), - (120.0, 100.0), - (120.0, 120.0), - (100.0, 120.0), - ), - "side": ( - (200.0, 100.0), - (220.0, 100.0), - (220.0, 120.0), - (200.0, 120.0), - ), - "top": ( - (300.0, 100.0), - (320.0, 100.0), - (320.0, 120.0), - (300.0, 120.0), - ), -} - - -def _fixed_base_reference_rows( - corners_by_view: dict[str, tuple[tuple[float, float], ...]] | None = None, -) -> list[dict]: - selected = corners_by_view or _FIXED_BASE_CORNERS_BY_VIEW - return [ - { - "kind": "fixed_base_reference_locked", - "view": view, - "corner_reference_xy": [list(point) for point in corners], - } - for view, corners in selected.items() - ] - - -def _resume_reference_views( - corners_by_view: dict[str, tuple[tuple[float, float], ...]] | None = None, -) -> dict[str, SimpleNamespace]: - selected = corners_by_view or _FIXED_BASE_CORNERS_BY_VIEW - return { - view: SimpleNamespace(locked_base_corners_xy=corners) - for view, corners in selected.items() - } - - -def test_automatic_resume_requires_failed_matching_geometry(tmp_path: Path) -> None: - config = _config(tmp_path) - session = config.session_root / "20260819_150000" - session.mkdir(parents=True) - (session / "raw_samples.jsonl").write_text( - json.dumps( - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "source_urdf_sha256": config.source_urdf_sha256, - } - ) - + "\n" - ) - atomic_write_json( - session / "calibration_summary_zh.json", - { - "result": "FAIL", - "hashes": { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - }, - }, - ) - atomic_session_pointer(config.session_root, "latest_attempt", session) - - assert _automatic_resume_candidate(config) == session - - summary = json.loads( - (session / "calibration_summary_zh.json").read_text() - ) - summary["hashes"]["camera_extrinsics_sha256"] = "0" * 64 - atomic_write_json(session / "calibration_summary_zh.json", summary) - assert _automatic_resume_candidate(config) is None - - -def test_thumb_scope_resolves_passed_base_and_launches_partial_mode( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - session = config.session_root / "20260830_120000" - session.mkdir(parents=True) - (session / "raw_samples.jsonl").write_text("{}\n", encoding="utf-8") - (session / f"g20_right_{config.serial_number}_calibration.json").write_text( - "{}\n", encoding="utf-8" - ) - atomic_write_json( - session / "calibration_summary_zh.json", - { - "result": "PASS", - "quality": {"passed": True}, - "hashes": { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - }, - }, - ) - # Construct an archived fixture, not a new certified publication. - (config.session_root / "latest_passed").symlink_to(session, target_is_directory=True) - - resolved = _resolve_partial_base_session( - config, config.session_root / "latest_passed" - ) - command = _launch_command( - config, - config.session_root / "20260830_130000", - resume_from=resolved, - recalibration_scope="thumb", - ) - - assert resolved == session.resolve() - assert "recalibration_scope:=thumb" in command - assert f"resume_raw_samples_path:={session / 'raw_samples.jsonl'}" in command - - -def test_standalone_thumb_launch_has_no_resume_dependency(tmp_path: Path) -> None: - config = _config(tmp_path) - command = _launch_command( - config, - config.session_root / "20260831_140000", - recalibration_scope="thumb", - ) - - assert "recalibration_scope:=thumb" in command - assert all("resume_raw_samples_path" not in value for value in command) - - -def test_thumb_scope_rejects_base_with_different_geometry(tmp_path: Path) -> None: - config = _config(tmp_path) - session = config.session_root / "20260830_120000" - session.mkdir(parents=True) - (session / "raw_samples.jsonl").touch() - (session / f"g20_right_{config.serial_number}_calibration.json").write_text( - "{}\n", encoding="utf-8" - ) - atomic_write_json( - session / "calibration_summary_zh.json", - { - "result": "PASS", - "quality": {"passed": True}, - "hashes": { - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - }, - }, - ) - - with pytest.raises(ValueError, match="differs from the current product"): - _resolve_partial_base_session(config, session) - - -def test_automatic_resume_accepts_ctrl_c_checkpoint_without_summary( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - session = config.session_root / "20260819_153000" - session.mkdir(parents=True) - (session / "raw_samples.jsonl").write_text( - json.dumps( - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_file": str(config.camera_extrinsics), - } - ) - + "\n", - encoding="utf-8", - ) - atomic_session_pointer(config.session_root, "latest_attempt", session) - - assert not (session / "calibration_summary_zh.json").exists() - assert _automatic_resume_candidate(config) == session - - -def test_automatic_resume_skips_newer_attempt_without_start_checkpoint( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - usable = config.session_root / "20260819_150000" - usable.mkdir(parents=True) - (usable / "raw_samples.jsonl").write_text( - json.dumps( - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "source_urdf_sha256": config.source_urdf_sha256, - } - ) - + "\n", - encoding="utf-8", - ) - summary = { - "result": "FAIL", - "hashes": { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - }, - } - atomic_write_json(usable / "calibration_summary_zh.json", summary) - - interrupted = config.session_root / "20260819_160000" - interrupted.mkdir() - (interrupted / "raw_samples.jsonl").write_text("", encoding="utf-8") - atomic_write_json(interrupted / "calibration_summary_zh.json", summary) - atomic_session_pointer(config.session_root, "latest_attempt", interrupted) - - assert _automatic_resume_candidate(config) == usable - - -def test_runtime_json_is_minimal_v4_with_21_independent_midpoint_curves() -> None: - payload = _payload() - assert payload["schema_version"] == 4 - assert len(payload["joints"]) == 21 - assert all("source_joint" not in joint for joint in payload["joints"].values()) - assert sum("zero_angles" in joint for joint in payload["joints"].values()) == 16 - assert { - name for name, joint in payload["joints"].items() if joint.get("passive") - } == PASSIVE_JOINTS - # midpoint of 0.0011 and 0.0009 rad/u8 - assert payload["joints"]["thumb_cmc_pitch"]["angle_rad"][100] == pytest.approx( - 0.155 - ) - - - - - - - - - - - - - - - - - - - - - - -def test_latest_attempt_pointer_is_atomic_session_binding(tmp_path: Path) -> None: - root = tmp_path / "G20_RIGHT_001" - first = root / "20260819_120000" - second = root / "20260819_130000" - first.mkdir(parents=True) - second.mkdir() - atomic_session_pointer(root, "latest_attempt", first) - assert (root / "latest_attempt").resolve() == first - atomic_session_pointer(root, "latest_attempt", second) - assert (root / "latest_attempt").resolve() == second - - -def test_failure_report_is_copyable_and_persisted(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - session = config.session_root / "20260819_140000" - session.mkdir(parents=True) - status = { - "state": "PAUSED", - "reason": "synchronised_tag_state_timeout", - "feedback_hz": 29.8, - "active": {"joints": ["index_pip"], "valid_frames": 41}, - "views": { - "front": {"ready": True, "missing_tag_ids": []}, - "side": {"ready": False, "missing_tag_ids": [17]}, - "top": {"ready": True, "missing_tag_ids": []}, - }, - } - payload, block = build_failure_report( - config, session, status, reason=status["reason"] - ) - assert payload["error_code"] == "OBS-TAG-103" - assert "请复制以下内容给开发者" in block - assert "会话编号:G20_RIGHT_001_20260819_140000" in block - saved = json.loads((session / "calibration_summary_zh.json").read_text()) - assert saved["quality"]["passed"] is False - - -def test_synchronised_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - session = config.session_root / "20260820_172334" - session.mkdir(parents=True) - status = { - "state": "PAUSED", - "reason": "synchronised_tag_state_timeout:front", - "feedback_hz": 58.8, - "active": {"valid_frames": 381}, - "views": { - "front": { - "ready": True, - "missing_tag_ids": [], - "group_pnp_reason": "group_missing_pose_candidates", - "group_missing_candidate_roles": ["thumb_ip"], - "pnp_rejection_counts": { - "thumb_ip:no_pose_within_reprojection_or_tilt_limit": 12, - }, - "pnp_candidate_diagnostics": { - "thumb_ip": { - "tag_id": 3, - "solved_candidate_count": 2, - "reprojection_candidate_count": 0, - "independent_tilt_candidate_count": 0, - } - }, - }, - "side": {"ready": True, "missing_tag_ids": []}, - "top": {"ready": True, "missing_tag_ids": []}, - }, - } - - payload, _block = build_failure_report( - config, session, status, reason=status["reason"] - ) - - assert payload["error_code"] == "CAM-GEOMETRY-201" - assert "轨迹在运动中失效" in payload["problem_zh"] - assert "整组PnP几何检查拒绝" in payload["explanation_zh"] - assert "缺候选=thumb_ip" in payload["explanation_zh"] - assert payload["pnp_diagnostics"]["front"][ - "group_missing_candidate_roles" - ] == ["thumb_ip"] - assert "不要调整" in payload["automatic_action_zh"] - - -def test_sweep_start_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - session = config.session_root / "20260821_154105" - session.mkdir(parents=True) - status = { - "state": "PAUSED", - "reason": "sweep_start_tag_timeout", - "feedback_hz": 58.8, - "active": {"valid_frames": 0}, - "views": { - "front": {"ready": True, "missing_tag_ids": []}, - "side": { - "ready": True, - "missing_tag_ids": [], - "group_pnp_reason": "group_initializing:7/8", - "pnp_initialization_progress": { - "accepted": 7, - "required": 8, - }, - "group_pnp_rejection_counts": { - "group_normal_alignment": 5, - }, - }, - "top": {"ready": True, "missing_tag_ids": []}, - }, - } - - payload, _block = build_failure_report( - config, session, status, reason=status["reason"] - ) - - assert payload["error_code"] == "CAM-GEOMETRY-201" - assert "PnP" in payload["problem_zh"] - assert "初始化7/8" in payload["explanation_zh"] - assert "group_normal_alignment×5" in payload["explanation_zh"] - - -def test_multiview_sample_failure_has_stable_observation_code() -> None: - code, problem, _suggestion = classify_error( - "sweep_bins_too_few:pinky_mcp_roll_side", {} - ) - - assert code == "OBS-SAMPLE-104" - assert "轨迹不完整" in problem - - -def test_palm_orientation_coverage_has_validation_error_code() -> None: - code, problem, suggestion = classify_error( - "palm_orientation_quality_failed", {} - ) - - assert code == "VAL-QUALITY-501" - assert "方向校正" in problem - assert "至少三枚" in suggestion - - -@pytest.mark.parametrize( - "reason", - ( - "name 'endpoint_zero_offsets' is not defined", - "validated_endpoint_zero_state_incomplete:missing=index_pip;extra=-", - ), -) -def test_endpoint_zero_lifecycle_failure_is_a_publication_error( - reason: str, -) -> None: - code, problem, suggestion = classify_error(reason, {}) - - assert code == "PUB-ARTIFACT-601" - assert "发布阶段" in problem - assert "不要移动相机或Tag" in suggestion - - -def test_fitting_uses_long_watchdog_without_weakening_motion_timeout() -> None: - assert _status_timeout_seconds({"state": "FITTING"}) == 600.0 - assert _status_timeout_seconds({"state": "SWEEP"}) == 90.0 - assert _status_timeout_seconds({"state": "RETURN_BASELINE"}) == 90.0 - - -def test_stopped_status_heartbeat_has_stable_communication_code() -> None: - code, problem, suggestion = classify_error( - "MOTION-COMM-303:calibration status stopped", {} - ) - - assert code == "MOTION-COMM-303" - assert "状态心跳" in problem - assert "断点" in suggestion - - -def test_fixed_base_reference_movement_has_stable_camera_code() -> None: - code, problem, suggestion = classify_error( - "fixed_base_reference_moved", {} - ) - - assert code == "CAM-REFERENCE-204" - assert "Tag" in problem - assert "新会话预检前重新摆放" in suggestion - - -def test_missing_startup_status_has_specific_camera_status_code() -> None: - code, problem, suggestion = classify_error( - "CAM-STATUS-202:no calibration status received", {} - ) - - assert code == "CAM-STATUS-202" - assert "未发布状态" in problem - assert "尚未开始运动" in suggestion - - -def test_sample_domain_contract_has_specific_non_publication_code() -> None: - code, problem, suggestion = classify_error( - "DATA-CONTRACT-701:sample is missing explicit feedback_u8", {} - ) - - assert code == "DATA-CONTRACT-701" - assert "数据契约" in problem - assert "无需重新采集" in suggestion - - -def test_startup_watchdog_detects_only_calibration_child_exit( - tmp_path: Path, -) -> None: - log = tmp_path / "calibration.log" - log.write_text( - "[three_camera_calibration_node-6]: process has died " - "[pid 123, exit code 1]\n", - encoding="utf-8", - ) - assert _calibration_node_exited_before_status(log) is True - - log.write_text( - "[component_container_mt-4]: process has died [exit code -11]\n", - encoding="utf-8", - ) - assert _calibration_node_exited_before_status(log) is False - - -def test_progress_contains_stage_eta_tags_cameras_and_feedback() -> None: - status = { - "state": "SWEEP", - "progress": 0.43, - "feedback_hz": 98.0, - "resume": { - "used": True, - "completed_task_count": 3, - "total_task_count": 16, - }, - "active": { - "joints": ["index_pip"], - "cycle": 2, - "repetitions": 4, - "direction": "increasing", - "current_motion_target_u8": 117, - "actual_u8": 116.5, - "valid_frames": 80, - "automatic_retry_count": 1, - "fit_attempt": 3, - "fit_attempt_limit": 3, - "fit_retry_cycles": [2], - }, - "views": { - view: { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [0], - "detected_tag_ids": [0], - } - for view in ("front", "side", "top") - }, - } - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - assert "43.0%" in text - assert "食指PIP" in text - assert "相机:3/3" in text - assert "反馈:98.0 Hz" in text - assert "当前任务ID:正面[0✓] 侧面[0✓] 顶部[0✓]" in text - assert "断点:已恢复 3/16 个完整任务" in text - assert "首次拟合定位第 2 轮需复核,仅补采对应轮次双向" in text - - -def test_progress_reports_new_start_position_without_claiming_resume() -> None: - status = { - "state": "SWEEP", - "progress": 0.01, - "feedback_hz": 58.8, - "active": {}, - "views": {}, - "resume": { - "used": False, - "checkpoint_requested": True, - "checkpoint_pending": False, - "start_position_policy": ( - "discard_all_tasks_for_new_start_pose" - ), - }, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "标定前机械手位置已变化" in text - assert "未混用旧断点,本次整手从头采集" in text - assert "已恢复" not in text - - -def test_progress_describes_nonlocalized_full_fit_retry() -> None: - status = { - "state": "SWEEP", - "progress": 0.9, - "feedback_hz": 58.0, - "active": { - "joints": ["thumb_cmc_pitch"], - "cycle": 1, - "repetitions": 4, - "direction": "decreasing", - "valid_frames": 20, - "automatic_retry_count": 1, - "fit_attempt": 2, - "fit_attempt_limit": 3, - "fit_retry_cycles": [1, 2, 3, 4], - }, - "views": {}, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "未能定位单一异常轮,联合补采第 1/2/3/4 轮双向" in text - - -def test_preflight_progress_marks_non_base_tag_occlusion_as_allowed() -> None: - status = { - "state": "PREFLIGHT", - "progress": 0.0, - "feedback_hz": 40.5, - "active": {}, - "views": { - "front": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "configured_tag_ids": list(range(8)), - "visible_configured_tag_ids": list(range(6)), - "required_tag_ids": [0], - "detected_tag_ids": [0], - }, - "side": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "configured_tag_ids": list(range(9)), - "visible_configured_tag_ids": list(range(5)), - "required_tag_ids": [4], - "detected_tag_ids": [4], - }, - "top": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "configured_tag_ids": [8, 9], - "visible_configured_tag_ids": [8, 9], - "required_tag_ids": [8], - "detected_tag_ids": [8], - }, - }, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "当前 13/19(允许遮挡)" in text - assert "本阶段必需 3/3" in text - - -def test_sweep_progress_lists_visible_and_missing_task_tag_ids() -> None: - status = { - "state": "SWEEP", - "progress": 0.5, - "feedback_hz": 58.8, - "active": { - "joints": ["ring_pip", "ring_dip"], - "cycle": 1, - "repetitions": 4, - "direction": "decreasing", - }, - "views": { - "front": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [0], - "detected_tag_ids": [0], - }, - "side": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [4, 5, 14], - "detected_tag_ids": [4, 14], - }, - "top": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [8], - "detected_tag_ids": [8], - }, - }, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "Tag:4/5 可见" in text - assert "正面[0✓]" in text - assert "侧面[4✓,5✗,14✓]" in text - assert "顶部[8✓]" in text - assert "✓实时可见/锁=基准锁定/✗不可用" in text - - -def test_multiview_progress_marks_occluded_front_base_as_locked() -> None: - status = { - "state": "PREPARE_SWEEP", - "reason": "waiting_for_task_tags_at_sweep_start", - "progress": 0.562, - "feedback_hz": 58.5, - "active": { - "task_name": "middle_roll_multiview", - "joints": ["middle_mcp_roll", "middle_mcp_roll_side"], - "cycle": 0, - "repetitions": 4, - "direction": "decreasing", - "current_motion_target_u8": 255, - "actual_u8": 254.0, - }, - "views": { - "front": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [0, 12], - "locked_reference_tag_ids": [0], - "detected_tag_ids": [12], - }, - "side": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [4, 15], - "locked_reference_tag_ids": [], - "detected_tag_ids": [4, 15], - "pnp_pose_valid": False, - "pnp_initialization_progress": { - "accepted": 5, - "required": 8, - }, - "pnp_rejection_counts": { - "middle_pip:no_pose_within_reprojection_or_tilt_limit": 7, - }, - }, - "top": { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "required_tag_ids": [8], - "locked_reference_tag_ids": [], - "detected_tag_ids": [8], - }, - }, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "已到扫描起点,等待任务Tag" in text - assert "命令/反馈:255/254.0" in text - assert "Tag:5/5 可见(含锁定 1)" in text - assert "正面[0锁,12✓]" in text - assert "预计剩余 等待Tag" in text - assert "PnP:侧面 初始化5/8" in text - assert "Tag可见不等于三维位姿有效" in text - - -def test_device_preflight_progress_says_tags_are_checked_after_recovery() -> None: - status = { - "state": "PREFLIGHT", - "preflight_mode": "device_only_before_baseline", - "progress": 0.0, - "feedback_hz": 58.9, - "active": {}, - "views": { - view: { - "camera_info_valid": True, - "camera_extrinsics_valid": True, - "configured_tag_ids": list(range(6)), - "visible_configured_tag_ids": list(range(3)), - "required_tag_ids": [0], - "detected_tag_ids": [], - } - for view in ("front", "side", "top") - }, - } - - text = render_progress_zh( - "G20_RIGHT_001", status, ProgressEstimator(started_at=0.0) - ) - - assert "阶段:设备连接预检" in text - assert "Tag:当前 9/18(基准恢复后检查)" in text - assert "本阶段必需" not in text - - -def test_combination_kinematics_can_use_independent_passive_curve() -> None: - config = load_product_config(PRODUCT, workspace=REPO, check_can=False) - model = UrdfKinematicModel(config.source_urdf) - angles = {"thumb_mcp": 0.4, "thumb_ip": 0.2} - independent = model.link_transform( - "thumb_ip", - zero_offsets={}, - joint_angles=angles, - independent_mimic_angles=True, - ) - mimic = model.link_transform( - "thumb_ip", - zero_offsets={}, - joint_angles=angles, - independent_mimic_angles=False, - ) - assert not np.allclose(independent, mimic) diff --git a/src/linkerhand_calibration/test/test_golden_regression.py b/src/linkerhand_calibration/test/test_golden_regression.py deleted file mode 100644 index 7e1a367..0000000 --- a/src/linkerhand_calibration/test/test_golden_regression.py +++ /dev/null @@ -1,72 +0,0 @@ -import hashlib -import json -from pathlib import Path - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.golden_regression import ( - validate_golden_sessions, -) - - -def _write(path: Path, value: str) -> str: - path.write_text(value, encoding="utf-8") - return hashlib.sha256(value.encode("utf-8")).hexdigest() - - -def test_static_golden_validation_covers_pass_fail_and_partial_scopes( - tmp_path: Path, -) -> None: - passed = { - "20260830_181154": ("full", {}), - "20260831_141123": ( - "thumb", - {"preserved_certified_zero_joints": [f"joint_{i}" for i in range(12)]}, - ), - "20260831_163843": ("thumb", {"standalone": True}), - } - for session_id, (scope, extras) in passed.items(): - session = tmp_path / session_id - session.mkdir() - payload = ( - {"artifact_type": "g20_right_standalone_thumb_calibration"} - if extras.pop("standalone", False) - else {"artifact_type": "full"} - ) - json_text = json.dumps(payload) + "\n" - json_hash = _write(session / "calibration.json", json_text) - urdf_hash = _write(session / "corrected.urdf", f"<{session_id}/>\n") - summary = { - "result": "PASS", - "calibration_scope": scope, - "artifacts": {"json": "calibration.json", "urdf": "corrected.urdf"}, - "hashes": { - "calibration_json_sha256": json_hash, - "corrected_urdf_sha256": urdf_hash, - }, - **extras, - } - (session / "calibration_summary_zh.json").write_text( - json.dumps(summary), encoding="utf-8" - ) - - failures = { - "20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"), - "20260831_142322": ( - "VAL-QUALITY-501", - "zero_model_validation_failed", - ), - } - for session_id, (code, reason) in failures.items(): - session = tmp_path / session_id - session.mkdir() - (session / "calibration_summary_zh.json").write_text( - json.dumps( - {"result": "FAIL", "error_code": code, "reason": reason} - ), - encoding="utf-8", - ) - - (tmp_path / "latest_passed").symlink_to("20260830_181154") - result = validate_golden_sessions(tmp_path, replay_full_session=False) - - assert result["20260830_181154"]["result"] == "PASS" - assert result["20260831_111837"]["error_code"] == "FIT-MODEL-401" diff --git a/src/linkerhand_calibration/test/test_held_joint_replay.py b/src/linkerhand_calibration/test/test_held_joint_replay.py deleted file mode 100644 index d75a405..0000000 --- a/src/linkerhand_calibration/test/test_held_joint_replay.py +++ /dev/null @@ -1,86 +0,0 @@ -"""A held ancestor's hysteresis is part of the physical FK condition.""" - -from types import SimpleNamespace as NS - -import numpy as np -import pytest - -from linkerhand_calibration.core.domain.result import JointMapping -from linkerhand_calibration.core.fitting.tag_installation import TagInstallationFailure, matrix_tuple -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.core.urdf.tag_acceptance import validate_serialized_tag_holdout -from linkerhand_calibration.runtime.artifacts.evidence import prepare_tag_replay -from test_frozen_tag_replay import transform - - -def virtual_capture(tmp_path): - source = tmp_path / "source.urdf" - source.write_text(''' - - - - - - ''') - tasks = tuple(NS(key=name, joints=(name,), command_index=i, start_value=0., end_value=1.) - for i, name in enumerate(("root", "tip"))) - profile = NS(retained_joints=frozenset(), command=NS(unit="rad", urdf_joint_by_joint={}), - measurement=NS(measurements={name:NS(view="front", child_role=name, kind="relative_rotation") - for name in ("root", "tip")}), motion=NS(tasks=tasks), - vision=NS(views=(NS(name="front", tags=( - NS(role="root", link="proximal"), NS(role="tip", link="distal"))),))) - mappings = {name: JointMapping(name,i,"feedback_rad",(0.,1.),(0.,1.),(delta,1+delta),(-delta,1-delta)) - for i,(name,delta) in enumerate((("root",.08),("tip",.025)))} - base=transform((.1,-.2,.3),(.1,.2,.7)) - mounts={"root":transform((.2,.3,-.1),(.01,.02,.03)), - "tip":transform((-.3,.1,.2),(.015,.01,.025))} - rows=[] - for cycle in range(4): - for task in tasks: - for direction in ("increasing","decreasing"): - held="increasing" if (cycle+(direction=="increasing"))%2 else "decreasing" - for i,x in enumerate(np.linspace(.05,.95,50)): - sdk=[.45,.55];sdk[task.command_index]=float(x) - directions=[held,held];directions[task.command_index]=direction - # Elementary ground truth, independent of URDF FK and fit. - root=sdk[0]+(.08 if directions[0]=="increasing" else -.08) - tip=sdk[1]+(.025 if directions[1]=="increasing" else -.025) - pose=transform((0,0,root)) - if task.key=="tip":pose=pose@transform(xyz=(.04,0,.05))@transform((0,tip,0)) - rows.append({"joint":task.key,"task_name":task.key,"view":"front","cycle":cycle, - "sample_id":f"front:{task.key}:{cycle}:{direction}:{i}","direction":direction, - "state_rad":sdk,"command_vector_rad":sdk,"command_direction_by_index":directions, - "child_pose_common":matrix_tuple(base@pose@mounts[task.key])}) - return profile,source,mappings,base,rows - - -def test_held_direction_is_preserved_through_training_and_final_urdf_replay(tmp_path): - profile,source,mappings,base,rows=virtual_capture(tmp_path) - args=dict(profile=profile,source_model=UrdfKinematicModel(source), - plan=NS(mimic_output={},zero_offsets_rad={}),output_mappings=mappings,common_from_base=base) - # Dropping known arrival branches falsely makes a perfectly rigid Tag - # appear to move by >4 degrees between captures of its child joint. - without_history=[{k:v for k,v in row.items() if k!="command_direction_by_index"} for row in rows] - with pytest.raises(TagInstallationFailure): - prepare_tag_replay(**args,records=without_history) - replay=prepare_tag_replay(**args,records=rows) - assert max(m.training_rotation_p95_deg for m in replay.installations.values())<1e-6 - payload={"schema_version":7,"profile_id":"synthetic","curve_input_domain":"feedback_rad", - "joints":{name:{"urdf_joint":name,"motor_index":m.motor_index, - "curve_input_knots_rad":list(m.knots),"angle_rad":list(m.angle_rad), - "increasing_rad":list(m.increasing_rad),"decreasing_rad":list(m.decreasing_rad)} - for name,m in mappings.items()}} - metrics=validate_serialized_tag_holdout(corrected_urdf=source,payload=payload, - common_from_base=replay.common_from_base,installations=replay.installations, - observations=replay.holdout,required_roles=replay.required_roles) - assert max(r["maximum_deg"] for r in metrics.values())<1e-6 - - -@pytest.mark.parametrize("history", [["increasing"], ["increasing","invalid"], "increasing"]) -def test_partial_or_invalid_direction_history_cannot_silently_use_mean(tmp_path, history): - profile,source,mappings,base,rows=virtual_capture(tmp_path) - rows[0]["command_direction_by_index"]=history - with pytest.raises(ValueError,match="invalid SDK direction history"): - prepare_tag_replay(profile=profile,source_model=UrdfKinematicModel(source), - plan=NS(mimic_output={},zero_offsets_rad={}),output_mappings=mappings, - common_from_base=base,records=rows) diff --git a/src/linkerhand_calibration/test/test_hikrobot_camera.py b/src/linkerhand_calibration/test/test_hikrobot_camera.py deleted file mode 100644 index fc75b61..0000000 --- a/src/linkerhand_calibration/test/test_hikrobot_camera.py +++ /dev/null @@ -1,136 +0,0 @@ -from pathlib import Path -from ctypes import c_uint8 -from threading import Event -from types import SimpleNamespace -from unittest.mock import Mock - -import pytest -import yaml - -from linkerhand_calibration.hikrobot_camera import ( - DeviceDescriptor, - HikrobotCameraNode, - decode_c_string, - load_camera_calibration, - resolve_camera_info_path, - select_device, -) - - -def test_decode_c_string_stops_at_first_null() -> None: - assert decode_c_string(b"DB2163742\0ignored") == "DB2163742" - - -def test_select_device_accepts_serial_or_guid() -> None: - devices = [ - DeviceDescriptor( - index=0, - model="MV-CS020-10UM", - serial="DB2163742", - guid="2BDFB2163742", - ), - DeviceDescriptor( - index=1, - model="MV-CS020-10UM", - serial="DB2163739", - guid="2BDFB2163739", - ), - ] - - assert select_device(devices, "DB2163742", "MV-CS020-10UM").index == 0 - assert select_device(devices, "2BDFB2163739", "MV-CS020-10UM").index == 1 - - -def test_select_device_never_guesses_when_multiple_cameras_exist() -> None: - devices = [ - DeviceDescriptor(0, "MV-CS020-10UM", "one", "guid-one"), - DeviceDescriptor(1, "MV-CS020-10UM", "two", "guid-two"), - ] - - with pytest.raises(RuntimeError, match="selector is required"): - select_device(devices, "", "MV-CS020-10UM") - - -def test_select_device_rejects_wrong_model() -> None: - devices = [DeviceDescriptor(0, "other", "DB2163742", "guid")] - - with pytest.raises(RuntimeError, match="expected a model"): - select_device(devices, "DB2163742", "MV-CS020-10UM") - - -def test_load_standard_camera_calibration(tmp_path: Path) -> None: - path = tmp_path / "camera.yaml" - path.write_text( - yaml.safe_dump( - { - "image_width": 1624, - "image_height": 1240, - "camera_name": "hikrobot_front_DB2163742", - "camera_matrix": { - "rows": 3, - "cols": 3, - "data": [1000.0, 0.0, 812.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 1.0], - }, - "distortion_model": "plumb_bob", - "distortion_coefficients": { - "rows": 1, - "cols": 5, - "data": [0.1, -0.2, 0.0, 0.0, 0.1], - }, - "rectification_matrix": { - "rows": 3, - "cols": 3, - "data": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], - }, - "projection_matrix": { - "rows": 3, - "cols": 4, - "data": [1000.0, 0.0, 812.0, 0.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 0.0, 1.0, 0.0], - }, - }, - sort_keys=False, - ), - encoding="utf-8", - ) - - calibration = load_camera_calibration(path) - - assert calibration.width == 1624 - assert calibration.height == 1240 - assert calibration.k[0] == 1000.0 - assert calibration.p[5] == 1001.0 - - -def test_camera_info_url_only_accepts_local_files(tmp_path: Path) -> None: - path = resolve_camera_info_path(str(tmp_path / "front.yaml")) - assert path == (tmp_path / "front.yaml").resolve() - assert resolve_camera_info_path("") is None - with pytest.raises(ValueError, match="filesystem path"): - resolve_camera_info_path("package://example/front.yaml") - - -def test_grab_loop_retries_clock_failures_without_publishing_or_exiting(monkeypatch): - stop = Event() - waits = [] - monkeypatch.setattr(stop, 'wait', lambda seconds: waits.append(seconds)) - timing = Mock() - timing.refresh_if_due.side_effect = [ValueError('clock mismatch'), ValueError('latch failed'), None] - camera, logger = Mock(), Mock() - camera.MV_CC_GetImageBuffer.return_value = 0 - camera.MV_CC_FreeImageBuffer.return_value = 0 - published = [] - - def publish(frame): - assert timing.refresh_if_due.call_count == 3 - assert timing.invalidate.call_count == 2 - published.append(frame) - stop.set() - - node = SimpleNamespace(_camera=camera, _mvs=SimpleNamespace(MV_FRAME_OUT=c_uint8), - _camera_timing=timing, _stop_event=stop, - get_parameter=lambda name: SimpleNamespace(value=1000), - get_logger=lambda: logger, _publish_frame=publish) - HikrobotCameraNode._grab_loop(node) - assert len(published) == 1 and waits == [.2, .2] - assert camera.MV_CC_GetImageBuffer.call_count == camera.MV_CC_FreeImageBuffer.call_count == 1 - assert logger.error.call_count == logger.info.call_count == 1 diff --git a/src/linkerhand_calibration/test/test_image_bundle_optimizer.py b/src/linkerhand_calibration/test/test_image_bundle_optimizer.py deleted file mode 100644 index 2e5f2d5..0000000 --- a/src/linkerhand_calibration/test/test_image_bundle_optimizer.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Finite numerical recovery, real acquisition replay, and unchanged refusal gates.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.sparse import eye - -from linkerhand_calibration.core.geometry.tag_pose import image_bundle_optimizer as optimizer -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ImageMotionParameters - - -def result(status, cost=2., nfev=150, x=(.5,)): - return SimpleNamespace(status=status, success=status > 0, cost=cost, nfev=nfev, - x=np.array(x), fun=np.array([1.]), optimality=.1) - - -@pytest.mark.parametrize('terminal', ['exhausted', 'exception', 'worse', 'nonfinite']) -def test_unresolved_recovery_never_authorizes_or_repeats(monkeypatch, terminal): - first = result(0) - calls = [] - def solve(fun, initial, **kwargs): - calls.append((initial.copy(), kwargs)) - if len(calls) == 1: - return first - if terminal == 'exception': - raise ValueError('numerical failure') - return result(0 if terminal == 'exhausted' else 2, - cost=3. if terminal == 'worse' else 1., nfev=50, - x=(np.nan,) if terminal == 'nonfinite' else (.4,)) - monkeypatch.setattr(optimizer, 'least_squares', solve) - fit = optimizer.solve_image_bundle(lambda x: x, np.array([.9]), - sparsity=eye(1), scale=np.ones(1), limits=ImageMotionParameters(), bounds=([0.], [1.])) - assert not fit.success and fit.status == 0 - assert len(calls) == len(fit.image_solver_attempts) == 2 - assert calls[1][0] == first.x - assert calls[1][1]['tr_solver'] == 'exact' - assert calls[1][1]['max_nfev'] == 50 - assert calls[0][1]['bounds'] == calls[1][1]['bounds'] - for tolerance in ('ftol', 'xtol', 'gtol'): - assert calls[0][1][tolerance] == calls[1][1][tolerance] - - -@pytest.mark.parametrize('case', ['converged', 'large_graph', 'disabled', 'nonfinite']) -def test_ineligible_retry_does_not_spend_more_budget(monkeypatch, case): - first = result(2 if case == 'converged' else 0, - x=tuple(np.zeros(optimizer.MAXIMUM_DENSE_PARAMETERS+1)) if case == 'large_graph' else (np.nan if case == 'nonfinite' else .5,)) - calls = [] - monkeypatch.setattr(optimizer, 'least_squares', lambda *a, **k: calls.append(k) or first) - fit = optimizer.solve_image_bundle(lambda x: x, np.zeros(len(first.x)), - sparsity=eye(len(first.x)), scale=np.ones(len(first.x)), - limits=ImageMotionParameters(maximum_solver_recovery_evaluations=0 if case == 'disabled' else 50)) - assert fit is first and len(calls) == 1 and len(fit.image_solver_attempts) == 1 - - -@pytest.fixture(scope='module') -def exhausted_ip(): - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime.passed_resume import restore_parent_models - from o30_capture_fixture import SOURCE - data = json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_thumb_ip_solver_budget.json.gz').read_bytes())) - rows, failure = data['records'], data['original_failure'] - profile = load_bundled_hand_profile('o30_right_18') - owner = BranchInitialization(profile, source_urdf=SOURCE) - restore_parent_models(owner.parent_references, - [r for r in rows if r['kind'] == 'motion_branch_initialization'], failure['session_epoch']) - owner.parent_references.verified[(failure['session_epoch'], failure['task_name'])] = next( - r for r in rows if r['kind'] == 'parent_reference_verified') - motion = MotionCommand('zero_approach', profile.motion.joint_zero_references['thumb_ip'].command, - 200., task_key=failure['task_name'], reference_joints=('thumb_ip',)) - owner.begin(motion, session_epoch=failure['session_epoch'], motion_version=failure['motion_version']) - for row in rows: - if row['kind'] == 'motion_branch_observation' and row['task_name'] == failure['task_name']: - owner.observe(row) - request = owner.request('front', replace(motion, phase='joint_zero', zero_joints=('thumb_ip',)), - session_epoch=failure['session_epoch'], motion_version=failure['motion_version']+1) - assert not request.invalid_reason - assert tuple(f.evidence.stamp_ns for f in request.image_frames) == tuple(failure['source_image_stamps']) - return data, request, solve_initialization(request) - - -@pytest.mark.replay -def test_real_exhausted_candidate_is_solved_then_rejected_by_original_pixel_gate(exhausted_ip): - from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion - from linkerhand_calibration.runtime.branch_initialization import initialization_artifacts - from linkerhand_calibration.runtime.motion_provenance import _check_measured_transfer_report - from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import image_motion_model_from_dict, image_model_payload - data, request, resolved = exhausted_ip - assert data['original_failure']['hypotheses'][1]['training_solver_status'] == 0 - assert resolved.resolved, resolved.reason - winner, alternative = resolved.hypotheses - assert winner.training_accepted and len(winner.training_solver_attempts) == 1 - assert alternative.converged and not alternative.training_accepted - assert alternative.reason == 'image_motion_reprojection_failed' - assert [a.backend for a in alternative.training_solver_attempts] == ['sparse_lsmr', 'dense_exact'] - assert alternative.training_solver_attempts[0].status == 0 - assert alternative.training_solver_attempts[1].status > 0 - assert alternative.training_solver_evaluations <= 200 - assert winner.validation_quality[0].maximum_frame_rms_px < .34 - assert alternative.validation_quality[0].maximum_frame_rms_px > 1.5 - assert resolved.model.maximum_reprojection_error_px == 1.5 - assert set(resolved.model.training_stamps_ns).isdisjoint(resolved.model.validation_stamps_ns) - _, report = initialization_artifacts(request, resolved) - _check_measured_transfer_report(report) - assert image_motion_model_from_dict(report['frozen_image_model']) == resolved.model - for payload in report['evidence_payloads'].values(): - assert payload['image_solver_policy'] == optimizer.IMAGE_BUNDLE_SOLVER_POLICY - assert payload['image_solver_attempts'] == report['image_solver_attempts'] - # Audit replays the same raw pixels with the same deterministic two-stage solver. - replayed = resolve_image_motion(request.image_frames, request.relations, - geometry_constraints=request.geometry_constraints, source_hinges=request.source_hinges, - source_urdf_sha256=request.source_urdf_sha256) - assert replayed.resolved and image_model_sha256(image_model_payload(replayed.model)) == image_model_sha256(image_model_payload(resolved.model)) - - -@pytest.mark.replay -@pytest.mark.parametrize('eligible_winner', [True, False]) -def test_budget_exhaustion_still_vetoes_without_motion_retry(exhausted_ip, eligible_winner): - from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver - from linkerhand_calibration.runtime.zero_recovery import failure_category, FailureCategory, permits_recovery - from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh - _, request, resolved = exhausted_ip - winner, alternative = resolved.hypotheses - winner = replace(winner, training_accepted=eligible_winner) - alternative = replace(alternative, converged=False, reason='image_motion_optimizer_unresolved', - training_solver_status=0, training_accepted=False) - validation = [i for i, f in enumerate(request.image_frames) - if f.evidence.stamp_ns in resolved.model.validation_stamps_ns] - outcome = solver.select_image_hypotheses(request.image_frames, validation, - [winner, alternative], [resolved.model, resolved.model], - [np.zeros(len(validation))]*2, ImageMotionParameters()) - assert not outcome.resolved and outcome.model is None - assert outcome.reason == 'image_motion_solver_budget_exhausted' - assert failure_category(outcome.reason) == FailureCategory.NUMERICAL_SOLVER - assert not permits_recovery([outcome.reason], 0) - assert reason_zh({'reason': 'joint_zero_motion_unresolved:'+outcome.reason}, model_name='O30')[0] == 'OBS-SOLVER-118' - - -@pytest.mark.replay -def test_recovered_real_preparation_passes_formal_source_and_parent_contracts(exhausted_ip): - from linkerhand_calibration.runtime.branch_initialization import initialization_artifacts - from linkerhand_calibration.runtime.motion_provenance import _initializations, validate_source_geometry - from linkerhand_calibration.profiles import load_bundled_hand_profile - from o30_capture_fixture import SOURCE - data, request, resolved = exhausted_ip - _, report = initialization_artifacts(request, resolved) - records = json.loads(json.dumps([*data['records'], report])) - profile = load_bundled_hand_profile('o30_right_18') - assert _initializations(records) - validate_source_geometry(profile, SOURCE, records) - - -@pytest.mark.replay -def test_solver_audit_tampering_rejected_before_source_replay(exhausted_ip): - from linkerhand_calibration.runtime.branch_initialization import initialization_artifacts - from linkerhand_calibration.runtime.motion_provenance import _initializations - _, request, resolved = exhausted_ip - _, report = initialization_artifacts(request, resolved) - for field in ('image_solver_attempts', 'image_solver_policy'): - changed = deepcopy(report) - changed[field] = [] if field == 'image_solver_attempts' else 'ignore_unfinished' - with pytest.raises(ValueError, match='solver_report_changed'): - _initializations([changed]) - changed = json.loads(json.dumps(report)) - changed['hypotheses'][1]['training_solver_attempts'][1]['status'] = 0 - with pytest.raises(ValueError, match='solver_report_changed'): - _initializations([changed]) - changed = deepcopy(report) - changed.pop('image_solver_policy') - changed.pop('image_solver_attempts') - with pytest.raises(ValueError, match='solver_report_changed'): - _initializations([changed]) diff --git a/src/linkerhand_calibration/test/test_image_error_budget.py b/src/linkerhand_calibration/test/test_image_error_budget.py deleted file mode 100644 index 1445a6e..0000000 --- a/src/linkerhand_calibration/test/test_image_error_budget.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Independent truth separates measurement noise from motion approximation.""" - -from dataclasses import replace -import math - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.tag_installation import FrozenTagInstallation -from linkerhand_calibration.core.urdf.image_acceptance import ( - ImageHoldoutFailure, TagImageObservation, validate_serialized_image_holdout, -) -from linkerhand_calibration.core.urdf.image_consistency import _rigid_projection -from linkerhand_calibration.core.urdf.tag_acceptance import TagHoldout, validate_serialized_tag_holdout - - -def known_scene(tmp_path, *, focal, error_deg=.5, translation_m=0.): - source = tmp_path / "known.urdf" - source.write_text(''' - - - ''') - payload = dict(schema_version=7, profile_id="KNOWN/right/one/v1", curve_input_domain="feedback_rad", - joints={"drive": dict(urdf_joint="drive", motor_index=0, - curve_input_knots_rad=[0., 1.], angle_rad=[0., 1.])}) - base, mount = np.eye(4), np.eye(4) - base[2, 3], mount[0, 3] = .8, .1 - frozen = FrozenTagInstallation("tip", "finger", tuple(map(tuple, mount)), ("train:0",), 0., 0.) - objects = np.array([[-.008, .008, 0], [.008, .008, 0], [.008, -.008, 0], [-.008, -.008, 0]]) - camera = np.array([[focal, 0, 812.], [0, focal, 620.], [0, 0, 1.]]) - poses, images = [], [] - for index, sdk in enumerate(np.linspace(.1, .9, 40)): - angle = float(sdk) + math.radians(error_deg) - rotation = np.array([[math.cos(angle), -math.sin(angle), 0], - [math.sin(angle), math.cos(angle), 0], [0, 0, 1.]]) - pose = np.eye(4) - pose[:3, :3] = rotation - pose[:3, 3] = [.1*math.cos(angle)+translation_m, .1*math.sin(angle), .8] - row = TagHoldout(f"camera:{index}", "tip", 3, (float(sdk),), {}, tuple(map(tuple, pose))) - points = (objects @ rotation.T + pose[:3, 3]) @ camera.T - corners = points[:, :2] / points[:, 2:] - poses.append(row) - images.append(TagImageObservation(row.sample_id, row.role, 3, row.sdk_values, (), - "camera", .016, tuple(map(tuple, corners)), tuple(map(tuple, camera)), - tuple(map(tuple, np.eye(4))), task_name="drive")) - return dict(corrected_urdf=source, payload=payload, common_from_base=base, - installations={"tip": frozen}, required_roles=("tip",), observations=images, - independent_observations=poses, input_kind="feedback", holdout_cycle=3, minimum_samples=40) - - -@pytest.mark.parametrize("focal", [1000., 3500.]) -def test_same_allowed_physical_error_does_not_depend_on_focal_length(tmp_path, focal): - scene = known_scene(tmp_path, focal=focal) - physical = {key: value for key, value in scene.items() if key not in {"observations", "independent_observations"}} - physical["observations"] = scene["independent_observations"] - validate_serialized_tag_holdout(**physical) - metrics = validate_serialized_image_holdout(**scene)["tip"] - assert metrics["independent_motion"]["mae_deg"] == pytest.approx(.5) - assert metrics["maximum_px"] < 1e-8 - assert (metrics["nominal_projection"]["maximum_px"] > 1.5) == (focal == 3500.) - - -@pytest.mark.parametrize("error", [dict(error_deg=4.), dict(error_deg=0., translation_m=.004)]) -def test_perfect_measurement_pixels_cannot_bypass_physical_accuracy(tmp_path, error): - scene = known_scene(tmp_path, focal=3500., **error) - with pytest.raises(ImageHoldoutFailure) as caught: - validate_serialized_image_holdout(**scene) - report = caught.value.report["tip"] - assert report["maximum_px"] < 1e-8 - assert not report["independent_motion"]["passed"] - - -def test_extra_view_witness_is_separate_from_independent_accuracy(tmp_path): - scene = known_scene(tmp_path, focal=3500.) - extra = replace(scene["observations"][0], sample_id="camera:extra", task_name="another_motion") - scene["observations"].append(extra) - report = validate_serialized_image_holdout(**scene)["tip"] - assert report["count"] == 41 - assert report["independent_motion"]["count"] == 40 - witness = report["auxiliary_consistency_by_task"]["another_motion"] - assert witness["passed"] and witness["maximum_px"] <= 1.5 - assert witness["interpretation"] == "feasible_pose_displacement_only" - - -def test_stationary_images_cannot_dilute_inconsistent_motion(tmp_path): - scene = known_scene(tmp_path, focal=3500., error_deg=0.) - original = scene["observations"][0] - scene["observations"].extend(replace(original, sample_id=f"camera:static:{i}", - task_name="stationary") for i in range(100)) - scene["observations"].append(replace(original, sample_id="camera:bad", task_name="moving", - corners_xy=tuple((x+80, y) for x, y in original.corners_xy))) - with pytest.raises(ImageHoldoutFailure) as caught: - validate_serialized_image_holdout(**scene) - report = caught.value.report["tip"] - assert report["independent_motion"]["passed"] - assert report["auxiliary_consistency_by_task"]["stationary"]["passed"] - assert not report["auxiliary_consistency_by_task"]["moving"]["passed"] - - -@pytest.mark.parametrize("mutation", ["missing_pose", "sdk", "direction", "duplicate_pose"]) -def test_independent_pose_identity_and_inputs_cannot_be_replaced_by_witnesses(tmp_path, mutation): - scene = known_scene(tmp_path, focal=3500.) - rows = scene["independent_observations"] - if mutation == "missing_pose": rows.pop() - elif mutation == "sdk": rows[0] = replace(rows[0], sdk_values=(.2,)) - elif mutation == "direction": rows[0] = replace(rows[0], sdk_directions_by_joint={"drive": "increasing"}) - else: rows.append(rows[0]) - with pytest.raises(ValueError, match="final_image_holdout"): - validate_serialized_image_holdout(**scene) - - -@pytest.mark.parametrize("x", [np.zeros(6), np.array([.6, -.3, .2, -.8, .2, .5])]) -def test_rigid_projection_jacobian_matches_independent_finite_differences(x): - pose = np.eye(4) - pose[:3, :3] = Rotation.from_euler("xyz", [.3, -.6, .2]).as_matrix() - pose[:3, 3] = [.08, -.03, .5] - camera = np.array([[3500., 4., 820.], [0, 3600., 650.], [0, 0, 1.]]) - objects = np.array([[-.008, .008, 0], [.008, .008, 0], [.008, -.008, 0], [-.008, -.008, 0]]) - args = (pose, camera, objects, np.zeros((4, 2)), np.deg2rad(3.), .003) - _, analytic = _rigid_projection(x, *args) - numeric = np.stack([(_rigid_projection(x+step, *args)[0]-_rigid_projection(x-step, *args)[0]) / 2e-5 - for step in np.eye(6)*1e-5], axis=-1) - np.testing.assert_allclose(analytic, numeric, rtol=2e-6, atol=2e-8) diff --git a/src/linkerhand_calibration/test/test_image_model_selection.py b/src/linkerhand_calibration/test/test_image_model_selection.py deleted file mode 100644 index 1dfb526..0000000 --- a/src/linkerhand_calibration/test/test_image_model_selection.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Selection stays independent of holdout quality, order and duplicate starts.""" -from dataclasses import replace -import gzip -from itertools import permutations -import json -from pathlib import Path - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.tag_pose.image_model_selection import ( - compare_image_losses, equivalent_frame_selections, holm_adjusted_probabilities, - training_candidate_index, -) -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionHypothesis -from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageRoleQuality - - -def hypothesis(training, validation, reason=""): - return ImageMotionHypothesis((("root", 0), ("moving", 0)), True, reason, - (ImageRoleQuality("moving", 30, training, training, training),), - (ImageRoleQuality("moving", 30, validation, validation, validation),), training_accepted=True) - - -def test_holdout_cannot_select_or_replace_training_candidate(): - candidates = (hypothesis(.2, 1.6, "image_motion_reprojection_failed"), hypothesis(.3, .1)) - assert training_candidate_index(candidates) == 0 - changed = tuple(replace(h, validation_quality=(), reason="holdout_unavailable") for h in candidates) - assert training_candidate_index(changed) == 0 - assert training_candidate_index(tuple(replace(h, training_accepted=False) for h in candidates)) is None - - -def test_failed_selected_holdout_vetoes_resolver_without_trying_runner_up(monkeypatch): - from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver - from test_motion_image_diagnostics import frames, RELATIONS - - outcomes = iter(((hypothesis(.2, 1.6, "image_motion_reprojection_failed"), object(), np.array([1.6])), - (hypothesis(.3, .1), object(), np.array([.1])))) - monkeypatch.setattr(solver, "_fit_hypothesis", lambda *args, **kwargs: next(outcomes)) - result = solver.resolve_image_motion(frames(alternatives=True), RELATIONS) - assert not result.resolved and result.model is None - assert result.reason == "image_motion_no_observable_image_model" - - -def test_validation_exception_preserves_training_identity(monkeypatch): - from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver - from linkerhand_calibration.core.geometry.tag_pose.motion_image_solver import ImageHingeBundle - from test_motion_image_diagnostics import frames, RELATIONS - - def fail(*args, **kwargs): - raise ValueError("validation_failed") - monkeypatch.setattr(ImageHingeBundle, "validate", fail) - result = solver.resolve_image_motion(frames(), RELATIONS) - assert not result.resolved - assert result.hypotheses[0].training_accepted - assert result.hypotheses[0].reason == "validation_failed" - - -@pytest.mark.parametrize("values,expected", [ - ([(0, .008), (1, 0.), (2, 0.)], {0: .008, 1: 0., 2: 0.}), - ([(0, .008), (1, .009), (2, 0.)], {0: .016, 1: .016, 2: 0.}), - ([(0, .001), (1, .005), (2, .008)], {0: .003, 1: .01, 2: .01}), -]) -def test_holm_preserves_family_level_and_is_order_independent(values, expected): - for ordering in permutations(values): - assert dict(holm_adjusted_probabilities(ordering)) == pytest.approx(expected) - - -def test_holm_rejects_invalid_probabilities_and_duplicate_ids(): - assert dict(holm_adjusted_probabilities([(0, float("nan")), (1, -.1), (2, 1.1)])) == {0: 1., 1: 1., 2: 1.} - assert holm_adjusted_probabilities([]) == () - with pytest.raises(ValueError, match="duplicate"): - holm_adjusted_probabilities([(0, .01), (0, .02)]) - - -def test_comparison_keeps_pixel_margin_and_rejects_isolated_outlier(): - angles = np.linspace(0., 1.2, 72)[:, None] - baseline = np.full(72, .3) - assert compare_image_losses(baseline, baseline + .02, angles, .03) == 1. - assert compare_image_losses(baseline, baseline + .2, angles, .03) < .001 - outlier = baseline.copy() - outlier[-2:] += 20 - assert compare_image_losses(baseline, outlier, angles, .03) > .01 - assert compare_image_losses(baseline, baseline + .2, np.zeros_like(angles), .03) == 1. - assert compare_image_losses(baseline, baseline + np.nan, angles, .03) == 1. - # Exact image repetitions must not count as new motion evidence. - repeated = compare_image_losses(np.repeat(baseline, 4), np.repeat(baseline + .2, 4), - np.repeat(angles, 4, axis=0), .03) - assert repeated == compare_image_losses(baseline, baseline + .2, angles, .03) - - -def test_equivalence_matches_roles_and_rejects_missing_or_duplicate_roles(): - from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionFrameSelection - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRoleSelection - from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - - pose = SquareTagPose((0., 0., 0., 1.), (0., 0., 1.), .1) - first = ImageMotionFrameSelection(True, "", (MotionRoleSelection("root", pose), MotionRoleSelection("tip", pose))) - assert equivalent_frame_selections(first, replace(first, selections=first.selections[::-1])) - assert not equivalent_frame_selections(first, replace(first, selections=first.selections[:1])) - assert not equivalent_frame_selections(first, replace(first, selections=(first.selections[0],)*2)) - assert not equivalent_frame_selections(first, replace(first, resolved=False)) - - -@pytest.mark.parametrize("attempt,frames_count", [(0, 96), (1, 95)]) -def test_recorded_side_attempts_pass_with_original_geometry_and_quality_limits(attempt, frames_count): - from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion - from test_production_image_motion import recorded_image_frames - - fixture = Path(__file__).parent / "fixtures/o6_side_preparation_193254.json.gz" - case = json.loads(gzip.decompress(fixture.read_bytes()))["cases"][attempt] - frames = recorded_image_frames(case["frames"]) - assert len(frames) == frames_count - result = resolve_image_motion(frames, tuple(MotionRelation(**r) for r in case["relations"]), - geometry_constraints=tuple(ParallelAxisGeometry(**c) for c in case["constraints"]), - source_urdf_sha256=case["source_urdf_sha256"]) - assert result.resolved, result.reason - 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) - winner = training_candidate_index(result.hypotheses) - assert result.model.branches == result.hypotheses[winner].branches - adjusted = result.hypotheses[winner].comparison_adjusted_p_values - assert len(adjusted) == 3 and max(p for _, p in adjusted) <= .01 diff --git a/src/linkerhand_calibration/test/test_image_motion_capture_evidence.py b/src/linkerhand_calibration/test/test_image_motion_capture_evidence.py deleted file mode 100644 index 59a7752..0000000 --- a/src/linkerhand_calibration/test/test_image_motion_capture_evidence.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Capture persists the actual frozen image solver boundary for read-back.""" - -from copy import deepcopy -from dataclasses import replace -from types import SimpleNamespace -import json - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.image_motion_replay import replay_image_models, validate_image_sample -from linkerhand_calibration.core.geometry.frozen_evidence import EvidenceDecoder -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import image_motion_model_from_dict -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose -from linkerhand_calibration.runtime import capture as capture_module -from linkerhand_calibration.runtime.capture import CaptureFrame, ObservationCapture -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from test_image_motion_provenance import image_motion_capture - - -def capture_rig(monkeypatch): - profile, records = image_motion_capture() - profile = replace(profile, measurement=replace(profile.measurement, candidate_selection_tasks=frozenset())) - original = next(row for row in records if row["kind"] == "pnp_candidate_frame") - task = profile.motion.tasks[0] - moving = next(role for role, item in original["roles"].items() if "frozen_image_model" in item) - root = next(role for role in original["roles"] if role != moving) - model = image_motion_model_from_dict(original["roles"][moving]["frozen_image_model"]) - branch = SimpleNamespace(task_name=task.key, view=task.view, constraints=(), - image_model=model, evidence_ids=((moving, "a" * 64),)) - - class Tracker: - maximum_reprojection_error_px = 1.5 - - def estimate(self, role, corners, *, stamp_ns, **kwargs): - self.stamp = stamp_ns - return SquareTagPose(**original["roles"][role]["reprojection_valid_candidates"][0]), "" - - def observation_snapshot(self, role, stamp_ns): - item = original["roles"][role] - diagnostics = dict(item["candidate_diagnostics"], observation_stamp_ns=stamp_ns) - return tuple(SquareTagPose(**p) for p in item["reprojection_valid_candidates"]), diagnostics, 0 - - def freeze_roles(self, *args, **kwargs): - return True - - tracker = Tracker() - lock = SimpleNamespace(fixed_tag_by_view={task.view: 6}, missing_views=set(), locking_enabled=False, - observe=lambda *args: None) - capture = ObservationCapture(profile, reference_lock=lock, trackers={task.view: tracker}, - extrinsics=SimpleNamespace(transform=lambda _: np.eye(4))) - capture.locked_poses[task.view] = SquareTagPose(**original["roles"][root]["selected"]) - capture.locked_revisions[task.view] = 7 - capture.install_motion_model(branch) - - class SelectedEvidence: - def observation_snapshot(self, role, stamp_ns): - candidates, diagnostics, corrections = tracker.observation_snapshot(role, stamp_ns) - if role == moving: - diagnostics.update(frozen_image_model=original["roles"][role]["frozen_image_model"], - image_model_sha256=original["roles"][role]["image_model_sha256"], - motion_evidence_verified=True, motion_evidence_id="a" * 64) - return candidates, diagnostics, corrections - - def select(frame, model, tracker, selected, *, base, cached): - return {moving: SquareTagPose(**original["roles"][moving]["selected"])}, {}, SelectedEvidence() - - monkeypatch.setattr(capture_module, "select_motion_poses", select) - values = tuple(profile.command.baseline_values) - frame = CaptureFrame(task.view, 1000, np.asarray(original["camera_matrix"]), - {role: np.asarray(item["corners_xy"]) for role, item in original["roles"].items()}, values, values) - return profile, capture, frame, branch - - -@pytest.mark.parametrize("phase", ["joint_zero", "steady", "sweep"]) -def test_actual_capture_writes_replayable_image_model_when_candidate_strategy_is_empty(monkeypatch, phase): - profile, capture, frame, branch = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - motion = MotionCommand(phase, profile.command.baseline_values, 40., task_key=task.key, - command_index=task.command_index, cycle=0, direction="increasing", zero_joints=task.joints) - rows, movement = capture.consume(frame, motion) - assert movement is None - candidates = next(row for row in rows if row["kind"] == "pnp_candidate_frame") - sample = next(row for row in rows if row["kind"] in {"joint_sample", "joint_zero_sample"}) - assert candidates["sample_phase"] == phase - replayed = replay_image_models(candidates) - validate_image_sample(sample, candidates, replayed) - moving = branch.image_model.geometry[0].relation.child_role - evidence = candidates["roles"][moving] - assert evidence["pose_source"] == "image_constrained_current_image" - assert evidence["selected"] != evidence["reprojection_valid_candidates"][0] - decoder = EvidenceDecoder() - persisted_frame = decoder.loads(json.dumps(candidates)) - persisted_sample = decoder.loads(json.dumps(sample)) - validate_image_sample(persisted_sample, persisted_frame, replay_image_models(persisted_frame)) - - -def test_unconfirmed_diagnostic_does_not_emit_formal_image_candidate_frame(monkeypatch): - profile, capture, frame, _ = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - motion = MotionCommand("sweep", profile.command.baseline_values, 40., task_key=task.key, - command_index=task.command_index, cycle=0, direction="increasing") - rows, _ = capture.consume(frame, motion, diagnostic=True, accepted_joints=frozenset()) - assert [row["kind"] for row in rows] == ["diagnostic_observation_frame"] - - -@pytest.mark.parametrize('reason', [ - 'pose_image_motion_model_changed', - 'pose_image_motion_decision_invalid', - 'pose_observation_superseded', -]) -def test_failed_commit_keeps_original_reason_without_authorizing_missing_snapshot(monkeypatch, reason): - profile, capture, frame, branch = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - role = branch.image_model.geometry[0].relation.child_role - evidence = SimpleNamespace(observation_snapshot=lambda *args: None) - monkeypatch.setattr(capture_module, 'select_motion_poses', - lambda *args, **kwargs: ({}, {role: reason}, evidence)) - motion = MotionCommand('joint_zero', profile.command.baseline_values, 40., - task_key=task.key, zero_joints=task.joints) - rows, _ = capture.consume(frame, motion) - event = next(row for row in rows if row['kind'] == 'pnp_branch_event' and row['tag_role'] == role) - assert event['reason'] == reason - assert event['pnp_observation_evidence']['selected_pose'] is None - assert not event['pnp_observation_evidence']['candidate_diagnostics'] - assert not any(row['kind'] in {'joint_zero_sample', 'pnp_candidate_frame'} for row in rows) - - -@pytest.mark.parametrize("feedback_available", [True, False]) -def test_diagnostic_hold_keeps_transient_raw_images_before_formal_steady_window(monkeypatch, feedback_available): - profile, capture, frame, _ = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - command, feedback, target = [list(profile.command.baseline_values) for _ in range(3)] - command[task.command_index], feedback[task.command_index], target[task.command_index] = 224., 230., 192. - frame = replace(frame, command=tuple(command), feedback=tuple(feedback) if feedback_available else None) - motion = MotionCommand("steady", tuple(target), 40., task_key=task.key, - command_index=task.command_index, cycle=0, direction="decreasing", steady_index=0, - minimum_hold_seconds=10.) - rows, _ = capture.consume(frame, motion, diagnostic=True, allow_recording=False, - accepted_joints=frozenset(task.joints)) - assert [row["kind"] for row in rows] == ["diagnostic_observation_frame"] - raw = rows[0] - assert raw["sample_phase"] == "steady" and raw["steady_target"] == 192. - assert raw["steady_index"] == 0 and raw["minimum_hold_seconds"] == 10. - assert raw["target_command_vector"] == target and raw["command_vector"] == command - assert raw["feedback_vector"] == (feedback if feedback_available else None) - assert not raw["accepted_sample_window_open"] and not raw["is_accepted_joint_sample"] - assert raw["tags"] and raw["camera_matrix"] == frame.camera_matrix.tolist() - - -def test_formal_capture_does_not_record_unstable_steady_images(monkeypatch): - profile, capture, frame, _ = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - motion = MotionCommand("steady", profile.command.baseline_values, 40., task_key=task.key, - command_index=task.command_index, cycle=0, direction="decreasing", steady_index=0) - rows, _ = capture.consume(frame, motion, diagnostic=False, allow_recording=False) - assert not rows - - -@pytest.mark.parametrize("phase", ["joint_zero", "sweep"]) -def test_missing_image_model_root_cannot_be_replaced_by_stationary_cache(monkeypatch, phase): - profile, capture, frame, branch = capture_rig(monkeypatch) - task = profile.motion.tasks[0] - root = branch.image_model.geometry[0].relation.parent_role - frame = replace(frame, corners={role: points for role, points in frame.corners.items() if role != root}) - motion = MotionCommand(phase, profile.command.baseline_values, 40., task_key=task.key, - command_index=task.command_index, cycle=0, direction="increasing", zero_joints=task.joints) - rows, movement = capture.consume(frame, motion) - assert movement is None - assert not any(row["kind"] in {"joint_sample", "joint_zero_sample", "pnp_candidate_frame"} for row in rows) - - -def test_installed_image_model_is_managed_even_without_current_task(monkeypatch): - _, capture, frame, branch = capture_rig(monkeypatch) - tracker = capture.trackers[frame.view] - estimate = tracker.estimate - children = {item.relation.child_role for item in branch.image_model.geometry} - observed = set() - - def check_managed(role, *args, **kwargs): - if role in children: - assert kwargs.get("motion_managed") is True - observed.add(role) - return estimate(role, *args, **kwargs) - - monkeypatch.setattr(tracker, "estimate", check_managed) - rows, movement = capture.consume(frame, None) - assert observed == children and movement is None - assert not rows - - -def test_current_task_resolves_installed_parent_model_first_and_keeps_ancestral_roles(monkeypatch): - profile, capture, _, branch = capture_rig(monkeypatch) - parent = deepcopy(branch) - parent.task_name = "previous_joint_task" - first = parent.image_model.geometry[0] - child = deepcopy(branch) - child.image_model = replace(branch.image_model, - geometry=(replace(first, relation=replace(first.relation, - parent_role=first.relation.child_role, child_role="terminal")),), - tag_sizes=((first.relation.child_role, .016), ("terminal", .016)), - branches=((first.relation.child_role, 0), ("terminal", 0))) - child.evidence_ids = (("terminal", "b" * 64),) - capture._motion_models.clear() - capture.install_motion_model(parent) - capture.install_motion_model(child) - motion = SimpleNamespace(task_key=child.task_name) - assert capture._motion_model_schedule(child.view, motion) == (parent, child) - evidence = {"terminal": {"frozen_image_model": {"tag_sizes": child.image_model.tag_sizes}}, - first.relation.child_role: {"frozen_image_model": {"tag_sizes": parent.image_model.tag_sizes}}} - assert capture._image_model_roles({"terminal"}, evidence) == { - "terminal", first.relation.parent_role, first.relation.child_role} - - -def test_installation_cannot_change_an_existing_physical_tag_model(monkeypatch): - _, capture, _, branch = capture_rig(monkeypatch) - replacement = deepcopy(branch) - replacement.task_name = "another_task" - geometry = replacement.image_model.geometry[0] - replacement.image_model = replace(replacement.image_model, - geometry=(replace(geometry, pivot_parent_xyz_m=(.1, .1, .1)),)) - with pytest.raises(ValueError, match="image_motion_model_replacement_forbidden"): - capture.install_motion_model(replacement) diff --git a/src/linkerhand_calibration/test/test_image_motion_live_flow.py b/src/linkerhand_calibration/test/test_image_motion_live_flow.py deleted file mode 100644 index aeac63a..0000000 --- a/src/linkerhand_calibration/test/test_image_motion_live_flow.py +++ /dev/null @@ -1,79 +0,0 @@ -"""One coordinator transport run using the complete recorded thumb image arc. - -SDK commands select the nearest recorded image; timestamps are virtual. This -exercises the production image solver, new tracker commit and evidence writer, -not hand accuracy or an independent camera truth dataset. -""" -import gzip -from dataclasses import replace -import json -from pathlib import Path - -import numpy as np -import pytest - -from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection -from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance, validate_source_geometry -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from test_diagnostic_coordinator import run_until, THUMB_JOINTS -from runtime_host_fixture import coordinator_fixture, ready - - -@pytest.mark.parametrize("reference_mode", ["tag_pose", "stationary_image"]) -def test_recorded_image_geometry_completes_thumb_coordinator_without_publication(tmp_path, monkeypatch, reference_mode): - import test_diagnostic_coordinator as transport - payload = json.loads(gzip.decompress((Path(__file__).parent / "fixtures" / - "o6_image_live_flow_200044.json.gz").read_bytes())) - assert payload["source_sha256"] == "52f9ed7510fc58a4271532a2dc13a678f2e4aa2f1dc3153bf749fcf377cb23c5" - host, clock = coordinator_fixture(tmp_path, diagnostic_capture="o6_thumb", - profile_transform=lambda p: replace(p, acquisition=replace(p.acquisition, - fixed_reference_mode=reference_mode))) - ready(host, clock) - assert host.branch_initialization.image_geometry - for view, data in payload["views"].items(): - host.cameras.matrices[view] = np.asarray(data["camera_matrix"]) - host.cameras.image_sizes[view] = (2048, 2048) - monkeypatch.setattr(host.finalization, "start", lambda *a, **kw: pytest.fail("diagnostic started finalizer")) - assert host.start().success - - def recorded_frame(host, view, feedback): - phase = host._motion.phase if host._motion is not None else "joint_zero" - target = feedback[0 if view == "front" else 1] - pool = payload["views"][view]["frames"] - # Preparation exclusively uses recorded preparation images. A later - # direction cannot silently enter the geometry-training callback. - selected_phase = "sweep" if phase == "sweep" else "zero_approach" - available = [row for row in pool if row["phase"] == selected_phase] - if phase == "sweep": - direction = host._motion.direction - matching = [row for row in available if row["direction"] == direction] - available = matching or available - row = min(available, key=lambda r: abs(r["input"] - target)) - return DetectionInput(view, host.ports.clock_ns(), tuple(TagDetection(tag["id"], - tuple(tuple(p) for p in tag["corners"]), 0, 100.) for tag in row["tags"])) - - monkeypatch.setattr(transport, "projected_frame", recorded_frame) - try: - run_until(host, clock, lambda: host.execution.session.phase == Phase.DIAGNOSTIC_COMPLETE) - assert set(host.execution.zero_references) == THUMB_JOINTS - assert not host.execution.diagnostic_zero_attempts - assert not host.finalization.started and not host.final_json and not host.final_urdf - records = host.raw_records - initializations = [r for r in records if r["kind"] == "motion_branch_initialization"] - assert len(initializations) == 2 and all(r["status"] == "resolved" for r in initializations) - assert all(r.get("frozen_image_model") and r.get("image_model_sha256") for r in initializations) - samples = [r for r in records if r["kind"] == "joint_sample"] - assert {r["joint"] for r in samples} == THUMB_JOINTS - assert {r["direction"] for r in samples} == {"increasing", "decreasing"} - assert len(host.execution.completed_units) == 4 - proof = validate_motion_provenance(host.profile, records, required=True) - assert proof["motion_evidence_verified"] and not proof["is_accuracy_certificate"] - validate_source_geometry(host.profile, host.parameters.source_urdf, records) - from linkerhand_calibration.core.artifacts.storage import load_jsonl - journal = load_jsonl(host.raw_path) - assert json.loads(json.dumps(records[0])) == journal[0] - assert validate_motion_provenance(host.profile, journal, required=True) == proof - with pytest.raises(ValueError, match="image_model_source_urdf_changed"): - validate_motion_provenance(host.profile, records[1:], required=True) - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_image_motion_provenance.py b/src/linkerhand_calibration/test/test_image_motion_provenance.py deleted file mode 100644 index a9d0b1b..0000000 --- a/src/linkerhand_calibration/test/test_image_motion_provenance.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Constrained image poses keep raw candidates and survive immutable replay.""" - -from copy import deepcopy -from dataclasses import asdict, replace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.image_motion_replay import ( - IMAGE_POSE_SOURCE, image_model_sha256, replay_image_models, validate_image_sample, -) -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionModel -from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageHingeGeometry -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import select_image_motion_frame -from test_motion_image_diagnostics import MATRIX, frames - - -def recorded_image(*, stamp=None, cycle=0): - model = ImageMotionModel((ImageHingeGeometry(MotionRelation("hinge", "root", "moving"), - (0., 0., 1.), (0., 0., 0., 1.), (.01, .02, 0.), (.035, 0., 0.)),), MATRIX, - (("root", .016), ("moving", .016)), (1, 2), (1,), (2,), (("root", 0), ("moving", 0))) - original = frames(depth_error=.008)[24] - if stamp is not None: - original = replace(original, evidence=replace(original.evidence, stamp_ns=stamp)) - result = select_image_motion_frame(model, original) - assert result.resolved, result - selected = {item.role: asdict(item.pose) for item in result.selections} - observations = {item.role: item for item in original.observations} - stamp = original.evidence.stamp_ns - frame = {"kind": "pnp_candidate_frame", "task_name": "task", "view": "front", - "image_stamp_ns": stamp, "cycle": cycle, "direction": "increasing", "attempt": 1, - "tag_size_m": .016, "camera_matrix": MATRIX, - "camera_matrix_source": "CameraInfo.P[:3,:3]", "input_is_rectified": True, - "roles": {}} - evidence = {} - for candidates in original.evidence.roles: - role = candidates.role - data = {"corners_xy": observations[role].corners_xy, "tag_size_m": .016, - "selected": selected[role], "reprojection_valid_candidates": [asdict(p) for p in candidates.candidates], - "pose_source": IMAGE_POSE_SOURCE if role == "moving" else "verified_fixed_reference", - "locked_reference": role == "root"} - if role == "moving": - data.update(frozen_image_model=asdict(model), image_model_sha256=image_model_sha256(asdict(model))) - frame["roles"][role] = data - evidence["parent" if role == "root" else "child"] = {**data, "tag_role": role, - "selected_pose": selected[role], "reference_branch_revision": 1, - "candidate_diagnostics": {"branch_frozen": True, "branch_revision": 1, - "branch_status": "tracking", "observation_stamp_ns": stamp, - "fixed_reference_verified": role == "root"}} - data["candidate_diagnostics"] = evidence["parent" if role == "root" else "child"]["candidate_diagnostics"] - rp, rc = (Rotation.from_quat(selected[r]["quaternion_xyzw"]) for r in ("root", "moving")) - row = {key: frame[key] for key in ("task_name", "view", "image_stamp_ns", "cycle", "direction", "attempt")} - row.update(kind="joint_sample", joint="hinge", sample_phase="sweep", pnp_observation_evidence=evidence, - parent_pose_common=selected["root"], child_pose_common=selected["moving"], - relative_quaternion_xyzw=(rp.inv()*rc).as_quat().tolist(), - relative_translation_xyz_m=rp.inv().apply(np.subtract(selected["moving"]["translation_xyz_m"], - selected["root"]["translation_xyz_m"])).tolist()) - return frame, row - - -def test_image_pose_replay_is_distinct_from_raw_pnp_and_never_mutates_capture(): - frame, row = recorded_image() - before = deepcopy((frame, row)) - replay = replay_image_models(frame) - validate_image_sample(row, frame, replay) - assert frame == before[0] and row == before[1] - assert frame["roles"]["moving"]["selected"] != frame["roles"]["moving"]["reprojection_valid_candidates"][0] - - -@pytest.mark.parametrize("mutation", ["model", "hash", "projection", "size", "corners", "selected", - "source", "raw_candidates", "future_support"]) -def test_frozen_model_and_current_image_evidence_cannot_be_substituted(mutation): - frame, _ = recorded_image() - moving = frame["roles"]["moving"] - if mutation == "model": - moving["frozen_image_model"]["geometry"][0]["pivot_parent_xyz_m"] = (.2, .2, .2) - elif mutation == "hash": - moving["image_model_sha256"] = "a" * 64 - elif mutation == "projection": - frame["camera_matrix_source"] = "CameraInfo.K" - elif mutation == "size": - moving["tag_size_m"] = .02 - elif mutation == "corners": - moving["corners_xy"] = tuple((x + 15., y) for x, y in moving["corners_xy"]) - elif mutation == "selected": - moving["selected"] = {**moving["selected"], "translation_xyz_m": (.1, .1, .8)} - elif mutation == "source": - moving["pose_source"] = "current_image" - elif mutation == "raw_candidates": - moving.pop("reprojection_valid_candidates") - else: - frame["image_stamp_ns"] = 2 - with pytest.raises(ValueError, match="image_motion_provenance_"): - replay_image_models(frame) - - -@pytest.mark.parametrize("mutation", ["sample_model", "sample_corners", "sample_pose", "relative", "identity", "marker"]) -def test_sample_must_match_the_replayed_frozen_image(mutation): - frame, row = recorded_image() - row = deepcopy(row) - item = row["pnp_observation_evidence"]["child"] - if mutation == "sample_model": - item["image_model_sha256"] = "a" * 64 - elif mutation == "sample_corners": - item["corners_xy"] = ((0., 0.),) * 4 - elif mutation == "sample_pose": - item["selected_pose"]["translation_xyz_m"] = (.1, .1, .8) - elif mutation == "relative": - row["relative_translation_xyz_m"] = [0., 0., 0.] - elif mutation == "identity": - row["image_stamp_ns"] += 1 - else: - item["pose_source"] = "current_image" - item.pop("frozen_image_model") - item.pop("image_model_sha256") - with pytest.raises(ValueError, match="image_motion_provenance_"): - validate_image_sample(row, frame, replay_image_models(frame)) - - -def test_articulated_finalizer_replays_model_without_running_ippe_or_refitting(): - from linkerhand_calibration.core.geometry.candidate_selection import resolve_chain_observations - frame, row = recorded_image() - records = [] - # Distinct images with equal pose are sufficient for this read-back test; - # the separate motion initializer and final fitter enforce motion span. - for index in range(65): - current_frame, current_row = deepcopy(frame), deepcopy(row) - for record in (current_frame, current_row): - record.update(image_stamp_ns=100 + index, cycle=0 if index < 40 else 3) - for item in current_row["pnp_observation_evidence"].values(): - item["candidate_diagnostics"]["observation_stamp_ns"] = 100 + index - for item in current_frame["roles"].values(): - item["candidate_diagnostics"]["observation_stamp_ns"] = 100 + index - records.extend((current_frame, current_row)) - before = deepcopy(records) - result, report = resolve_chain_observations(records, task_key="task", role_pairs={"hinge": ("root", "moving")}, - fixed_roles={"root"}, require_frozen_branches=True, - solve_pose=lambda *args, **kwargs: pytest.fail("IPPE reselection would change frozen geometry")) - assert result == before == records - assert report["changed_joint_rows"] == 0 and report["geometry_refitted"] is False - assert report["holdout_frames"] == 25 - - -def image_motion_capture(): - """Construct a journal from an independently specified exact hinge model. - - Solver observability is tested in its own module. Here a known model and - images isolate read-back, authorization and zero/holdout identity handling. - """ - from linkerhand_calibration.core.domain.reference import build_joint_zero_reference - from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 - from test_motion_reference_provenance import motion_capture - - profile, old_reference, records = motion_capture(source_stamps=tuple(range(10, 34))) - # The independent projected hinge fixture uses 16 mm tags. Match the - # capture profile to that geometry instead of inheriting live tag sizes. - profile = replace(profile, vision=replace(profile.vision, views=tuple( - replace(view, tags=tuple(replace(tag, size_m=.016) for tag in view.tags)) - for view in profile.vision.views))) - name = old_reference.joint - spec = profile.measurement.measurements[name] - role_names = {"root": spec.parent_role, "moving": spec.child_role} - original_frame, original_sample = recorded_image() - raw_model = original_frame["roles"]["moving"]["frozen_image_model"] - raw_model["geometry"][0]["relation"] = dict(joint=name, parent_role=spec.parent_role, child_role=spec.child_role) - raw_model.update(tag_sizes=tuple((role_names[role], size) for role, size in raw_model["tag_sizes"]), - branches=tuple((role_names[role], branch) for role, branch in raw_model["branches"]), - source_stamps_ns=tuple(range(10, 34)), training_stamps_ns=tuple(range(10, 22)), - validation_stamps_ns=tuple(range(22, 34))) - digest = image_model_sha256(raw_model) - original_frame["roles"] = {role_names[role]: item for role, item in original_frame["roles"].items()} - moving = original_frame["roles"][spec.child_role] - moving.update(frozen_image_model=raw_model, image_model_sha256=digest) - for item in original_sample["pnp_observation_evidence"].values(): - item["tag_role"] = role_names[item["tag_role"]] - if item.get("frozen_image_model"): - item.update(frozen_image_model=raw_model, image_model_sha256=digest) - sources = [row for row in records if row["kind"] == "motion_branch_observation"] - for source in sources: - source.update(camera_matrix=MATRIX, input_is_rectified=True, sample_phase="zero_approach", - tags={role: {"tag_size_m": item["tag_size_m"], "corners_xy": item["corners_xy"], - "reprojection_valid_candidates": item["reprojection_valid_candidates"]} - for role, item in original_frame["roles"].items()}) - audit = next(row for row in records if row["kind"] == "motion_branch_initialization") - audit.update(constraints=[], frozen_image_model=raw_model, image_model_sha256=digest, - source_frame_hashes={str(row["image_stamp_ns"]): source_frame_sha256(row) for row in sources}) - payload = {key: audit[key] for key in ("task_name", "view", "zero_joints", "session_epoch", - "motion_version", "source_image_stamps", "source_frame_hashes", "constraints", - "frozen_image_model", "image_model_sha256")} - payload["tag_role"] = spec.child_role - evidence_id = image_model_sha256(payload) - audit.update(evidence_ids={spec.child_role: evidence_id}, evidence_payloads={spec.child_role: payload}) - output = [*sources, audit] - zero_rows = [] - for row in records: - if row["kind"] not in {"joint_zero_sample", "joint_sample"}: - continue - frame = deepcopy(original_frame) - for key in ("task_name", "view", "image_stamp_ns", "cycle", "direction", "attempt"): - frame[key] = row.get(key) - for item in frame["roles"].values(): - item["candidate_diagnostics"]["observation_stamp_ns"] = row["image_stamp_ns"] - row = deepcopy(row) - for field in ("pnp_observation_evidence", "parent_pose_common", "child_pose_common", - "relative_quaternion_xyzw", "relative_translation_xyz_m"): - row[field] = deepcopy(original_sample[field]) - for item in row["pnp_observation_evidence"].values(): - item["candidate_diagnostics"].update(observation_stamp_ns=row["image_stamp_ns"], - branch_frozen=row["kind"] != "joint_zero_sample", branch_revision=7) - if item["tag_role"] == spec.child_role: - item["candidate_diagnostics"].update(motion_evidence_verified=True, motion_evidence_id=evidence_id) - output.extend((frame, row)) - if row["kind"] == "joint_zero_sample": - zero_rows.append(row) - reference = build_joint_zero_reference(profile, name, zero_rows, session_epoch=1, motion_version=2) - output.append(reference.as_record()) - return profile, output - - -def test_new_model_reference_and_fourth_round_remain_bound_to_pre_zero_images(): - from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance - profile, records = image_motion_capture() - result = validate_motion_provenance(profile, records, require_complete=True) - assert result["motion_evidence_verified"] and not result["is_accuracy_certificate"] - - -def test_resume_preserves_zero_image_frames_for_later_provenance_replay(): - from linkerhand_calibration.core.domain.reference import read_joint_zero_references - from linkerhand_calibration.runtime.joint_resume import JointResume - from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance - - profile, records = image_motion_capture() - reference = next(iter(read_joint_zero_references(profile, records).values())) - zero_images = {(s.view, s.image_stamp_ns) for s in reference.samples} - expected = [r for r in records if r["kind"] == "pnp_candidate_frame" - and (r["view"], r["image_stamp_ns"]) in zero_images] - assert expected - for _ in range(2): - resume = JointResume(profile) - resume.references, resume.rows = {reference.joint: reference}, tuple(records) - # This test isolates transport of the frozen zero's original image - # evidence; independent new-pose verification is tested separately. - assert resume.verify(reference) is reference - copied = resume.take_motion_provenance_records() - assert [r for r in copied if r["kind"] == "pnp_candidate_frame"] == expected - records = [*copied, reference.as_record()] - assert validate_motion_provenance(profile, records, required=True)["motion_evidence_verified"] - - -@pytest.mark.parametrize("mutation", ["missing_frame", "source_changed", "holdout_changed", "old_model_disguised"]) -def test_final_provenance_does_not_accept_unreplayable_image_geometry(mutation): - from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance - profile, records = image_motion_capture() - if mutation == "missing_frame": - records = [row for row in records if row["kind"] != "pnp_candidate_frame"] - elif mutation == "source_changed": - records[0]["camera_matrix"] = ((1700., 0., 640.), *MATRIX[1:]) - else: - row = next(row for row in records if row.get("kind") == "joint_sample" and row.get("cycle") == 3) - item = row["pnp_observation_evidence"]["child"] - if mutation == "holdout_changed": - item["image_model_sha256"] = "a" * 64 - else: - item["pose_source"] = "current_image" - item.pop("frozen_image_model") - item.pop("image_model_sha256") - with pytest.raises(ValueError, match="provenance_"): - validate_motion_provenance(profile, records, require_complete=True) diff --git a/src/linkerhand_calibration/test/test_image_motion_tracker_commit.py b/src/linkerhand_calibration/test/test_image_motion_tracker_commit.py deleted file mode 100644 index 65281be..0000000 --- a/src/linkerhand_calibration/test/test_image_motion_tracker_commit.py +++ /dev/null @@ -1,150 +0,0 @@ -"""The constrained-pose bridge verifies pixels once and commits metadata atomically.""" -from dataclasses import replace -from threading import Thread - -import pytest - -from linkerhand_calibration.core.geometry.tag_pose import tracking, image_motion_commit -from linkerhand_calibration.core.geometry.tag_pose.fixed_reference import FixedTagReference -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import RoleCandidates -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion -from linkerhand_calibration.core.geometry.tag_pose.tracking import MotionPoseSelection, SquareTagPoseTracker -from test_motion_image_diagnostics import frames, RELATIONS - - -@pytest.fixture(scope="module") -def model_source(): - source = frames(depth_error=.008) - result = resolve_image_motion(source, RELATIONS) - assert result.resolved - return result.model, source - - -@pytest.fixture -def observed(monkeypatch, model_source): - model, source = model_source - tracker = SquareTagPoseTracker() - root = source[0].evidence.roles[0].candidates[0] - root_observation = source[0].observations[0] - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, **k: [root]) - for index in range(5): - selected, _ = tracker.estimate("root", root_observation.corners_xy, tag_size_m=.016, - camera_matrix=source[0].camera_matrix, stamp_ns=100_000_000 + index * 50_000_000) - assert selected is not None - snapshot = tracker.motion_snapshot("root", 300_000_000) - assert tracker.freeze_roles({"root": snapshot.revision}) - reference = FixedTagReference(selected, snapshot.revision) - - def observe(frame): - for role, observation in zip(frame.evidence.roles, frame.observations): - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, _p=role.candidates, **k: list(_p)) - tracker.estimate(role.role, observation.corners_xy, tag_size_m=observation.tag_size_m, - camera_matrix=frame.camera_matrix, stamp_ns=frame.evidence.stamp_ns, - fixed_reference=reference if role.role == "root" else None, - motion_managed=role.role != "root") - actual_root = tracker.motion_snapshot("root", frame.evidence.stamp_ns) - return replace(frame, evidence=replace(frame.evidence, roles=( - RoleCandidates("root", (actual_root.previous_pose,), True), frame.evidence.roles[1]))) - - frame = observe(source[12]) - return tracker, model, frame, observe, source - - -def verify(observed): - tracker, model, frame, _, _ = observed - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - return tracker.verify_image_motion_frame((snapshot,), model, frame, {"moving": "evidence"}) - - -def test_constrained_pose_has_its_own_verified_commit_without_changing_pnp(observed, monkeypatch): - tracker, _, frame, _, _ = observed - original = tracker.last_candidates_by_role["moving"] - calls = [] - solve = image_motion_commit.select_image_motion_frame - def unlocked(model, frame): - completed = [] - thread = Thread(target=lambda: completed.append(tracker.motion_snapshot("moving", frame.evidence.stamp_ns))) - thread.start(); thread.join(timeout=1) - assert completed, "numerical image solve held the tracker lock" - calls.append(1) - return solve(model, frame) - monkeypatch.setattr(image_motion_commit, "select_image_motion_frame", unlocked) - decision = verify(observed) - assert decision.accepted, decision - assert decision.selections[0].pose not in original - legacy = tracker.commit_motion_frame(decision.selections, establish=True) - assert not legacy.accepted and legacy.reason == "pose_motion_selection_invalid" - result = tracker.commit_image_motion_frame(decision, establish=True) - assert result.accepted and len(calls) == 1 - assert tracker.last_candidates_by_role["moving"] == original - diagnostics = result.observation_snapshots[0].diagnostics - assert diagnostics["pose_source"] == "image_constrained_current_image" - assert diagnostics["frozen_image_model"]["policy"] == decision.model.policy - assert diagnostics["image_model_sha256"] == decision.model_sha256 - assert diagnostics["image_motion_quality"] - assert not tracker.commit_image_motion_frame(decision, establish=True).accepted - - -def test_copied_decision_cannot_authorize_an_arbitrary_pose(observed): - tracker, _, _, _, _ = observed - decision = verify(observed) - assert decision.accepted - pose = replace(decision.selections[0].pose, translation_xyz_m=(0., 0., .1)) - forged = replace(decision, selections=(replace(decision.selections[0], pose=pose),)) - assert tracker.commit_image_motion_frame(forged, establish=True).reason == "pose_image_motion_decision_invalid" - assert tracker.commit_image_motion_frame(decision, establish=True).accepted - - -def test_child_or_root_arriving_after_solve_invalidates_entire_decision(observed): - tracker, _, _, observe, source = observed - decision = verify(observed) - observe(source[13]) - result = tracker.commit_image_motion_frame(decision, establish=True) - assert not result.accepted and result.reason == "pose_observation_superseded" - assert tracker._branches.states["moving"].motion_evidence_id is None - - -def test_different_pixels_cannot_be_substituted_before_verification(observed): - tracker, model, frame, _, _ = observed - shifted = replace(frame.observations[1], corners_xy=tuple((x + .1, y) for x, y in frame.observations[1].corners_xy)) - frame = replace(frame, observations=(frame.observations[0], shifted)) - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - decision = tracker.verify_image_motion_frame((snapshot,), model, frame, {"moving": "evidence"}) - assert not decision.accepted and decision.reason == "pose_image_motion_observation_mismatch" - - -def test_reset_and_freeze_cannot_adopt_a_delayed_model(observed): - tracker, _, _, _, _ = observed - decision = verify(observed) - tracker.reset() - assert tracker.commit_image_motion_frame(decision, establish=True).reason == "pose_observation_superseded" - - -def test_frozen_identity_cannot_change_model_or_return_to_legacy_solver(observed): - tracker, model, frame, observe, source = observed - decision = verify(observed) - assert tracker.commit_image_motion_frame(decision, establish=True).accepted - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - assert tracker.freeze_roles({"moving": snapshot.revision}, require_motion_evidence=True) - frame = observe(source[13]) - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - legacy = MotionPoseSelection(snapshot, snapshot.candidates[0], "evidence") - assert tracker.commit_motion_frame((legacy,)).reason == "pose_motion_solver_changed" - changed = replace(model, source_stamps_ns=(*model.source_stamps_ns, model.source_stamps_ns[-1] + 1)) - decision = tracker.verify_image_motion_frame((snapshot,), changed, frame, {"moving": "evidence"}) - assert decision.accepted - assert tracker.commit_image_motion_frame(decision).reason == "pose_image_motion_model_changed" - decision = tracker.verify_image_motion_frame((snapshot,), model, frame, {"moving": "evidence"}) - assert tracker.commit_image_motion_frame(decision).accepted - - -def test_only_the_fixed_root_advancing_also_invalidates_decision(observed, monkeypatch): - tracker, _, frame, _, _ = observed - decision = verify(observed) - root = tracker.motion_snapshot("root", frame.evidence.stamp_ns) - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, **k: [root.previous_pose]) - tracker.estimate("root", frame.observations[0].corners_xy, tag_size_m=.016, - camera_matrix=frame.camera_matrix, stamp_ns=frame.evidence.stamp_ns + 1, - fixed_reference=FixedTagReference(root.previous_pose, root.revision)) - assert tracker.commit_image_motion_frame(decision, establish=True).reason == "pose_observation_superseded" - assert tracker._branches.states["moving"].motion_evidence_id is None diff --git a/src/linkerhand_calibration/test/test_image_projection_worker.py b/src/linkerhand_calibration/test/test_image_projection_worker.py deleted file mode 100644 index b8de98d..0000000 --- a/src/linkerhand_calibration/test/test_image_projection_worker.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Independent execution preserves current pixels, geometry and rejection.""" - -from dataclasses import replace -from types import SimpleNamespace - -from linkerhand_calibration.core.geometry.tag_pose.image_projection_worker import ImageProjectionWorker -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import image_motion_model_from_dict -from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame -from test_image_motion_provenance import recorded_image -from test_motion_image_diagnostics import frames -from test_image_motion_tracker_commit import observed, model_source - - -def test_process_projection_matches_direct_solution_and_rejects_changed_camera(): - recorded, _ = recorded_image() - model = image_motion_model_from_dict(recorded["roles"]["moving"]["frozen_image_model"]) - frame = frames(depth_error=.008)[24] - worker = ImageProjectionWorker(workers=1) - try: - actual = worker.solve(model, frame) - assert actual.resolved - expected = select_image_motion_frame(model, frame) - assert actual == expected - changed = replace(frame, camera_matrix=((1700., 0., 640.), *frame.camera_matrix[1:])) - assert worker.solve(model, changed).reason == "image_motion_camera_changed" - finally: - worker.close() - assert not worker.solve(model, frame).resolved - - -def test_process_result_still_requires_current_tracker_identity(observed): - tracker, model, frame, observe, source = observed - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - worker = ImageProjectionWorker(workers=1) - try: - decision = tracker.verify_image_motion_frame((snapshot,), model, frame, - {"moving": "evidence"}, projection_worker=worker) - assert decision.accepted - observe(source[13]) - result = tracker.commit_image_motion_frame(decision, establish=True) - assert not result.accepted and result.reason == "pose_observation_superseded" - assert tracker._branches.states["moving"].motion_evidence_id is None - finally: - worker.close() - - -def test_caller_cannot_substitute_a_pose_proposal_as_a_worker(observed): - tracker, model, frame, _, _ = observed - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - forged = SimpleNamespace(solve=lambda *_: select_image_motion_frame(model, frame)) - decision = tracker.verify_image_motion_frame((snapshot,), model, frame, - {"moving": "evidence"}, projection_worker=forged) - assert not decision.accepted and decision.reason == "pose_image_motion_worker_invalid" diff --git a/src/linkerhand_calibration/test/test_independent_urdf_export.py b/src/linkerhand_calibration/test/test_independent_urdf_export.py deleted file mode 100644 index a238f78..0000000 --- a/src/linkerhand_calibration/test/test_independent_urdf_export.py +++ /dev/null @@ -1,206 +0,0 @@ -"""A near-zero limit must never flatten a nonlinear passive joint's travel.""" - -from copy import deepcopy -import hashlib -import json -import xml.etree.ElementTree as ET - -import numpy as np -import pytest - -from linkerhand_calibration.core.urdf.acceptance import SerializedJointMapping, validate_compact_urdf_tables -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.core.urdf.patch import UrdfJointPatch, UrdfPatchSet, write_urdf_patches -from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan -from linkerhand_calibration.core.urdf.validate import validate_structural_urdf_diff -from linkerhand_calibration.runtime.artifacts.urdf_from_json import ( - rebuild_urdf_from_json, serialize_urdf_correction, validate_json_urdf_correspondence, -) - - -@pytest.fixture -def nonlinear_release(tmp_path): - source = tmp_path / "original.urdf" - source.write_text(''' - - - - - - - -''') - digest = hashlib.sha256(source.read_bytes()).hexdigest() - rights = {name: ("origin.rpy", "limit.lower", "limit.upper", "mimic.multiplier", "mimic.offset") - for name in ("pitch", "ip")} - plan = build_standard_correction_plan(source_urdf=source, source_sha256=digest, - zero_offsets_rad={}, authorized_fields=rights, independent_joint_channels={"pitch": 0, "ip": 0}) - parent = np.linspace(0, .6, 256) - child = 1.71 * parent + .39 * parent**2 - payload = {"format": "unified_calibration_v3", "schema_version": 3, - "profile_id": "VIRTUAL/right/nonlinear/v3", "input_unit": "u8", "baseline_command": [0], - "joints": {name: {"sdk_channel": 0, "angle_rad": angles.tolist()} - for name, angles in (("pitch", parent), ("ip", child))}, - "urdf_correction": serialize_urdf_correction(plan)} - path = tmp_path / "calibration.json" - path.write_text(json.dumps(payload)) - return source, rights, plan, payload, path - - -def rebuild(data, output): - source, rights, _, payload, path = data - path.write_text(json.dumps(payload)) - return rebuild_urdf_from_json(path, source, output, authorized_fields=rights, - expected_profile_id="VIRTUAL/right/nonlinear/v3") - - -def test_nonlinear_angles_survive_normal_urdf_fk_and_json_rebuild(nonlinear_release, tmp_path): - source, _, plan, payload, _ = nonlinear_release - original = source.read_bytes() - output = rebuild(nonlinear_release, tmp_path / "corrected.urdf") - assert output.read_bytes() == plan.write(source, tmp_path / "direct.urdf").read_bytes() - validate_json_urdf_correspondence(payload, source, output, expected_profile_id=payload["profile_id"]) - model = UrdfKinematicModel(output) - mapping = SerializedJointMapping(payload, input_kind="command") - ratios = [] - for command in (32, 128, 255): - angles = mapping.evaluate([command], "", active_joints=("pitch", "ip")) - resolved = model.resolve_angles(angles) # No custom mimic override. - assert resolved == angles - ratios.append(resolved["ip"] / resolved["pitch"]) - expected = np.eye(4) - for name in ("pitch", "ip"): - joint = model.joints[name] - expected = expected @ joint.origin @ model._motion(joint, angles[name]) - assert np.allclose(model.link_transform("ip", zero_offsets={}, joint_angles=angles), expected) - assert ratios[-1] - ratios[0] > .2 - assert np.degrees(resolved["ip"]) > 66 - assert source.read_bytes() == original - for name, joint in model.joints.items(): - before = UrdfKinematicModel(source).joints[name] - assert joint.lower == before.lower and joint.upper == before.upper - assert np.array_equal(joint.origin, before.origin) - assert ET.parse(output).findall("./joint/mimic") == [] - - -@pytest.mark.parametrize("corruption", ["wrong_channel", "missing_curve", "wrong_parent", "missing_removal", - "legacy_removal", "coefficient_and_removal", "extra_removal", "changed_axis"]) -def test_incomplete_or_changed_coupling_cannot_rebuild(nonlinear_release, tmp_path, corruption): - source, rights, plan, original, path = nonlinear_release - payload = deepcopy(original) - correction = payload["urdf_correction"] - if corruption == "wrong_channel": - payload["joints"]["ip"]["sdk_channel"] = 1 - payload["baseline_command"].append(0) - elif corruption == "missing_curve": - del payload["joints"]["ip"] - elif corruption == "wrong_parent": - correction["passive_joint_sources"]["ip"] = "missing" - elif corruption == "missing_removal": - correction["joint_patches"]["ip"] = {"limit_upper": "1.2"} - elif corruption == "legacy_removal": - correction["schema_version"] = 1 - del correction["joint_angle_source"], correction["passive_joint_sources"] - elif corruption == "coefficient_and_removal": - correction["joint_patches"]["ip"]["mimic_multiplier"] = "2" - elif corruption == "extra_removal": - correction["joint_patches"]["pitch"] = {"remove_mimic": True} - else: - correction["joint_patches"]["ip"]["axis_xyz"] = "1 0 0" - output = tmp_path / "invalid.urdf" - with pytest.raises(ValueError): - rebuild((source, rights, plan, payload, path), output) - assert not output.exists() - - -def test_removal_requires_its_own_right_and_preserves_linkage(nonlinear_release, tmp_path): - source, rights, plan, payload, _ = nonlinear_release - with pytest.raises(ValueError, match="authorization"): - write_urdf_patches(source_urdf=source, destination_urdf=tmp_path / "unauthorized.urdf", - patches=UrdfPatchSet({"ip": UrdfJointPatch(remove_mimic=True)}), authorized_fields=rights) - output = rebuild(nonlinear_release, tmp_path / "valid.urdf") - changed = output.read_text().replace('parent link="proximal"', 'parent link="base"') - output.write_text(changed) - with pytest.raises(ValueError, match="topology"): - validate_structural_urdf_diff(source, output, authorized_fields=plan.authorized_fields) - with pytest.raises(ValueError, match="overridden"): - validate_compact_urdf_tables(payload, UrdfKinematicModel(source)) - - -def test_robot_state_publisher_preserves_json_passive_angles(nonlinear_release, tmp_path): - assert_robot_state_publisher_angles(nonlinear_release, tmp_path) - - -def assert_robot_state_publisher_angles(nonlinear_release, tmp_path, *, use_mimic=False): - """Use private topics, real ROS TF and no SDK or motor-command publisher.""" - rclpy = pytest.importorskip("rclpy") - from ament_index_python.packages import get_package_prefix, PackageNotFoundError - from pathlib import Path - import os - import subprocess - import time - import uuid - import yaml - from scipy.spatial.transform import Rotation - from sensor_msgs.msg import JointState - from tf2_msgs.msg import TFMessage - try: - executable = Path(get_package_prefix("robot_state_publisher")) / "lib/robot_state_publisher/robot_state_publisher" - except PackageNotFoundError: - pytest.skip("robot_state_publisher is not installed") - output = rebuild(nonlinear_release, tmp_path / "ros.urdf") - model = UrdfKinematicModel(output) - parameters = tmp_path / "parameters.yaml" - parameters.write_text(yaml.safe_dump({"/**": {"ros__parameters": { - "robot_description": output.read_text(), "publish_frequency": 100.0, "ignore_timestamp": True}}})) - namespace = "/nonlinear_export_test_" + uuid.uuid4().hex - context = rclpy.context.Context() - rclpy.init(context=context) - node = rclpy.create_node("json_angle_test", namespace=namespace, context=context) - executor = rclpy.executors.SingleThreadedExecutor(context=context) - executor.add_node(node) - messages = [] - subscription = node.create_subscription(TFMessage, namespace + "/tf", messages.append, 10) - publisher = node.create_publisher(JointState, namespace + "/joint_states", 10) - process = subprocess.Popen([str(executable), "--ros-args", "--params-file", str(parameters), - "-r", f"__ns:={namespace}", "-r", f"/tf:={namespace}/tf", - "-r", f"/tf_static:={namespace}/tf_static"], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, env=dict(os.environ, ROS_LOG_DIR=str(tmp_path))) - try: - for command in (32, 128, 255): - payload = nonlinear_release[3] - angles = [payload["joints"][name]["angle_rad"][command] for name in ("pitch", "ip")] - state = JointState() - state.header.stamp = node.get_clock().now().to_msg() - state.name, state.position = (["pitch"], angles[:1]) if use_mimic else (["pitch", "ip"], angles) - messages.clear() - deadline = time.monotonic() + 8 - observed = None - while time.monotonic() < deadline: - assert process.poll() is None - publisher.publish(state) - executor.spin_once(timeout_sec=.05) - for message in messages: - for transform in message.transforms: - if transform.child_frame_id == "distal" and transform.header.stamp == state.header.stamp: - observed = transform - if observed is not None: - break - assert observed is not None, "no passive-joint TF from robot_state_publisher" - assert observed.header.frame_id == "proximal" - q = observed.transform.rotation - rotation = Rotation.from_quat([q.x, q.y, q.z, q.w]) - expected_angle = model.resolve_angles({"pitch": angles[0]})["ip"] if use_mimic else angles[1] - expected = Rotation.from_rotvec([0, 0, expected_angle]) - assert (rotation.inv() * expected).magnitude() < 1e-10 - finally: - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=2) - executor.shutdown() - node.destroy_subscription(subscription) - node.destroy_node() - context.shutdown() diff --git a/src/linkerhand_calibration/test/test_interleaved_motion_efficiency.py b/src/linkerhand_calibration/test/test_interleaved_motion_efficiency.py deleted file mode 100644 index 14e94c6..0000000 --- a/src/linkerhand_calibration/test/test_interleaved_motion_efficiency.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Remove duplicated travel/settling while preserving the physical sample gate.""" - -from dataclasses import replace -import math - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.visual_motion import VisualMotionObserver - - -@pytest.mark.parametrize("phase", [ - "sweep", "steady", "baseline", "prepare", "zero_approach", "clearance", - "task_exit", "return", "reference_pose", "reference_return", - "mapping_probe", "mapping_return"]) -def test_o30_scan_and_transition_trajectories_share_requested_peak_speed(phase): - profile = load_bundled_hand_profile("o30_right_18", scope="full") - start = profile.command.baseline_values - target = list(start); target[0] = 255. - segment = MotionExecution(profile, MotionCommand(phase, tuple(target), 200., command_index=0), - initial_command=start, initial_feedback=start, now=0., identity=phase, - visual_observer=VisualMotionObserver(profile)) - segment.sample(0.) - assert math.pi*255/(2*segment.duration) == pytest.approx(30.) - - -def test_same_target_hold_waits_for_fresh_stable_feedback_after_sweep_handoff(): - profile = load_bundled_hand_profile("o30_right_18", scope="full") - profile = replace(profile, acquisition=replace(profile.acquisition, motion_observation="feedback")) - start = profile.command.baseline_values - target = list(start); target[0] = 32. - move = MotionExecution(profile, MotionCommand("sweep", tuple(target), 200., - defer_settling_to_hold=True), initial_command=start, initial_feedback=start, - now=0., identity="move") - normal = MotionExecution(profile, replace(move.command, defer_settling_to_hold=False), - initial_command=start, initial_feedback=start, now=0., identity="ordinary_endpoint") - for index in range(30): - now = index*.02 - command = move.sample(now) - normal.sample(now) - move.observe(command, stamp=index, now=now) - normal.observe(command, stamp=index, now=now) - now = move.duration+.001 - for segment in (move, normal): - segment.sample(now); segment.observe(target, stamp=100, now=now) - assert move.arrived(now) - assert not normal.arrived(now) # Its endpoint hold still applies. - hold = MotionExecution(profile, MotionCommand("steady", tuple(target), 200., command_index=0), - initial_command=target, initial_feedback=target, now=now, identity="hold") - began = now - for index in range(31): - now = began + index*.02 - feedback = target.copy() - if index < 15: - feedback[0] += 4 if index % 2 else -4 - hold.sample(now);hold.observe(feedback, stamp=index, now=now) - if index < 25: - assert not hold.steady_ready(now) - assert hold.steady_ready(now) - - -def test_deferred_settling_cannot_bypass_missing_motion_or_apply_to_avoidance(): - profile = load_bundled_hand_profile("o30_right_18", scope="full") - profile = replace(profile, acquisition=replace(profile.acquisition, motion_observation="feedback")) - start = profile.command.baseline_values - target = list(start); target[0] = 32. - command = MotionCommand("sweep", tuple(target), 200., defer_settling_to_hold=True) - segment = MotionExecution(profile, command, initial_command=start, initial_feedback=start, - now=0., identity="blocked") - for index in range(200): - now = index*.02;segment.sample(now);segment.observe(start, stamp=index, now=now) - assert not segment.arrived(now) - with pytest.raises(ValueError, match="only a sweep"): - MotionExecution(profile, replace(command, phase="clearance"), initial_command=start, - initial_feedback=start, now=0., identity="invalid") - - -def test_four_round_capture_does_not_mix_with_six_round_resume(): - from linkerhand_calibration.core.domain.capture_plan import configure_training, FIXED - profile = configure_training(load_bundled_hand_profile("o30_right_18", scope="full"), FIXED) - former = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="separate", motion_observation="feedback"), - measurement=replace(profile.measurement, release_basis="feedback_and_command")) - optimized, separate = CalibrationEngine(profile), CalibrationEngine(former) - assert len(optimized.scan_units()) == 144 - assert len(separate.scan_units()) == 216 - assert {unit.cycle for unit in optimized.scan_units()} == {0, 1, 2, 3} - assert optimized.capture_schedule_version != separate.capture_schedule_version diff --git a/src/linkerhand_calibration/test/test_joint_pair_diagnostic.py b/src/linkerhand_calibration/test/test_joint_pair_diagnostic.py deleted file mode 100644 index de78a79..0000000 --- a/src/linkerhand_calibration/test/test_joint_pair_diagnostic.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Known-truth joint image fit; diagnostic results never authorize a model.""" -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points -from linkerhand_calibration.core.geometry.tag_pose.joint_pair_diagnostic import JointPairImages, diagnose_joint_pair - - -@pytest.fixture(scope='module') -def fitted_pair(): - matrix = np.array([[1600.,0,640.],[0,1600.,480.],[0,0,1.]]) - reference = Rotation.from_euler('xy',[5,8],degrees=True) - origin,pivot = np.array([.015,-.04,.005]),np.array([.006,-.02,0.]) - objects = square_object_points(.016) - rows = [] - phases = np.r_[np.zeros(12,dtype=bool),np.ones(12,dtype=bool)] - for i,moving in enumerate(phases): - theta = np.deg2rad((i-12)*75/11) if moving else 0. - parent = Rotation.from_euler('xy',[15,10] if moving else [-25+i*50/11,20*np.sin(i/3)],degrees=True) - motion = Rotation.from_rotvec([0,0,theta]) - child = motion.apply(reference.apply(objects)+origin-pivot)+pivot - tags = {} - for role,points in [('parent',objects),('child',child)]: - camera = parent.apply(points)+[.015,-.02,.5] - projected = camera@matrix.T - tags[role] = dict(tag_size_m=.016,corners_xy=(projected[:,:2]/projected[:,2:]).tolist()) - rows.append(dict(image_stamp_ns=i+1,camera_matrix=matrix.tolist(),tags=tags)) - bundle = JointPairImages(rows,phases,'parent','child') - fit = bundle.fit((0,0,0,0)) - assert fit.success - return bundle,fit,reference,origin,pivot - - -def test_joint_fit_recovers_installation_and_axis_without_frozen_parent(fitted_pair): - bundle,fit,reference,origin,pivot = fitted_pair - assert np.max(np.abs(fit.fun)) < 1e-3 - assert (Rotation.from_rotvec(fit.x[:3]).inv()*reference).magnitude() < 1e-3 - assert np.linalg.norm(fit.x[3:6]-origin) < 1e-5 - axis,estimated = bundle.geometry(fit.x) - assert abs(axis@[0,0,1]) > .9999 - assert np.linalg.norm(estimated-pivot) < 1e-4 - assert all(np.isfinite(bundle.bounds(fit))) - - -def test_schur_covariance_includes_parent_and_angle_nuisance(fitted_pair): - bundle,fit,*_ = fitted_pair - jac = fit.jac.toarray() - scaled = jac*bundle.scale[None,:] - variance = max(.03**2,float(fit.fun@fit.fun/(len(fit.fun)-len(fit.x)))) - full = np.linalg.inv(scaled.T@scaled)[:10,:10]*variance*bundle.scale[:10,None]*bundle.scale[None,:10] - assert np.allclose(bundle.covariance(fit),full,rtol=1e-5,atol=1e-10) - # Treating parent poses as exact would incorrectly reduce uncertainty. - exact = np.linalg.inv(scaled[:,:10].T@scaled[:,:10])*variance*bundle.scale[:10,None]*bundle.scale[None,:10] - assert np.trace(full) > np.trace(exact) - - -def test_validation_keeps_shared_installation_and_hinge_frozen(fitted_pair): - bundle,fit,*_ = fitted_pair - original = fit.x.copy() - other = JointPairImages(bundle.rows,bundle.flexion,'parent','child') - residual = bundle.validate(fit,other) - assert np.max(np.abs(residual)) < 1e-3 - assert np.array_equal(original,fit.x) - - -def test_validation_angle_uncertainty_retains_shared_correlations(fitted_pair, monkeypatch): - bundle, fit, *_ = fitted_pair - rows = [{**row, 'image_stamp_ns': row['image_stamp_ns']+100} for row in bundle.rows] - other = JointPairImages(rows, bundle.flexion, 'parent', 'child') - original = fit.x.copy() - _, _, states = bundle.validate(fit, other, return_local_states=True) - covariance = bundle.validation_angle_covariance(fit, other, states) - assert np.allclose(covariance, covariance.T) - assert np.linalg.eigvalsh(covariance).min() > -1e-12 - assert np.all(np.diag(covariance)[other.flexion] > 0) - assert np.max(np.abs(covariance-np.diag(np.diag(covariance)))) > 1e-10 - monkeypatch.setattr(bundle, 'covariance', lambda fit: np.zeros((10, 10))) - conditional = bundle.validation_angle_covariance(fit, other, states) - assert np.trace(covariance) > np.trace(conditional) - assert np.array_equal(original, fit.x) - with pytest.raises(ValueError, match='independent_images'): - bundle.validation_angle_covariance(fit, bundle, states) - with pytest.raises(ValueError, match='state_invalid'): - bundle.validation_angle_covariance(fit, other, (*states[:-1], np.full(7, np.nan))) - other.roles = ('another_parent', 'another_child') - with pytest.raises(ValueError, match='contract_changed'): - bundle.validation_angle_covariance(fit, other, states) - other.roles = bundle.roles - original_rows = other.rows - other.rows = (*other.rows[:-1], other.rows[0]) - with pytest.raises(ValueError, match='independent_images'): - bundle.validation_angle_covariance(fit, other, states) - other.rows = original_rows - other.matrices[0, 0, 0] += 1 - with pytest.raises(ValueError, match='contract_changed'): - bundle.validation_angle_covariance(fit, other, states) - - -def test_vectorized_projection_matches_independent_per_image_formula(fitted_pair): - bundle,fit,*_ = fitted_pair - parameters = fit.x.copy() - parameters[:10] += np.linspace(-.001,.001,10) - scalar = np.concatenate([bundle.image_residual(parameters[:10], - parameters[offset:offset+(7 if moving else 6)],i) - for i,(moving,offset) in enumerate(zip(bundle.flexion,bundle.offsets))]) - assert np.allclose(bundle.residual(parameters),scalar,rtol=1e-9,atol=1e-9) - - -def test_joint_diagnostic_rejects_missing_or_overlapping_evidence(fitted_pair): - bundle,*_ = fitted_pair - with pytest.raises(ValueError,match='independent_images_missing'): - diagnose_joint_pair(bundle.rows[:12],bundle.rows[12:],'parent','child') - with pytest.raises(ValueError,match='source_order_invalid'): - diagnose_joint_pair(bundle.rows,bundle.rows,'parent','child') - - -def test_failed_candidates_are_retained_and_diagnostic_cannot_authorize(fitted_pair, monkeypatch): - bundle,*_ = fitted_pair - source = list(bundle.rows) - flexion = [{**row,'image_stamp_ns':row['image_stamp_ns']+24} for row in source] - def fail(self, branches): - raise ValueError('bounded_solver_failed') - monkeypatch.setattr(JointPairImages,'fit',fail) - result = diagnose_joint_pair(source,flexion,'parent','child') - assert result['publication_allowed'] is False - assert len(result['candidates']) == 16 - assert len({tuple(c['branches']) for c in result['candidates']}) == 16 - assert all(c['reason']=='bounded_solver_failed' for c in result['candidates']) - assert set(result['training_stamps']).isdisjoint(result['validation_stamps']) - assert not {'model','resolved','selected_hypothesis'} & result.keys() - - -def test_comparison_never_selects_winner_from_holdout(): - from linkerhand_calibration.core.geometry.tag_pose.joint_pair_diagnostic import _diagnostic_comparisons - phases = [False]*24+[True]*24 - motion = [[v,0.,0.,v] for v in np.tile(np.linspace(0.,1.5,24),2)] - common = dict(converged=True,validation_motion_coordinates=motion, - installation_quaternion_xyzw=[0.,0.,0.,1.],axis_parent_xyz=[0.,0.,1.], - pivot_parent_xyz_m=[0.,0.,0.]) - candidates = [dict(common,training_cost=cost,installation_translation_xyz_m=[index*.01,0.,0.], - validation_errors_by_role_px=[[error,error]]*48) - for index,(cost,error) in enumerate(((1.,1.),(2.,.01)))] - report = _diagnostic_comparisons(candidates,phases) - assert report['training_candidate'] == 0 - assert all(test['adjusted_p_value'] == 1. for test in report['phase_tests']) - - -def test_uncertainty_failure_does_not_erase_competing_image_fit(fitted_pair, monkeypatch): - bundle, fit, *_ = fitted_pair - source = list(bundle.rows) - flexion = [{**r, 'image_stamp_ns': r['image_stamp_ns']+24} for r in source] - def fitted(self, branches): - self.axis, self.basis = bundle.axis, bundle.basis - return fit - monkeypatch.setattr(JointPairImages, 'fit', fitted) - monkeypatch.setattr(JointPairImages, 'bounds', lambda *args: (.01, .0001)) - monkeypatch.setattr(JointPairImages, 'validate', lambda self, fit, other, **kwargs: - (np.zeros((other.count, 16)), np.zeros((other.count, 4)), ())) - def unavailable(*args): - raise ValueError('joint_pair_geometry_rank_deficient') - monkeypatch.setattr(JointPairImages, 'validation_angle_covariance', unavailable) - report = diagnose_joint_pair(source, flexion, 'parent', 'child') - assert len(report['candidates']) == 16 - assert all(c['converged'] and c['angle_uncertainty_available'] is False - and c['angle_uncertainty_reason'] == 'joint_pair_geometry_rank_deficient' - for c in report['candidates']) - assert report['publication_allowed'] is False diff --git a/src/linkerhand_calibration/test/test_joint_zero_runtime.py b/src/linkerhand_calibration/test/test_joint_zero_runtime.py deleted file mode 100644 index 9d19765..0000000 --- a/src/linkerhand_calibration/test/test_joint_zero_runtime.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Per-joint references use the production executor without ROS or hardware.""" - -from dataclasses import replace -import numpy as np -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.domain.reference import build_joint_zero_reference, read_joint_zero_references -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.scan_quality import observation_streams -from linkerhand_calibration.runtime.joint_resume import JointResume - - -@pytest.mark.parametrize("invalid", ["disabled_approach", "missing_approach", "wrong_baseline"]) -def test_zero_pose_contract_rejects_invalid_motion_before_hardware(invalid): - from linkerhand_calibration.core.domain.profile import validate_profile - profile = load_bundled_hand_profile("g20_right_19") - name, spec = next(iter(profile.motion.joint_zero_references.items())) - if invalid == "disabled_approach": - pose = list(spec.approach_commands[0]) - channel = min(profile.command.disabled_indices) - pose[channel] = 0 if pose[channel] else 255 - changed = replace(spec, approach_commands=(tuple(pose),)) - reason = "moves a disabled channel" - elif invalid == "missing_approach": - changed = replace(spec, approach_commands=()) - reason = "explicit nonzero final arrival" - else: - channel = next(t.command_index for t in profile.motion.tasks if t.key == spec.task_key) - pose = list(spec.command) - pose[channel] = 0 if pose[channel] else 255 - changed = replace(spec, command=tuple(pose)) - reason = "joint baseline, not task start" - profile = replace(profile, motion=replace(profile.motion, - joint_zero_references={**profile.motion.joint_zero_references, name: changed})) - with pytest.raises(ValueError, match=reason): - validate_profile(profile) - - -def reference_rows(profile, name, *, stamp=1, tilt=0): - from scipy.spatial.transform import Rotation - spec = profile.motion.joint_zero_references[name] - channel = next(t.command_index for t in profile.motion.tasks if t.key == spec.task_key) - direction = "increasing" if spec.command[channel] > spec.approach_commands[-1][channel] else "decreasing" - views = {profile.measurement.measurements[name].view} - secondary = profile.measurement.cross_view_sources.get(name) - if secondary: - views.add(profile.measurement.measurements[secondary].view) - pose = {"quaternion_xyzw": [0, 0, 0, 1], - "translation_xyz_m": [0, 0, .5]} - child = {"quaternion_xyzw": Rotation.from_rotvec([tilt, 0, 0]).as_quat().tolist(), "translation_xyz_m": [0, 0, .6]} - rows = [{"view": view, "image_stamp_ns": stamp+i, - "relative_quaternion_xyzw": child["quaternion_xyzw"], "relative_translation_xyz_m": [0, 0, .1], - f"command_vector_{profile.command.unit}": spec.command, f"state_{profile.command.unit}": spec.command, - "command_direction_by_index": [direction]*profile.command.command_count, - "parent_pose_common": pose, "child_pose_common": child} - for view in views for i in range(profile.acquisition.fixed_reference_minimum_frames)] - for row in rows: - primary = profile.measurement.measurements[name] - measurement = primary if primary.view == row["view"] else profile.measurement.measurements[secondary] - row["pnp_observation_evidence"] = { - side: {"pose_source": "current_image", "tag_role": role, - "candidate_diagnostics": {"observation_stamp_ns": row["image_stamp_ns"], - "branch_revision": 0, "branch_status": "tracking"}} - for side, role in (("parent", measurement.parent_role), ("child", measurement.child_role))} - return rows - - -def sweep_feedback_values(start, target, unit): - """Sample only this motion segment, at the same density over a full range.""" - fraction = abs(target-start)/abs(unit.end-unit.start) - return np.linspace(start, target, max(2, int(np.ceil(128*fraction))+1)) - - -@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) -def test_deferred_zero_is_locked_once_before_own_training_in_every_model(layout): - profile = load_bundled_hand_profile(layout) - driver = SessionExecution(profile) - driver.session.device_ready() - driver.session.start() - command, rows, references, stamp = profile.command.baseline_values, [], {}, 100 - trace = [] - for _ in range(6000): - phase = driver.session.phase - if phase == Phase.FIT: - break - if phase == Phase.REFERENCE_LOCKING: - required = driver.session.status().task.required_tag_ids_by_view - assert sum(len(ids) for ids in required.values()) == len(profile.vision.views) - driver.session.reference_locked() - assert driver.session.phase == Phase.PREPARE # No all-finger installation sweep. - continue - if phase == Phase.EVALUATE: - assert driver.evaluate(rows).passed - continue - segment_start = command - motion = driver.motion(command) - command = motion.target - trace.append((motion.phase, motion.task_key, motion.cycle)) - if motion.zero_joints: - for name in motion.zero_joints: - assert name not in references - spec = profile.motion.joint_zero_references[name] - assert command == spec.command - assert command[motion.command_index] == profile.command.baseline_values[motion.command_index] - 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 - elif motion.recording: - task = next(t for t in profile.motion.tasks if t.key == motion.task_key) - assert set(task.joints) <= references.keys() - unit = driver.action.scan_unit - feedback_values = (sweep_feedback_values(segment_start[task.command_index], - command[task.command_index], unit) if motion.phase == "sweep" else - (command[task.command_index],)*profile.acquisition.steady_minimum_samples) - for field, name, spec in observation_streams(profile, task): - for feedback in feedback_values: - stamp += 1 - rows.append({field: name, "view": spec.view, "task_name": task.key, - "cycle": unit.cycle, "direction": unit.direction, "attempt": 1, - "image_stamp_ns": stamp, "sample_phase": motion.phase, - "steady_index": motion.steady_index, "steady_target": command[task.command_index], - f"feedback_{profile.command.unit}": float(feedback)}) - driver.motion_complete() - assert driver.session.phase == Phase.FIT - assert references == driver.zero_references - assert set(references) == set(profile.motion.joint_zero_references) - if layout == "g20_right_19": - index_zero = trace.index(("joint_zero", "index_roll_multiview", 0)) - assert ("sweep", "middle_pip_side", 3) in trace[:index_zero] - reference = references["index_mcp_roll"] - assert reference.baseline_command == 127 # Task starts at 255. - assert any(np.any(np.asarray(s.command) != profile.command.baseline_values) for s in reference.samples) - - -def test_resume_verifies_same_pose_and_preserves_original_zero(): - profile = load_bundled_hand_profile("o6_right_8") - name = next(iter(profile.motion.joint_zero_references)) - old = build_joint_zero_reference(profile, name, reference_rows(profile, name), session_epoch=1, motion_version=2) - current = build_joint_zero_reference(profile, name, reference_rows(profile, name, stamp=100), session_epoch=2, motion_version=4) - resume = JointResume(profile) - resume.references[name] = old - assert resume.verify(current) is old - slipped = build_joint_zero_reference(profile, name, reference_rows(profile, name, stamp=100, tilt=.1), - session_epoch=2, motion_version=5) - with pytest.raises(ValueError, match="joint_resume_reference_changed"): - resume.verify(slipped) - with pytest.raises(ValueError, match="duplicate_or_unknown"): - read_joint_zero_references(profile, [old.as_record(), old.as_record()]) - with pytest.raises(ValueError, match="not_before_training"): - read_joint_zero_references(profile, [old.as_record(), {"kind": "joint_sample", "joint": name, "image_stamp_ns": 1}]) - tampered = old.as_record() - tampered["samples"][0]["sample_id"] = "different_image:1" - with pytest.raises(ValueError, match="joint_zero_image_identity_invalid"): - read_joint_zero_references(profile, [tampered]) - - -def test_incomplete_zero_window_does_not_revalidate_poses_on_every_control_tick(monkeypatch): - from linkerhand_calibration.core.domain.reference import JointZeroSample - profile = load_bundled_hand_profile("o6_right_8") - name = next(iter(profile.motion.joint_zero_references)) - rows = reference_rows(profile, name) - calls = [] - original = JointZeroSample.from_row - - def counted(row, unit): - calls.append(row["image_stamp_ns"]) - return original(row, unit) - - monkeypatch.setattr(JointZeroSample, "from_row", staticmethod(counted)) - for _ in range(20): - with pytest.raises(ValueError, match="joint_zero_samples_missing"): - build_joint_zero_reference(profile, name, rows[:-1], session_epoch=1, motion_version=1) - assert not calls - build_joint_zero_reference(profile, name, rows, session_epoch=1, motion_version=1) - assert len(calls) == len(rows) - rows[0]["relative_translation_xyz_m"] = [9, 9, 9] - with pytest.raises(ValueError, match="joint_zero_relative_pose_inconsistent"): - build_joint_zero_reference(profile, name, rows, session_epoch=1, motion_version=1) - - -def test_same_joint_in_later_task_cannot_acquire_a_second_reference(): - profile = load_bundled_hand_profile("o6_right_8") - first = profile.motion.tasks[0] - repeated = replace(first, key="repeat_same_joint") - profile = replace(profile, motion=replace(profile.motion, tasks=profile.motion.tasks+(repeated,))) - driver = SessionExecution(profile) - for name in first.joints: - driver.zero_references[name] = build_joint_zero_reference(profile, name, reference_rows(profile, name), - session_epoch=1, motion_version=1) - before = dict(driver.zero_references) - driver.session._unit_index = next(i for i, u in enumerate(driver.session.engine.scan_units()) if u.task_key == repeated.key) - driver.session.phase = Phase.PREPARE - assert driver.motion(profile.command.baseline_values).phase != "joint_zero" - assert driver.zero_references == before - - -def test_native_resume_imports_training_travel_only_after_reference_verification(tmp_path): - from copy import deepcopy - from pose_branch_fixture import add_ideal_motion_provenance - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - from runtime_host_fixture import coordinator_fixture, ready - from linkerhand_calibration.runtime.steady import steady_targets - host, clock = coordinator_fixture(tmp_path, model="o12") - try: - profile = host.profile - task = profile.motion.tasks[0] - units = [u for u in host.execution.session.engine.scan_units() if u.task_key == task.key and u.cycle == 0] - records, stamp = [{"kind": "session_start", "profile_id": profile.key.profile_id, - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "capture_schedule_version": host.execution.session.engine.capture_schedule_version, - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION}], 100 - templates = {} - for name in task.joints: - zeros = reference_rows(profile, name, stamp=10) - records.extend(add_ideal_motion_provenance(profile, name, zeros, - session_epoch=1, motion_version=1, source_stamps=(1, 2))) - templates[name] = {row["view"]: row for row in zeros} - reference = build_joint_zero_reference(profile, name, zeros, session_epoch=1, motion_version=2) - records.append(reference.as_record()) - for unit in units: - previous = unit.start - for index, target in enumerate(steady_targets(profile, unit)): - phases = [] - if index: - phases.append(("sweep", sweep_feedback_values(previous, target, unit))) - phases.append(("steady", (target,)*profile.acquisition.steady_minimum_samples)) - for phase, feedback_values in phases: - for field, name, spec in observation_streams(profile, task): - for feedback in feedback_values: - stamp += 1 - evidence = deepcopy(templates[name][spec.view]["pnp_observation_evidence"]) - for item in evidence.values(): - item["candidate_diagnostics"].update(observation_stamp_ns=stamp, branch_frozen=True) - records.append({"kind": "joint_sample" if field == "joint" else "secondary_joint_sample", - field: name, "view": spec.view, "task_name": task.key, "cycle": 0, - "direction": unit.direction, "attempt": 1, "image_stamp_ns": stamp, - "sample_phase": phase, "steady_index": index, "steady_target": target, - "feedback_rad": float(feedback), "pnp_observation_evidence": evidence}) - previous = target - keys = {(u.task_key, u.cycle, u.direction) for u in units} - host.joint_resume.stage(host.execution.session.engine, records, keys) - assert host.joint_resume.take_task(task.key) == ((), ()) - for name in task.joints: - zeros = reference_rows(profile, name, stamp=2000) - add_ideal_motion_provenance(profile, name, zeros, - session_epoch=2, motion_version=2, source_stamps=(1900, 1901)) - current = build_joint_zero_reference(profile, name, zeros, - session_epoch=2, motion_version=3) - host.execution.zero_references[name] = host.joint_resume.verify(current) - ready(host, clock) - assert host.start().success - clock.now += .1 - host.receive_feedback(profile.command.names, profile.command.baseline_values, host.ports.clock_ns()) - host.last_command = profile.command.baseline_values - host.execution.session.phase = Phase.PREPARE - host.tick() - while host._resume_import is not None: - clock.now += .02 - host.receive_feedback(profile.command.names, profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - assert host._resumed_count == 2 and host.execution.session.current_unit.cycle == 1 - assert host.execution.first_cycle_spans - assert all(value == pytest.approx(1) for value in host.execution.first_cycle_spans.values()) - finally: - host.close() - - -def test_zero_capture_recovers_after_transient_bad_pose_without_loosening_stability(tmp_path): - from types import SimpleNamespace - from runtime_host_fixture import coordinator_fixture - host, _clock = coordinator_fixture(tmp_path, model="o6") - try: - name = next(iter(host.profile.motion.joint_zero_references)) - bad = reference_rows(host.profile, name, tilt=.1)[:1] - stable = reference_rows(host.profile, name, stamp=100) - from pose_branch_fixture import confirm_reference_rows - confirm_reference_rows(host, stable) - host._steady_rows = [dict(row, kind="joint_zero_sample", joint=name) for row in bad+stable] - host._observation_epoch = host._segment_number = 1 - assert host._lock_joint_zeros(SimpleNamespace(task_key=host.profile.motion.joint_zero_references[name].task_key, - zero_joints=(name,))) - reference = host.execution.zero_references[name] - assert all(sample.image_stamp_ns >= 100 for sample in reference.samples) - assert len(host._steady_rows) == len(bad)+len(stable) # Evidence was not erased. - assert host.execution.session.phase != Phase.PAUSED - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_joint_zero_timing.py b/src/linkerhand_calibration/test/test_joint_zero_timing.py deleted file mode 100644 index 6f0630a..0000000 --- a/src/linkerhand_calibration/test/test_joint_zero_timing.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Irregular control timing must not erase stable joint-zero observations.""" - -import numpy as np -import pytest - -from linkerhand_calibration.runtime.inputs import DetectionInput -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from runtime_host_fixture import coordinator_fixture, ready -from test_joint_zero_runtime import reference_rows -from pose_branch_fixture import assume_motion_confirmation, confirm_reference_rows - - -@pytest.mark.parametrize("feedback_moves,fixed_occluded,recovery", [ - (False, False, False), (True, False, False), (False, True, False), (False, False, True), - (False, False, "slow"), (False, False, "nine_only")], - ids=["timer_jitter", "real_movement", "persistent_fixed_occlusion", "post_geometry_recovery", - "ten_slow_images", "nine_images_never_complete"]) -def test_zero_capture_requires_stable_feedback_and_valid_reference_images( - tmp_path, monkeypatch, feedback_moves, fixed_occluded, recovery): - host, clock = coordinator_fixture(tmp_path, model="o6") - try: - ready(host, clock) - assert host.start().success - task = host.profile.motion.tasks[0] - name = task.joints[0] - spec = host.profile.motion.joint_zero_references[name] - host.execution.session.phase = Phase.PREPARE - host.last_command = spec.command - # The declared approach has completed; exercise the real zero action, - # coordinator timing gates and reference builder from this point. - host.execution.motion(spec.command) - effects = host.execution._effects - index = next(i for i, effect in enumerate(effects) if effect.phase == "joint_zero") - host.execution._effects = effects[index:] - host.commanded_speed = host.execution._effects[0].speed - host._command_directions = ["increasing"]*host.command_count - assume_motion_confirmation(host, task.joints) - - def consume(frame, motion, **kwargs): - if motion is None: - return (), None - assert frame.feedback is not None and frame.command == spec.command - if fixed_occluded: - return ({"kind": "pnp_branch_event", "view": "top", "tag_role": "top_base", "tag_id": 6, - "image_stamp_ns": frame.stamp_ns, "reason": "fixed_reference_pose_unverified", - "pnp_observation_evidence": {"candidate_diagnostics": { - "observation_stamp_ns": frame.stamp_ns, "branch_status": "fixed_reference_unavailable", - "pose_constraint": "fixed_reference", "fixed_reference_verified": False}}},), None - row = reference_rows(host.profile, name, stamp=frame.stamp_ns)[0] - confirm_reference_rows(host, [row]) - return (dict(row, kind="joint_zero_sample", joint=name),), None - - monkeypatch.setattr(host.capture, "consume", consume) - start = clock.now - frames = (.59, .68, .77, .79, .81, .83, .85, .87, .89, .91) - if recovery: - # Real recorded run: tracker recovery consumes the first part of - # the post-solve window, followed by valid images at about 7 Hz. - frames = tuple(1.1+.14*i for i in range(10)) - if recovery in {"slow", "nine_only"}: - frames = tuple(0.8+.35*i for i in range(9 if recovery == "nine_only" else 10)) - times = sorted({*np.arange(0, .51, .02), *frames, frames[-1]+.02, .93, 2.02, 3.5, 7.1}) - if recovery in {"slow", "nine_only"}: - # A failed zero image window now gets one bounded fresh window. - # Keep feedback alive through that window without adding images. - times = sorted({*times, *np.arange(0, 9.11, .02)}) - for elapsed in times: - clock.now = start+float(elapsed) - feedback = list(spec.command) - if feedback_moves and elapsed == .77: - feedback[task.command_index] -= 5 - host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns()) - host.tick() - if recovery and elapsed == 0: - host._zero_geometry_completed_at = clock.now - if name in host.execution.zero_references: - break - if elapsed in frames: - host.receive_detections(DetectionInput(task.view, host.ports.clock_ns(), ())) - - if feedback_moves or fixed_occluded or recovery == "nine_only": - assert not host.execution.zero_references - assert host.execution.session.phase == Phase.PAUSED - assert "joint_zero_unavailable" in host.reason - assert len([r for r in host.raw_records if r.get("kind") == "joint_zero_recovery"]) == 1 - if fixed_occluded: - assert "fixed_reference_pose_unverified" in host.reason - assert "top:tag=6" in host.reason - assert not host.finalization.started - else: - assert host.execution.session.phase != Phase.PAUSED, host.reason - reference = host.execution.zero_references[name] - assert len(reference.samples) == host.profile.acquisition.fixed_reference_minimum_frames == 10 - assert len([r for r in host.raw_records if r["kind"] == "joint_zero_reference"]) == 1 - if recovery == "slow": - assert host.profile.acquisition.steady_timeout_seconds < clock.now-start < 5.0 - elif recovery: - assert host.profile.acquisition.steady_timeout_seconds < clock.now-start < 3.0 - else: - assert clock.now-start < host.profile.acquisition.steady_timeout_seconds - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_json_urdf_rebuild.py b/src/linkerhand_calibration/test/test_json_urdf_rebuild.py deleted file mode 100644 index 1eb1f08..0000000 --- a/src/linkerhand_calibration/test/test_json_urdf_rebuild.py +++ /dev/null @@ -1,92 +0,0 @@ -"""JSON alone reproduces the written URDF and rejects unsafe edits atomically.""" - -import hashlib -import json - -import numpy as np -import pytest - -from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.runtime.artifacts.urdf_from_json import ( - rebuild_urdf_from_json, serialize_urdf_correction, - validate_json_urdf_correspondence, -) - - -@pytest.fixture -def saved_calibration(tmp_path): - source = tmp_path/"source.urdf" - source.write_text(''' - - - - - - - -''') - rights = {name: ("origin.rpy", "limit.lower", "limit.upper", "mimic.multiplier", "mimic.offset") - for name in ("drive", "tip")} - plan = build_standard_correction_plan(source_urdf=source, - source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), - zero_offsets_rad={"drive": .1, "tip": .05}, - measured_ranges_output_rad={"drive": (0., 1.5), "tip": (0., .75)}, - authorized_fields=rights) - payload = {"format": "unified_calibration_v3", "schema_version": 3, - "profile_id": "VIRTUAL/right/rebuild/v1", "input_unit": "u8", "baseline_command": [0], - "joints": {name: {"sdk_channel": 0, "angle_rad": np.linspace(0, upper, 256).tolist()} - for name, upper in (("drive", 1.5), ("tip", .75))}, - "urdf_correction": serialize_urdf_correction(plan)} - path = tmp_path/"calibration.json" - path.write_text(json.dumps(payload)) - return source, rights, plan, path, payload - - -def rebuild(saved, output): - source, rights, _, path, payload = saved - return rebuild_urdf_from_json(path, source, output, - authorized_fields=rights, expected_profile_id=payload["profile_id"]) - - -def test_saved_json_reproduces_bytes_and_is_the_only_edit_source(saved_calibration, tmp_path): - source, _, plan, path, payload = saved_calibration - digest = hashlib.sha256(source.read_bytes()).hexdigest() - expected = plan.write(source, tmp_path/"expected.urdf") - output = rebuild(saved_calibration, tmp_path/"from_json.urdf") - assert output.read_bytes() == expected.read_bytes() - validate_json_urdf_correspondence(payload, source, output, expected_profile_id=payload["profile_id"]) - # A later caller has no in-memory fit. The requested edit is read anew. - payload["urdf_correction"]["joint_patches"]["drive"]["origin_rpy"] = "0 0 0.2" - path.write_text(json.dumps(payload)) - with pytest.raises(ValueError, match="correction_differs_from_urdf"): - validate_json_urdf_correspondence(payload, source, output, expected_profile_id=payload["profile_id"]) - changed = rebuild(saved_calibration, tmp_path/"changed.urdf") - assert not np.allclose(UrdfKinematicModel(changed).joints["drive"].origin, - UrdfKinematicModel(expected).joints["drive"].origin) - assert hashlib.sha256(source.read_bytes()).hexdigest() == digest - with pytest.raises(ValueError, match="overwrite"): - rebuild(saved_calibration, output) - - -@pytest.mark.parametrize("corruption", ["source", "profile", "unknown_field", "nonfinite", "curve_limit", "missing"]) -def test_invalid_json_never_installs_a_destination(saved_calibration, tmp_path, corruption): - source, rights, _, path, payload = saved_calibration - if corruption == "source": - source.write_text(source.read_text()+"\n") - elif corruption == "profile": - payload["profile_id"] = "WRONG" - elif corruption == "unknown_field": - payload["urdf_correction"]["joint_patches"]["drive"]["axis_xyz"] = "1 0 0" - elif corruption == "nonfinite": - payload["urdf_correction"]["joint_patches"]["drive"]["origin_rpy"] = "0 nan 0" - elif corruption == "curve_limit": - payload["urdf_correction"]["joint_patches"]["drive"]["limit_upper"] = "1.4" - else: - del payload["urdf_correction"] - 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="VIRTUAL/right/rebuild/v1") - assert not output.exists() diff --git a/src/linkerhand_calibration/test/test_kinematic_chain_diagnostic.py b/src/linkerhand_calibration/test/test_kinematic_chain_diagnostic.py deleted file mode 100644 index 37b21f5..0000000 --- a/src/linkerhand_calibration/test/test_kinematic_chain_diagnostic.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Stage references are rotations of one chain, never free rigid poses.""" -from copy import deepcopy - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from test_finger_chain_diagnostic import make_chain -from linkerhand_calibration.core.geometry.tag_pose.kinematic_chain_diagnostic import KinematicFingerChainImages - - -@pytest.fixture(scope='module') -def linked_fit(): - original, _, axes, _ = make_chain(parallel=True, full_parallel=True) - def bundle(rows): - return KinematicFingerChainImages(rows, original.phases, original.roles, - pip_dip_distance_m=original.distances[2], mcp_pip_distance_m=original.distances[1]) - canonical = bundle(original.rows) - seed = canonical.fit((0,)*6) - assert seed.success and np.max(np.abs(seed.fun)) < 1e-4 - shared = seed.x[:canonical.static_width].copy() - shared[canonical.stage_columns[0]:] = [.012, -.018, -.009, .015] - angles = np.tile(np.linspace(0., 1.3, 12), 3) - pixels = canonical.project(shared, angles, canonical.phases) - rows = deepcopy(original.rows) - for row, points in zip(rows, pixels): - for role, corners in zip(original.roles, points.reshape(2, 4, 2)): - row['tags'][role]['corners_xy'] = corners.tolist() - training = bundle(rows) - fit = training.fit((0,)*6) - assert fit.success - return training, fit, axes - - -def test_linked_stage_changes_recover_shared_geometry(linked_fit): - training, fit, axes = linked_fit - assert training.static_width == 22 # 18 shared + four physical visit angles. - assert len(training.angle_columns) == training.count-2 - assert np.max(np.abs(fit.fun)) < 1e-4 - geometry = training.physical_geometry(fit.x[:training.static_width]) - assert np.min(np.abs(np.sum(geometry[:, :3]*axes, axis=1))) > .99999 - - -def test_linked_covariance_includes_held_stage_angles(linked_fit): - training, fit, _ = linked_fit - jac = fit.jac*training.scale[None, :] - width = training.static_width - expected = np.linalg.inv(jac.T@jac)[:width, :width]*.03**2 - expected *= training.scale[:width, None]*training.scale[None, :width] - assert np.allclose(training.covariance(fit), expected, atol=1e-9, rtol=1e-3) - - -def test_frozen_linked_validation_rejects_unexplained_stage_translation(linked_fit): - training, fit, _ = linked_fit - rows = deepcopy(training.rows) - for row, phase in zip(rows, training.phases): - row['image_stamp_ns'] += 10000 - if phase == 1: - for tag in row['tags'].values(): - tag['corners_xy'] = (np.asarray(tag['corners_xy'])+[0., 5.]).tolist() - validation = KinematicFingerChainImages(rows, training.phases, training.roles, - pip_dip_distance_m=training.distances[2], mcp_pip_distance_m=training.distances[1]) - before = fit.x.copy() - residual, angles = training.validate(fit, validation) - assert np.array_equal(before, fit.x) - assert np.sqrt(np.mean(residual[training.phases == 1]**2)) > 1.5 - assert np.all(np.isfinite(training.validation_angle_covariance(fit, validation, angles))) - - -def test_camera_geometry_translation_and_correlated_covariance(linked_fit): - training, fit, _ = linked_fit - shared = fit.x[:training.static_width].copy() - before = training.camera_geometry(shared) - displacement = np.array([.01, -.02, .03]) - shared[3:6] += displacement - after = training.camera_geometry(shared) - assert np.allclose(after[:, :3], before[:, :3]) - assert np.allclose(after[:, 3:]-before[:, 3:], displacement) - # A common translation must remain correlated across all three joints. - covariance = np.zeros((training.static_width, training.static_width)) - covariance[3:6, 3:6] = np.eye(3)*1e-6 - propagated = training.camera_geometry_covariance(shared, covariance) - for first in range(3): - for second in range(3): - assert np.allclose(propagated[6*first+3:6*first+6, - 6*second+3:6*second+6], np.eye(3)*1e-6) - assert np.allclose(propagated[:3], 0.) - signature = training.training_signature(shared) - assert np.allclose(signature['stage_poses'][2][:3], - np.column_stack((Rotation.from_rotvec(shared[:3]).as_matrix(), shared[3:6]))) diff --git a/src/linkerhand_calibration/test/test_l6_right_profile.py b/src/linkerhand_calibration/test/test_l6_right_profile.py deleted file mode 100644 index 9d05339..0000000 --- a/src/linkerhand_calibration/test/test_l6_right_profile.py +++ /dev/null @@ -1,345 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import re - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core import validate_profile -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import cosine_position_trajectory_u8 -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import ( - CALIBRATED_ACTIVE_JOINTS, - COMMAND_NAMES, - ENDPOINT_ANCHOR_BY_JOINT, - KEY, - build_typed_profile, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.runner import render_l6_progress_zh -from linkerhand_calibration.extrinsics import matrix_payload, transform_matrix -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.product import load_product_config, sha256_file - - -PACKAGE = Path(__file__).resolve().parents[1] -SOURCE = PACKAGE / "urdf/l6_right/linkerhand_l6v3.1_right.urdf" -LEFT_SOURCE = PACKAGE / "urdf/l6_left/linkerhand_l6v3.1_left.urdf" -PRODUCT = PACKAGE / "config/l6_right_product.yaml" - -TRAVELS = { - "rh_thumb_cmc_roll": 1.34, - "rh_thumb_cmc_pitch": 0.78, - "rh_pinky_mcp_pitch": 1.10, -} -MULTIPLIERS = {"rh_thumb_dip": 1.20, "rh_pinky_dip": 1.10} -THUMB_ZERO_OFFSETS = { - "rh_thumb_cmc_roll": 0.04, - "rh_thumb_cmc_pitch": -0.05, -} -KINEMATIC_MODEL = UrdfKinematicModel(SOURCE) - - -def _pose(xyz: list[float], rpy: list[float]) -> np.ndarray: - return transform_matrix(xyz, Rotation.from_euler("xyz", rpy).as_quat()) - - -def _geometric_observation( - joint: str, - feedback: int, - *, - thumb_zero_offsets: dict[str, float] | None = None, -) -> dict[str, object]: - model = KINEMATIC_MODEL - state = [255.0] * 6 - motor = { - "rh_thumb_cmc_pitch": 0, - "rh_thumb_dip": 0, - "rh_thumb_cmc_roll": 1, - "rh_pinky_mcp_pitch": 5, - "rh_pinky_dip": 5, - }[joint] - state[motor] = float(feedback) - active_angles = { - name: travel * (255.0 - state[index]) / 255.0 - for name, travel, index in ( - ("rh_thumb_cmc_pitch", TRAVELS["rh_thumb_cmc_pitch"], 0), - ("rh_thumb_cmc_roll", TRAVELS["rh_thumb_cmc_roll"], 1), - ("rh_pinky_mcp_pitch", TRAVELS["rh_pinky_mcp_pitch"], 5), - ) - } - joint_angles = { - **active_angles, - "rh_thumb_dip": active_angles["rh_thumb_cmc_pitch"] - * MULTIPLIERS["rh_thumb_dip"], - "rh_pinky_dip": active_angles["rh_pinky_mcp_pitch"] - * MULTIPLIERS["rh_pinky_dip"], - } - zero_offsets = { - **( - THUMB_ZERO_OFFSETS - if thumb_zero_offsets is None - else thumb_zero_offsets - ), - "rh_pinky_mcp_pitch": 0.0, - } - base_common = _pose([0.11, -0.04, 0.72], [0.11, -0.08, 0.17]) - base_mounts = { - "front": _pose([0.0, 0.008, 0.055], [0.2, -0.1, 0.3]), - "side": _pose([-0.01, 0.002, 0.045], [-0.1, 0.2, -0.2]), - "top": _pose([0.012, -0.006, 0.05], [0.1, 0.3, 0.15]), - } - link_mounts = { - "rh_thumb_cmc_roll": _pose([0.012, 0.019, 0.006], [0.2, 0.1, -0.1]), - "rh_thumb_cmc_pitch": _pose([0.009, -0.028, 0.011], [-0.2, 0.1, 0.25]), - "rh_thumb_dip": _pose([0.006, -0.025, 0.018], [0.15, -0.1, 0.2]), - "rh_pinky_mcp_pitch": _pose([0.006, 0.004, 0.021], [-0.1, 0.2, 0.1]), - "rh_pinky_dip": _pose([0.004, 0.003, 0.019], [0.12, 0.08, -0.2]), - } - view = { - "rh_thumb_cmc_roll": "top", - "rh_thumb_cmc_pitch": "front", - "rh_thumb_dip": "front", - "rh_pinky_mcp_pitch": "side", - "rh_pinky_dip": "side", - }[joint] - if joint in {"rh_thumb_dip", "rh_pinky_dip"}: - parent_joint = { - "rh_thumb_dip": "rh_thumb_cmc_pitch", - "rh_pinky_dip": "rh_pinky_mcp_pitch", - }[joint] - parent = base_common @ model.link_transform( - parent_joint, - zero_offsets=zero_offsets, - joint_angles=joint_angles, - independent_mimic_angles=True, - ) @ link_mounts[parent_joint] - else: - parent = base_common @ base_mounts[view] - child = base_common @ model.link_transform( - joint, - zero_offsets=zero_offsets, - joint_angles=joint_angles, - independent_mimic_angles=True, - ) @ link_mounts[joint] - relative = np.linalg.inv(parent) @ child - return { - "state_u8": state, - "relative_translation_xyz_m": relative[:3, 3].tolist(), - "relative_quaternion_xyzw": Rotation.from_matrix( - relative[:3, :3] - ).as_quat().tolist(), - "parent_pose_common": matrix_payload(parent), - "child_pose_common": matrix_payload(child), - "view_normal_common_xyz": [0.577350269, 0.577350269, 0.577350269], - "camera_center_common_xyz_m": [0.0, 0.0, -0.5], - } - - -def _synthetic_records( - *, thumb_zero_offsets: dict[str, float] | None = None -) -> list[dict]: - result = [] - task_by_joint = { - "rh_thumb_cmc_roll": "thumb_roll_top", - "rh_thumb_cmc_pitch": "thumb_pitch_dip_front", - "rh_thumb_dip": "thumb_pitch_dip_front", - "rh_pinky_mcp_pitch": "pinky_pitch_dip_side", - "rh_pinky_dip": "pinky_pitch_dip_side", - } - source_by_passive = { - "rh_thumb_dip": "rh_thumb_cmc_pitch", - "rh_pinky_dip": "rh_pinky_mcp_pitch", - } - for name, task in task_by_joint.items(): - travel = ( - TRAVELS[name] - if name in TRAVELS - else TRAVELS[source_by_passive[name]] * MULTIPLIERS[name] - ) - for cycle in range(4): - for direction, commands in ( - ("decreasing", range(255, -1, -4)), - ("increasing", range(0, 256, 4)), - ): - values = list(commands) - if values[-1] not in {0, 255}: - values.append(0 if direction == "decreasing" else 255) - for feedback in values: - angle = travel * (255 - feedback) / 255.0 - geometry = _geometric_observation( - name, - feedback, - thumb_zero_offsets=thumb_zero_offsets, - ) - result.append( - { - "kind": "l6_joint_sample", - "task_name": task, - "joint": name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "feedback_u8": float(feedback), - **geometry, - } - ) - return result - - -def _synthetic_nonlinear_pinky_records() -> list[dict]: - records = _synthetic_records() - mounted = Rotation.from_euler("xyz", [0.2, -0.1, 0.3]) - for row in records: - if row["joint"] != "rh_pinky_dip": - continue - active = TRAVELS["rh_pinky_mcp_pitch"] * ( - 255.0 - float(row["feedback_u8"]) - ) / 255.0 - angle = 1.2 * active - 0.2 * active * active - observed = mounted * Rotation.from_rotvec([angle, 0.0, 0.0]) - row["relative_quaternion_xyzw"] = observed.as_quat().tolist() - return records - - -def _joint_block(text: str, name: str) -> str: - pattern = re.compile( - rf"]*\btype\s*=)[^>]*\bname=\"{re.escape(name)}\"" - rf"[^>]*>.*?", - re.DOTALL, - ) - match = pattern.search(text) - assert match is not None - return match.group(0) - - -def test_l6_profile_declares_six_channels_eight_tags_and_partial_scope() -> None: - profile = build_typed_profile() - validate_profile(profile) - assert profile.key == KEY - assert profile.namespace == "/l6_calibration" - assert profile.command.names == COMMAND_NAMES - assert profile.command.baseline_u8 == (255,) * 6 - assert profile.command.feedback_name_aliases == { - "thumb_cmc_yaw": "thumb_cmc_roll" - } - assert profile.motion.speed_parameters["preflight_u8"] == 1 - assert profile.motion.speed_parameters["formal_u8"] == 1 - assert profile.motion.speed_parameters["speed_settle_seconds"] == 0.2 - assert ( - profile.motion.speed_parameters["command_trajectory_full_range_seconds"] - == 6.0 - ) - assert all(task.preflight_speed_u8 == 1 for task in profile.motion.tasks) - assert all(task.formal_speed_u8 == 1 for task in profile.motion.tasks) - assert profile.vision.tag_ids == frozenset(range(8)) - assert profile.scope.selected_joints("partial") == CALIBRATED_ACTIVE_JOINTS - assert len(profile.joint_coverage) == 11 - assert profile.artifacts.publication_pointer == "latest_partial_passed" - assert profile.zero.coupling_model_by_joint == { - "rh_thumb_dip": "linear_mimic", - "rh_pinky_dip": "linear_mimic", - "rh_index_dip": "linear_mimic", - "rh_middle_dip": "linear_mimic", - "rh_ring_dip": "linear_mimic", - } - assert profile.zero.endpoint_anchor_by_joint == ENDPOINT_ANCHOR_BY_JOINT - - -def test_l6_operator_progress_names_current_task_round_and_direction() -> None: - text = render_l6_progress_zh( - { - "state": "RUNNING", - "serial_number": "L6_TEST", - "step_index": 42, - "step_count": 61, - "step_fraction": 0.5, - "task_name": "pinky_pitch_dip_side", - "phase": "sweep", - "cycle": 3, - "direction": "decreasing", - "attempt": 2, - "target_u8": 0, - "current_command_u8": 130.0, - "actual_u8": 127.5, - "valid_frames": 50, - "total_frames": 52, - "tag_detection_rate": 50 / 52, - "joint_frame_rate": 50 / 52, - "recognized_tag_ids": [3, 4], - "unrecognized_tag_ids": [5], - "feedback_hz": 29.8, - "formal_speed_u8": 1, - } - ) - assert "[L6_TEST] 标定中" in text - assert "小指 MCP pitch / DIP(侧面机位,ID3→ID4→ID5)" in text - assert "阶段:正式扫描(第 4/4 轮)" in text - assert "方向:递减" in text - assert "已识别 ID3/ID4" in text - assert "未识别/不合格 ID5" in text - assert "已自动重扫 1 次" in text - assert "步骤" not in text - - -def test_l6_six_second_cosine_trajectory_is_monotonic_and_smooth() -> None: - start, start_phase, duration = cosine_position_trajectory_u8( - 255.0, 0.0, 0.0, 6.0 - ) - middle, middle_phase, _ = cosine_position_trajectory_u8( - 255.0, 0.0, 3.0, 6.0 - ) - end, end_phase, _ = cosine_position_trajectory_u8( - 255.0, 0.0, 6.0, 6.0 - ) - assert (start, start_phase, duration) == pytest.approx((255.0, 0.0, 6.0)) - assert (middle, middle_phase) == pytest.approx((127.5, 0.5)) - assert (end, end_phase) == pytest.approx((0.0, 1.0)) - - commands = [ - round(cosine_position_trajectory_u8(255.0, 0.0, tick / 100.0, 6.0)[0]) - for tick in range(601) - ] - assert all(right <= left for left, right in zip(commands, commands[1:])) - assert max(abs(right - left) for left, right in zip(commands, commands[1:])) <= 1 - assert cosine_position_trajectory_u8(255.0, 127.5, 1.5, 6.0)[2] == pytest.approx(3.0) - - -def test_l6_operator_progress_shows_exact_failed_sweep_metric() -> None: - text = render_l6_progress_zh( - { - "state": "PAUSED", - "serial_number": "L6_TEST", - "reason": "sweep_quality_failed:thumb_roll_top:maximum_gap=22", - "phase": "sweep", - } - ) - assert "具体未通过项:maximum_gap=22" in text - - -def test_l6_operator_progress_explains_nonlinear_mimic_failure() -> None: - text = render_l6_progress_zh( - { - "state": "PAUSED", - "serial_number": "L6_TEST", - "reason": ( - "fit_or_publication_failed:mimic_residual_exceeds:" - "joint=rh_pinky_dip:multiplier=1.008793:" - "p95_deg=4.715:maximum_deg=6.476" - ), - } - ) - assert "rh_pinky_dip 的 linear_mimic 耦合模型未达到精度门限" in text - assert "P95=4.715°、最大=6.476°" in text - - -def test_l6_product_config_and_immutable_source_hash_are_valid() -> None: - product = load_product_config(PRODUCT, workspace=PACKAGE.parents[1], check_can=False) - assert product.profile_key == KEY - assert product.source_urdf == SOURCE.resolve() - assert sha256_file(SOURCE) == ( - "298c1fbf5189648911426f530b50bdbeea4830cab9c54e20f46c532485df4666" - ) - assert product.camera_extrinsics_sha256 == ( - "dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80" - ) diff --git a/src/linkerhand_calibration/test/test_mapping_input_contract.py b/src/linkerhand_calibration/test/test_mapping_input_contract.py deleted file mode 100644 index ded9946..0000000 --- a/src/linkerhand_calibration/test/test_mapping_input_contract.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Timed feedback remains continuous across fitting and serialized replay.""" - -import math - -import pytest - -from linkerhand_calibration.acquisition import StateSample, interpolate_state_u8 -from linkerhand_calibration.core.domain.result import JointMapping -from linkerhand_calibration.core.urdf.acceptance import SerializedJointMapping -from linkerhand_calibration.runtime.artifacts.serializers.unified_v1 import _mapping - - -def mapping(domain): - return JointMapping("joint", 0, domain, (0., 100., 255.), - (0., 1., 4.), (0., 1.2, 4.), (0., .8, 4.)) - - -def serialized(curve, version=3): - kind, unit = curve.input_domain.split("_") - payload = { - "format": "unified_calibration_report_v3" if version == 3 else "unified_calibration_v1", - "schema_version": version, "command_unit": unit, "command_names": ["motor"], - "joints": {"joint": {"urdf_joint": "joint", "passive": False, - kind + "_to_rad": _mapping(curve)}}, - } - return SerializedJointMapping(payload, input_kind=kind) - - -@pytest.mark.parametrize("version", [1, 3]) -@pytest.mark.parametrize("direction, at_100", [("", 1.), ("increasing", 1.2), ("decreasing", .8)]) -def test_fractional_feedback_agrees_with_declared_piecewise_linear_curve(version, direction, at_100): - curve = mapping("feedback_u8") - reader = serialized(curve, version) - # A half-byte measured position must not acquire a new quantization jump. - for value in (99.49, 99.50, 99.51): - expected = value / 100. * at_100 - assert curve.evaluate(value, direction) == pytest.approx(expected) - assert reader.evaluate((value,), direction, active_joints=("joint",))["joint"] == pytest.approx(expected) - - -def test_image_between_two_can_receipts_retains_its_interpolated_position(): - state, _ = interpolate_state_u8( - [StateSample(1_000_000, (99.,)), StateSample(3_000_000, (100.,))], - 2_000_000, maximum_skew_ns=2_000_000) - assert state == (99.5,) - reader = serialized(mapping("feedback_u8")) - assert reader.evaluate(state, "increasing", active_joints=("joint",))["joint"] == pytest.approx(1.194) - - -@pytest.mark.parametrize("domain", ["command_u8", "command_rad", "feedback_rad"]) -def test_wire_commands_and_radian_inputs_keep_their_existing_semantics(domain): - curve = mapping(domain) - value = 99.6 - expected_input = 100. if domain == "command_u8" else value - expected = expected_input / 100. - assert curve.evaluate(value) == pytest.approx(expected) - assert serialized(curve).evaluate((value,), "", active_joints=("joint",))["joint"] == pytest.approx(expected) - - -@pytest.mark.parametrize("domain", ["command_u8", "feedback_u8"]) -@pytest.mark.parametrize("value", [-.1, 255.1, math.nan]) -def test_quantization_cannot_hide_an_input_outside_measured_support(domain, value): - curve = mapping(domain) - with pytest.raises(ValueError): - curve.evaluate(value) - with pytest.raises(ValueError): - serialized(curve).evaluate((value,), "", active_joints=("joint",)) - - -@pytest.mark.parametrize("domain", ["command_u8", "feedback_u8"]) -def test_legacy_byte_table_keeps_its_deployed_lookup(domain): - reader = SerializedJointMapping({"schema_version": 6, "curve_input_domain": domain, - "joints": {"joint": {"motor_index": 0, "angle_rad": list(range(256))}}}) - assert reader.evaluate((99.6,), "", active_joints=("joint",))["joint"] == 100. diff --git a/src/linkerhand_calibration/test/test_measured_feedback_time.py b/src/linkerhand_calibration/test/test_measured_feedback_time.py deleted file mode 100644 index 8cbae77..0000000 --- a/src/linkerhand_calibration/test/test_measured_feedback_time.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Feedback synchronization uses each physical receipt, never a repeated cache.""" - -import json -import pytest - -from linkerhand_calibration.acquisition import StateSample, interpolate_state_u8 -from linkerhand_calibration.runtime.feedback import decode_timed_feedback -from runtime_host_fixture import coordinator_fixture - - -def test_independent_channel_times_recover_a_linear_motion_without_cache_delay(): - samples = ( - StateSample(10_000_000, (0., 2.), (1_000_000, 10_000_000)), - StateSample(20_000_000, (10., 2.), (20_000_000, 10_000_000)), - StateSample(30_000_000, (10., 6.), (20_000_000, 30_000_000)), - ) - values, skew = interpolate_state_u8(samples, 15_000_000, maximum_skew_ns=50_000_000) - assert values == pytest.approx((140/19, 3.)) - assert skew == 5_000_000 - - -def test_recent_cache_cannot_hide_a_stale_channel(): - samples = (StateSample(100_000_000, (10., 20.), (1_000_000, 100_000_000)),) - assert interpolate_state_u8(samples, 100_000_000, maximum_skew_ns=50_000_000) is None - - -def test_conflicting_values_for_one_receipt_are_invalid(): - samples = (StateSample(10, (10., 20.), (1, 10)), StateSample(20, (11., 21.), (1, 20))) - assert interpolate_state_u8(samples, 10, maximum_skew_ns=50) is None - - -@pytest.mark.parametrize('change', [dict(schema='heartbeat'), dict(stamp_ns=21), - dict(channel_stamps_ns=[0, 20]), dict(position=[1.]), dict(channel_stamps_ns=[1.0, 20])]) -def test_malformed_receipt_contract_is_rejected(change): - payload = dict(schema='can_feedback_v1', name=['a','b'], position=[1., 2.], - stamp_ns=20, channel_stamps_ns=[10, 20]) - with pytest.raises(ValueError): - decode_timed_feedback(json.dumps({**payload, **change})) - - -def test_duplicate_and_out_of_order_cache_do_not_refresh_watchdog(tmp_path): - host, clock = coordinator_fixture(tmp_path) - try: - names, values = host.profile.command.names, host.profile.command.baseline_values - stamp = host.ports.clock_ns() - host.receive_feedback(names, values, stamp, channel_stamps_ns=(stamp,)*len(values)) - first = tuple(host.state_receive_times) - clock.now += .5 - host.receive_feedback(names, values, stamp, channel_stamps_ns=(stamp,)*len(values)) - host.receive_feedback(names, values, stamp-1, channel_stamps_ns=(stamp-1,)*len(values)) - assert tuple(host.state_receive_times) == first - assert len(host.state_history) == 1 - finally: - host.close() - - -@pytest.mark.parametrize('displacement_ns', [-2_000_000_000, 2_000_000_000]) -def test_one_stale_or_future_channel_cannot_be_hidden_by_fresh_channels(tmp_path, displacement_ns): - host, clock = coordinator_fixture(tmp_path) - try: - names, values = host.profile.command.names, host.profile.command.baseline_values - stamps = [host.ports.clock_ns()]*len(values) - stamps[0] += displacement_ns - host.receive_feedback(names, values, max(stamps), channel_stamps_ns=stamps) - assert not host.state_history - assert not host.state_receive_times - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_measured_finalization.py b/src/linkerhand_calibration/test/test_measured_finalization.py deleted file mode 100644 index 21978fc..0000000 --- a/src/linkerhand_calibration/test/test_measured_finalization.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Production v3 files replay independent synthetic nonlinear passive motion.""" - -import hashlib -import json -import numpy as np -import pytest -from dataclasses import replace -from copy import deepcopy -import shutil - -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 - - -@pytest.fixture(scope="module", params=[False, True], ids=["original_layout", "virtual_reordered"]) -def measured_release(tmp_path_factory, request): - tmp_path = tmp_path_factory.mktemp("measured_release") - profile, source, records, offsets = measured_capture(tmp_path, reordered=request.param) - hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields} - hashes["source_urdf_sha256"] = hashlib.sha256(source.read_bytes()).hexdigest() - payload, fit, artifact = finalize_profile_session(profile=profile, session_dir=tmp_path/"session", - serial_number="SYNTHETIC_ONLY", source_urdf=source, protected_inputs=hashes, - records=records, standard_loader=lambda path: None) - return profile, source, records, hashes, payload, fit, artifact - - -def test_nonlinear_full_pair_and_separate_mimic_approximation_are_reported(measured_release): - profile, source, records, hashes, payload, fit, artifact = measured_release - assert payload["schema_version"] == 3 - acceptance = json.loads(artifact.staged_release.validation_json) - assert acceptance["independent_mapping_holdout"] - assert acceptance["urdf_joint_angle_source"] == "calibration_json" - assert acceptance["urdf_mimic_policy"] == "preserve_source_mimic" - assert not fit.mimic_approximations - assert acceptance["urdf_mimic_approximation"] - assert max(v["maximum_deg"] for v in acceptance["full_json_urdf_holdout"].values()) < 1 - reader = load_unified_mapper(artifact.path.parent/"release_manifest.json") - assert np.max(np.abs(reader.map_positions(profile.command.baseline_values))) < 1e-10 - from types import SimpleNamespace - from linkerhand_calibration.runtime.artifacts.completion import finish_online_artifacts - completed = finish_online_artifacts(SimpleNamespace(calibration_contract=SimpleNamespace(typed_profile=profile), - session_root=artifact.path.parent.parent), artifact.path.parent, - {"outputs": {"json": str(artifact.staged_release.calibration_json), "urdf": str(artifact.path)}}) - assert completed["URDF"] == str(artifact.path) - from linkerhand_calibration.runtime.adapters.legacy_byte_sdk import LegacyByteSdkAdapter - from linkerhand_calibration.runtime.adapters.base import HardwareHealth - from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint - writes = [] - sdk = LegacyByteSdkAdapter(profile.command, publish=writes.append, - set_speed_callback=lambda *_: None, health_callback=lambda: HardwareHealth(True, True)) - values = [float(31+7*i) for i in range(profile.command.command_count)] - for index in profile.command.disabled_indices: - values[index] = profile.command.baseline_values[index] - sdk.publish_position(values) - assert writes[-1] == tuple(values) - angles = dict(zip(reader.urdf_joint_names, reader.map_positions(values))) - assert any(joint.mimic_joint for joint in reader.urdf.joints.values()) - assert reader.urdf.resolve_angles(angles, independent_mimic_angles=True) == angles - for name, row in payload["joints"].items(): - assert row["sdk_channel"] == channel_for_joint(profile, name) - assert angles[name] == pytest.approx(row["angle_rad"][int(values[row["sdk_channel"]])], abs=1e-12) - - -@pytest.mark.parametrize("field", ["calibration_json", "corrected_urdf", "calibration_report"]) -def test_v3_modified_artifact_is_rejected(measured_release, tmp_path, field): - *_, artifact = measured_release - shutil.copytree(artifact.path.parent, tmp_path/"copy") - manifest = tmp_path/"copy/release_manifest.json" - target = manifest.parent/json.loads(manifest.read_text())[field] - target.write_text(target.read_text()+"\n") - with pytest.raises(ValueError, match="SHA256 changed"): - load_unified_mapper(manifest) - - -def test_missing_geometry_or_transferred_curve_cannot_be_published(measured_release): - from linkerhand_calibration.core.urdf.result_plan import prepare_standard_result - profile, source, records, hashes, payload, fit, artifact = measured_release - offsets = dict(fit.zero_offsets_rad) - offsets.pop(next(iter(profile.zero.passive_joints))) - with pytest.raises(ValueError, match="absolute_geometry"): - prepare_standard_result(profile, source, hashes["source_urdf_sha256"], replace(fit, zero_offsets_rad=offsets)) - mappings = dict(fit.output_mappings) - name = next(iter(mappings)) - mappings[name] = replace(mappings[name], transferred_from="different_physical_joint") - with pytest.raises(ValueError, match="transferred"): - prepare_standard_result(profile, source, hashes["source_urdf_sha256"], replace(fit, output_mappings=mappings)) - - -def test_v3_native_reader_uses_all_passive_curves_and_rejects_legacy_promotion(measured_release): - from linkerhand_calibration.runtime.artifacts.reader import UnifiedCommandMapper - from linkerhand_calibration.runtime.artifacts.serializers.measured_v3 import from_report - profile, source, records, hashes, payload, fit, artifact = measured_release - report = json.loads((artifact.path.parent/"calibration_report.json").read_text()) - native = deepcopy(report) - native["command_unit"] = "rad" - native["baseline_command"] = [v/255 for v in native["baseline_command"]] - for row in native["joints"].values(): - row["zero_reference"]["baseline_command"] /= 255 - for kind in ("command", "feedback"): - data = row[kind+"_to_rad"] - data.update(input_domain=kind+"_rad", input_unit="rad", - knots=[v/255 for v in data["knots"]], valid_input_range=[v/255 for v in data["valid_input_range"]]) - compact = from_report(native) - byte_reader = UnifiedCommandMapper(payload, artifact.path, report=report) - rad_reader = UnifiedCommandMapper(compact, artifact.path, report=native) - command = [100.]*profile.command.command_count - np.testing.assert_allclose(byte_reader.map_positions(command), rad_reader.map_positions([v/255 for v in command]), atol=1e-12) - wrong = deepcopy(report) - name = next(iter(wrong["joints"])) - wrong["joints"][name]["zero_reference"]["channel"] = 999 - with pytest.raises(ValueError, match="zero_evidence"): - from_report(wrong) - wrong["format"] = "unified_calibration_v1" - with pytest.raises(ValueError, match="legacy promotion"): - from_report(wrong) - - -def test_final_table_cannot_hide_fourth_round_zero_shift(measured_release): - from scipy.spatial.transform import Rotation - from linkerhand_calibration.core.urdf.measured_acceptance import validate_measured_joint_holdout - from linkerhand_calibration.runtime.acquisition import accepted_joint_records - profile, source, records, hashes, payload, fit, artifact = measured_release - steady = accepted_joint_records(profile, records, sample_phase="steady") - heldout = [dict(r) for rows in steady.values() for r in rows if r["cycle"] == 3] - name = next(iter(fit.curves)) - for row in heldout: - if row["joint"] == name: - extra = Rotation.from_rotvec(np.asarray(fit.curves[name].circle["axis_xyz"])*np.deg2rad(5)) - row["relative_quaternion_xyzw"] = (Rotation.from_quat(row["relative_quaternion_xyzw"])*extra).as_quat().tolist() - with pytest.raises(ValueError, match="inaccurate"): - validate_measured_joint_holdout(payload, fit.curves, heldout) diff --git a/src/linkerhand_calibration/test/test_measured_transfer.py b/src/linkerhand_calibration/test/test_measured_transfer.py deleted file mode 100644 index db1c3d6..0000000 --- a/src/linkerhand_calibration/test/test_measured_transfer.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Real DIP failure: bounded measured geometry helps fit, not branch certainty.""" - -from copy import deepcopy -from dataclasses import asdict, replace -import gzip -import json -from pathlib import Path - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import image_model_payload -from linkerhand_calibration.core.geometry.tag_pose.measured_transfer import MEASURED_TRANSFER_POLICY -from linkerhand_calibration.core.geometry.tag_pose.measured_transfer_solver import MeasuredTransferImageHingeBundle -from linkerhand_calibration.core.geometry.tag_pose.motion_image_diagnostics import _validate_frames -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ( - ImageMotionParameters, image_motion_model_from_dict, resolve_image_motion, _model, -) -from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import CadImageHingeBundle -from linkerhand_calibration.core.geometry.tag_pose.hinge_uncertainty import hinge_uncertainty -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.branch_initialization import ( - BranchInitialization, BranchInitializationRequest, solve_initialization, initialization_artifacts, -) -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import ( - _initializations, validate_source_geometry, _check_measured_transfer_report, -) -from linkerhand_calibration.runtime.passed_resume import restore_parent_models -from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh -from o30_capture_fixture import SOURCE -from test_transferred_hinge_geometry import recorded_transfer - - -@pytest.fixture(scope="module") -def middle_dip(): - path = Path(__file__).parent / "fixtures/o30_measured_transfer_middle_dip.json.gz" - data = json.loads(gzip.decompress(path.read_bytes())) - rows, failure = data["records"], data["original_failure"] - profile = load_bundled_hand_profile("o30_right_18") - owner = BranchInitialization(profile, source_urdf=SOURCE) - restore_parent_models(owner.parent_references, - [r for r in rows if r["kind"] == "motion_branch_initialization"], failure["session_epoch"]) - verified = next(r for r in rows if r["kind"] == "parent_reference_verified") - owner.parent_references.verified[(failure["session_epoch"], failure["task_name"])] = verified - spec = profile.motion.joint_zero_references["middle_dip"] - motion = MotionCommand("zero_approach", spec.command, 200., - task_key=failure["task_name"], reference_joints=("middle_dip",)) - owner.begin(motion, session_epoch=failure["session_epoch"], motion_version=failure["motion_version"]) - for row in rows: - if row["kind"] == "motion_branch_observation" and row["task_name"] == failure["task_name"]: - owner.observe(row) - request = owner.request("side", replace(motion, phase="joint_zero", zero_joints=("middle_dip",)), - session_epoch=failure["session_epoch"], motion_version=failure["motion_version"]+1) - assert not request.invalid_reason - return data, request, solve_initialization(request) - - -def test_real_middle_dip_fits_but_remains_ambiguous(middle_dip): - data, request, result = middle_dip - assert len(request.image_frames) == 129 - assert all(h["validation_quality"][0]["maximum_frame_rms_px"] > 2.8 - for h in data["original_failure"]["hypotheses"]) - assert not result.resolved and result.reason == "image_motion_families_not_distinguishable" - assert result.model is None - assert len(result.hypotheses) == 2 - for candidate in result.hypotheses: - assert candidate.converged and candidate.training_accepted and not candidate.reason - assert candidate.validation_quality[0].rms_px < .23 - assert candidate.validation_quality[0].maximum_frame_rms_px < .36 - assert all(check.consistent for check in candidate.source_geometry_checks) - check = candidate.source_geometry_checks[0] - assert check.axis_allowance_rad == request.source_hinges[0].child_axis_uncertainty_rad - assert any(p > .01 for h in result.hypotheses for _, p in h.comparison_adjusted_p_values) - frozen, report = initialization_artifacts(request, result) - assert frozen is None and not report["evidence_ids"] - assert "frozen_image_model" not in report - - -def test_real_source_evidence_survives_readback(middle_dip): - data, _, _ = middle_dip - assert len(_initializations(data["records"])) == 2 - validate_source_geometry(load_bundled_hand_profile("o30_right_18"), SOURCE, data["records"]) - - -@pytest.fixture(scope="module") -def middle_fit(middle_dip): - _, request, _ = middle_dip - limits = ImageMotionParameters() - paths, roles = _validate_frames(request.image_frames, request.relations, limits) - poses = {role: paths[role][0][::2] for role in roles} - bundle = MeasuredTransferImageHingeBundle(request.image_frames[::2], request.relations, poses, - constraints=request.geometry_constraints, sources=request.source_hinges) - return request, bundle, bundle.solve(limits), roles - - -def test_uncertainty_restores_unbounded_image_geometry(middle_fit): - _, bundle, fit, _ = middle_fit - free, linearization = bundle.uncertainty_problem(fit) - np.testing.assert_allclose(free.residual(linearization.x), bundle.residual(fit.x), atol=1e-7) - assert not hasattr(free, "lower") - assert hinge_uncertainty(bundle, fit) == hinge_uncertainty(free, linearization) - assert hinge_uncertainty(bundle, fit)[0][0][1] > .001 - - -def test_legacy_exact_transfer_reproduces_failure_and_still_deserializes(middle_fit): - request, bounded, _, roles = middle_fit - exact = CadImageHingeBundle(bounded.frames, bounded.relations, bounded.poses, - constraints=request.geometry_constraints, source_urdf_sha256=request.source_urdf_sha256, - source_hinges=request.source_hinges) - fit = exact.solve(ImageMotionParameters()) - assert fit.success - residual = exact.residual(fit.x).reshape(len(exact.frames), -1) - assert np.sqrt(np.mean(residual**2, axis=1)).max() > 1.5 - model = _model(exact, fit, request.image_frames, tuple((r, 0) for r in roles), - tuple(range(0, 129, 2)), tuple(range(1, 129, 2))) - assert model.policy == "image_hinge_v3_transferred_parallel_geometry" - assert image_motion_model_from_dict(asdict(model)) == model - - -@pytest.fixture(scope="module") -def ip_report(recorded_transfer): - _, frames, relations, options, result = recorded_transfer - request = BranchInitializationRequest("thumb_ip_front", "front", ("thumb_ip",), 1, 1, 1, - tuple(f.evidence for f in frames), relations, (), ("thumb_ip",), - image_frames=frames, image_geometry=True, **options) - _, report = initialization_artifacts(request, result) - return json.loads(json.dumps(report)) - - -def test_resolved_ip_preserves_full_report_and_uncertainty_binding(ip_report): - _check_measured_transfer_report(ip_report) - model = image_motion_model_from_dict(ip_report["frozen_image_model"]) - assert model.policy == MEASURED_TRANSFER_POLICY - assert model.maximum_reprojection_error_px == 1.5 - assert model.reprojection_tie_px == .03 - assert image_model_sha256(image_model_payload(model)) == ip_report["image_model_sha256"] - - -def test_source_bounds_do_not_authorize_corrupt_current_images(recorded_transfer): - _, original, relations, options, baseline = recorded_transfer - assert baseline.resolved - frames = list(original) - frame = frames[20] - observations = list(frame.observations) - child = observations[-1] - corners = list(child.corners_xy) - corners[0] = (corners[0][0]+20., corners[0][1]) - observations[-1] = replace(child, corners_xy=tuple(corners)) - frames[20] = replace(frame, observations=tuple(observations)) - result = resolve_image_motion(tuple(frames), relations, **options) - assert not result.resolved and result.model is None - assert not any(h.training_accepted for h in result.hypotheses) - - -@pytest.mark.parametrize("change", ["axis", "distance", "precision", "graph", "roles"]) -def test_model_readback_rejects_geometry_outside_source_domain(recorded_transfer, change): - model = recorded_transfer[-1].model - geometry = model.geometry[0] - if change == "axis": - axis = np.asarray(geometry.axis_parent_xyz) - tangent = np.cross(axis, [1, 0, 0]); tangent /= np.linalg.norm(tangent) - axis = Rotation.from_rotvec(tangent*.1).apply(axis) - kwargs = {"geometry": (replace(geometry, axis_parent_xyz=tuple(axis)),)} - elif change == "distance": - kwargs = {"geometry": (replace(geometry, pivot_parent_xyz_m=(1., 1., 1.)),)} - elif change == "precision": - kwargs = {"current_geometry_uncertainty": (("thumb_ip", .2, .001),)} - elif change == "graph": - kwargs = {"constraints": model.constraints*2} - else: - kwargs = {"geometry": (replace(geometry, relation=replace(geometry.relation, parent_role="wrong")),)} - with pytest.raises(ValueError): - replace(model, **kwargs) - - -@pytest.mark.parametrize("change", ["axes", "points", "checks", "child", "accepted", "missing"]) -def test_report_readback_rejects_detached_uncertainty(ip_report, change): - row = deepcopy(ip_report) - winner = next(h for h in row["hypotheses"] if h["branches"] == row["frozen_image_model"]["branches"]) - if change == "axes": winner["axis_uncertainty_95_rad"][0][1] /= 2 - elif change == "points": winner["pivot_uncertainty_95_m"][0][1] /= 2 - elif change == "checks": winner["source_geometry_checks"][0]["axis_allowance_rad"] *= 2 - elif change == "child": row["geometry_uncertainty"][0][1] /= 2 - elif change == "accepted": winner["training_accepted"] = False - else: row["hypotheses"].remove(winner) - with pytest.raises(ValueError, match="measured_transfer_"): - _check_measured_transfer_report(row) - - -def test_ambiguity_has_an_actionable_operator_message(): - code, message, action = reason_zh({"reason": - "joint_zero_motion_unresolved:view=side:joints=middle_dip:image_motion_families_not_distinguishable"}, - model_name="O30") - assert code == "OBS-POSE-AMBIGUOUS-117" - assert "多个" in message and "独立观测" in action and "已通过的数据保留" in action - - -@pytest.fixture(scope="module") -def thumb_ip_uncertain_alternative(): - path = Path(__file__).parent / "fixtures/o30_thumb_ip_uncertain_alternative.json.gz" - data = json.loads(gzip.decompress(path.read_bytes())) - rows, failure = data["records"], data["original_failure"] - profile = load_bundled_hand_profile("o30_right_18") - owner = BranchInitialization(profile, source_urdf=SOURCE) - restore_parent_models(owner.parent_references, - [r for r in rows if r["kind"] == "motion_branch_initialization"], failure["session_epoch"]) - verified = next(r for r in rows if r["kind"] == "parent_reference_verified") - owner.parent_references.verified[(failure["session_epoch"], failure["task_name"])] = verified - spec = profile.motion.joint_zero_references["thumb_ip"] - motion = MotionCommand("zero_approach", spec.command, 200., - task_key=failure["task_name"], reference_joints=("thumb_ip",)) - owner.begin(motion, session_epoch=failure["session_epoch"], motion_version=failure["motion_version"]) - for row in rows: - if row["kind"] == "motion_branch_observation": - owner.observe(row) - request = owner.request("front", replace(motion, phase="joint_zero", zero_joints=("thumb_ip",)), - session_epoch=failure["session_epoch"], motion_version=failure["motion_version"]+1) - assert not request.invalid_reason - assert tuple(f.evidence.stamp_ns for f in request.image_frames) == tuple(failure["source_image_stamps"]) - return request, solve_initialization(request) - - -def test_real_ip_compares_uncertain_alternative_before_authorizing(thumb_ip_uncertain_alternative): - request, result = thumb_ip_uncertain_alternative - assert result.resolved, result.reason - winner, alternative = result.hypotheses - assert winner.training_accepted and not winner.reason - assert alternative.converged and not alternative.training_accepted - assert alternative.reason == "image_motion_geometry_uncertain" - assert alternative.training_solver_evaluations > 0 - assert alternative.training_quality and alternative.validation_quality - assert winner.comparison_adjusted_p_values[0][0] == 1 - assert winner.comparison_adjusted_p_values[0][1] <= .01 - assert winner.validation_quality[0].maximum_frame_rms_px < .464 - assert result.model.maximum_reprojection_error_px == 1.5 - assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns) - frozen, report = initialization_artifacts(request, result) - assert frozen is not None - _check_measured_transfer_report(report) - assert image_motion_model_from_dict(report["frozen_image_model"]) == result.model - - -def test_uncertain_alternative_cannot_be_frozen(thumb_ip_uncertain_alternative): - from linkerhand_calibration.core.geometry.tag_pose.measured_transfer import transfer_geometry_checks - - request, result = thumb_ip_uncertain_alternative - alternative = result.hypotheses[1] - source = request.source_hinges[0] - constraint = request.geometry_constraints[0] - # Remove the inherited part to reconstruct the alternative's actual local - # bounds. Reusing valid geometry isolates the precision authorization gate. - bounds = (("thumb_ip", alternative.axis_uncertainty_95_rad[0][1]-source.child_axis_uncertainty_rad, - alternative.pivot_uncertainty_95_m[0][1]-source.child_point_uncertainty_m - -constraint.distance_m*source.child_axis_uncertainty_rad),) - with pytest.raises(ValueError, match="image_motion_geometry_uncertain"): - transfer_geometry_checks(result.model.geometry, request.geometry_constraints, request.source_hinges, bounds) - with pytest.raises(ValueError, match="image_motion_geometry_uncertain"): - replace(result.model, current_geometry_uncertainty=bounds) - - -def test_uncertain_alternative_still_vetoes_when_images_do_not_distinguish( - thumb_ip_uncertain_alternative, monkeypatch): - from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver - - request, result = thumb_ip_uncertain_alternative - # Isolate the selection boundary: equally good images, distinct poses, and - # the actual precision-ineligible hypothesis must not authorize the winner. - monkeypatch.setattr(solver, "equivalent_frame_selections", lambda *args: False) - validation = tuple(i for i, f in enumerate(request.image_frames) - if f.evidence.stamp_ns in result.model.validation_stamps_ns) - rejected = solver.select_image_hypotheses(request.image_frames, validation, - list(result.hypotheses), [result.model, result.model], - [np.zeros(len(validation)), np.zeros(len(validation))], ImageMotionParameters()) - assert not rejected.resolved and rejected.model is None - assert rejected.reason == "image_motion_families_not_distinguishable" diff --git a/src/linkerhand_calibration/test/test_measured_transfer_finalization.py b/src/linkerhand_calibration/test/test_measured_transfer_finalization.py deleted file mode 100644 index 9742957..0000000 --- a/src/linkerhand_calibration/test/test_measured_transfer_finalization.py +++ /dev/null @@ -1,117 +0,0 @@ -"""O6 synthetic records traverse the actual mixed v3 production finalizer. - -The elementary-FK fixture supplies synthetic geometry datums and its existing -virtual limits. This is a software integration check, not O6 hardware evidence. -""" - -import hashlib -import json - -import pytest - -from measured_capture_fixture import measured_capture -from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session -from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper - - -@pytest.mark.parametrize("reordered,separate_mapping", [(False, False), (True, False), (False, True)], - ids=["o6", "reordered_virtual_o6", "separate_command_mapping"]) -def test_o6_records_fit_expand_publish_and_read_back_without_recipient_observations(tmp_path, monkeypatch, reordered, separate_mapping): - profile, source, records, offsets = measured_capture(tmp_path, layout="o6_right_8", reordered=reordered) - if separate_mapping: - # This is synthetic evidence: exercise distinct motion and command - # partitions without claiming an additional physical recording. - from dataclasses import replace - profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode="separate")) - records = [dict(r, cycle={0: 4, 3: 5}[r["cycle"]]) if r.get("sample_phase") == "steady" else r - for r in records if r.get("sample_phase") != "steady" or r["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} - selected = [] - for row in records: - if row.get("sample_phase") != "steady": - selected.append(row) - continue - plan = plans[row["task_name"]] - if row["cycle"] == 4 and row["steady_target"] not in plan["training_nodes"]: - continue - selected.append(dict(row, steady_training_nodes=plan["training_nodes"], sampling_plan_sha256=plan["plan_sha256"])) - records = [*selected, *plans.values()] - from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher - from test_final_image_holdout import independent_pixels - original = ArtifactPublisher.prepare - def prepare(publisher, **kwargs): - validator = kwargs["validate"] - kwargs["validate"] = replace(validator, require_image_holdout=True, - image_observations=independent_pixels(validator.observations), - command_image_observations=independent_pixels(validator.command_observations)) - return original(publisher, **kwargs) - monkeypatch.setattr(ArtifactPublisher, "prepare", prepare) - source_bytes = source.read_bytes() - transfers = dict(profile.measurement.transferred_motion_sources) - assert len(transfers) == 6 - observed = {row["joint"] for row in records if row.get("kind") == "joint_sample"} - assert len(observed) == 5 and not observed & transfers.keys() - assert {row["joint"] for row in records if row.get("kind") == "joint_zero_reference"} == observed - hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields} - hashes["source_urdf_sha256"] = hashlib.sha256(source_bytes).hexdigest() - phases = [] - payload, fit, artifact = finalize_profile_session(profile=profile, session_dir=tmp_path/"session", - serial_number="SYNTHETIC_O6_ONLY", source_urdf=source, protected_inputs=hashes, - records=records, standard_loader=lambda _: None, phase_changed=phases.append) - assert source.read_bytes() == source_bytes - assert phases == ["fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"] - moving = {name for name, joint in UrdfKinematicModel(source).joints.items() if joint.kind != "fixed"} - assert len(moving) == 11 and set(payload["joints"]) == moving - assert set(fit.curves) == set(fit.joint_zero_references) == set(fit.command_holdout_metrics) == observed - assert set(fit.command_mappings) == set(fit.output_mappings) == moving - declared = profile.artifacts.directional_command_joints - assert set(payload["directional_command_joints"]) == declared - for name, evidence in fit.command_applicability.items(): - assert (evidence.get("command_mapping") == "directional") == (name in declared) - report = json.loads((artifact.path.parent/"calibration_report.json").read_text()) - acceptance = json.loads(artifact.staged_release.validation_json) - if separate_mapping: - assert acceptance["final_file_image_holdout_verified"] is True - assert set(acceptance["final_file_image_holdout"]) == {"feedback", "command"} - assert (artifact.path.parent/"command_sampling_plans.json").is_file() - assert acceptance["command_holdout_cycle"] == (5 if separate_mapping else 3) - assert acceptance["acceptance"] == "measured_and_transferred_json_urdf_v3" - assert set(acceptance["independent_mapping_holdout"]) == observed - assert acceptance["transferred_motion_sources"] == transfers - observed_roles = {profile.measurement.measurements[name].child_role for name in observed} - assert set(acceptance["full_json_urdf_holdout"]) == observed_roles - assert acceptance["urdf_joint_angle_source"] == "calibration_json" - assert acceptance["urdf_mimic_policy"] == "preserve_source_mimic" - assert not fit.mimic_approximations - assert acceptance["urdf_mimic_approximation"] - original_model, exported_model = UrdfKinematicModel(source), UrdfKinematicModel(artifact.path) - assert {name: j.mimic_joint for name, j in exported_model.joints.items()} == { - name: j.mimic_joint for name, j in original_model.joints.items()} - for name, original in original_model.joints.items(): - if original.mimic_joint: - actual = exported_model.joints[name] - assert (actual.mimic_multiplier, actual.mimic_offset) == ( - original.mimic_multiplier, original.mimic_offset) - for target, donor in transfers.items(): - assert payload["joints"][target]["angle_rad"] == payload["joints"][donor]["angle_rad"] - assert payload["joints"][target]["sdk_channel"] == channel_for_joint(profile, target) - assert fit.command_mappings[target].transferred_from == donor - row = report["joints"][target] - assert row["independently_measured"] is False and "zero_reference" not in row - assert "command_holdout" not in row and row["command_holdout_source_joint"] == donor - for name, angle in offsets.items(): - assert fit.zero_offsets_rad[name] == pytest.approx(angle, abs=.004) - mapper = load_unified_mapper(artifact.path.parent/"release_manifest.json") - assert mapper.transferred_motion_sources == transfers - baseline = profile.command.baseline_values - assert max(abs(value) for value in mapper.map_positions(baseline)) < 1e-10 - target = "rh_index_mcp_pitch" - command = list(baseline) - command[channel_for_joint(profile, target)] = 102 - angles = dict(zip(mapper.urdf_joint_names, mapper.map_positions(command))) - assert angles["rh_pinky_mcp_pitch"] == pytest.approx(0, abs=1e-10) - assert angles[target] == payload["joints"][target]["angle_rad"][102] - assert angles["rh_index_dip"] == payload["joints"]["rh_index_dip"]["angle_rad"][102] diff --git a/src/linkerhand_calibration/test/test_measured_zero_mapping.py b/src/linkerhand_calibration/test/test_measured_zero_mapping.py deleted file mode 100644 index 72ecad4..0000000 --- a/src/linkerhand_calibration/test/test_measured_zero_mapping.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Frozen zeros and nonlinear passive motion must survive independent holdout.""" - -import math -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.anchored_mapping import anchored_mean -from linkerhand_calibration.core.fitting.rotation_curve import RotationObservation, fit_rotation_curve -from linkerhand_calibration.core.fitting.coupling import fit_standard_mimic - - -def observations(*, slip=0.0): - mount = Rotation.from_euler("xyz", [0.3, -.8, .4]) - return [RotationObservation(f"{cycle}:{direction}:{i}", float(x), - tuple((mount * Rotation.from_rotvec([0, 0, .5*x+.3*x*x+(slip if cycle == 3 else 0)])).as_quat()), - cycle, direction) - for cycle in range(4) for direction in ("increasing", "decreasing") - for i, x in enumerate(np.linspace(0, 1, 65))], mount - - -def test_frozen_oblique_zero_preserves_nonlinear_motion_and_detects_holdout_slip(): - rows, mount = observations() - fit = fit_rotation_curve(rows[:390], input_domain="feedback_rad", sdk_to_joint_direction=1, - reference_xyzw=mount.as_quat()) - assert fit.measure(mount.as_quat())[0] == pytest.approx(0, abs=1e-12) - assert max(fit.holdout_errors(rows[390:])) < 1e-10 - slipped, _ = observations(slip=math.radians(5)) - assert min(fit.holdout_errors(slipped[390:])) > math.radians(4.9) - x = [r.input_value for r in rows[:390]] - y = [fit.measure(r.quaternion_xyzw)[0] for r in rows[:390]] - linear = fit_standard_mimic("parent", "child", x, y, - cycles=[r.cycle for r in rows[:390]], offset_rad=0) - assert linear.offset_rad == 0 - assert max(abs(e) for e in linear.residuals_rad) > math.radians(3) - - -def test_baseline_anchor_is_a_constraint_not_branch_recentering(): - increasing = [-.39, .005, .41] - decreasing = [-.41, -.001, .39] - result = anchored_mean([0, 1, 2], increasing, decreasing, 1, 1) - assert result == pytest.approx([-.4, 0, .4]) - assert increasing[1] == .005 and decreasing[1] == -.001 - with pytest.raises(ValueError, match="observed_training_knot"): - anchored_mean([0, 2], [-.4, .4], [-.4, .4], 1, 1) - with pytest.raises(ValueError, match="inconsistent"): - anchored_mean([0, 1, 2], [-.4, .1, .4], [-.4, .1, .4], 1, 1) diff --git a/src/linkerhand_calibration/test/test_motion_branch_evidence.py b/src/linkerhand_calibration/test/test_motion_branch_evidence.py deleted file mode 100644 index 27980b3..0000000 --- a/src/linkerhand_calibration/test/test_motion_branch_evidence.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Physical motion evidence can authorize a branch only before zero freeze.""" - -from dataclasses import FrozenInstanceError, replace -import json -import math -from pathlib import Path - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import ( - MotionEvidenceFrame, MotionEvidenceParameters, MotionRelation, RoleCandidates, - resolve_motion_branches, select_motion_frame, -) -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - - -RELATIONS = (MotionRelation("joint_a", "base", "parent"), - MotionRelation("joint_b", "parent", "child")) - - -def pose(rotation, translation, error=0.4): - return SquareTagPose(tuple(rotation.as_quat()), tuple(translation), error) - - -def motion_frames(*, span_deg=80, samples=81, bad_alternative=True, radius=0.05): - """Two independently articulated links, each with an image-favored false root.""" - frames = [] - for i, theta in enumerate(np.linspace(0, math.radians(span_deg), samples)): - base = pose(Rotation.identity(), (0, 0, 1), 0.1) - parent_r = Rotation.from_rotvec([0, 0, theta]) - parent_t = np.array([0, 0, 1]) + parent_r.apply([radius, 0, 0]) - child_r = parent_r * Rotation.from_rotvec([theta * 0.4, 0, 0]) - child_t = parent_t + parent_r.apply([0.03, 0, 0]) + child_r.apply([0, radius, 0]) - parent = pose(parent_r, parent_t) - child = pose(child_r, child_t) - # Change orientation, not measured image translation. The alternate - # cannot describe the same hinge axis and pivot over a sufficient arc. - wrong_r = Rotation.from_euler("y", 35, degrees=True) - parent_alt = pose(wrong_r * parent_r, parent_t, 0.1) - child_alt = pose(wrong_r * child_r, child_t, 0.1) - if not bad_alternative: - # An unknown constant Tag mounting rotation is unobservable from - # this motion: both families are physically admissible. - parent_alt = pose(parent_r * Rotation.from_euler("z", 40, degrees=True), parent_t, 0.1) - child_alt = pose(child_r * Rotation.from_euler("z", 40, degrees=True), child_t, 0.1) - frames.append(MotionEvidenceFrame(1_000_000_000 + i * 50_000_000, ( - RoleCandidates("base", (base,), frozen=True), - RoleCandidates("parent", (parent_alt, parent)), - RoleCandidates("child", (child_alt, child)), - ))) - return frames - - -def test_joint_parent_child_geometry_selects_actual_candidates_not_image_minimum(): - frames = motion_frames() - result = resolve_motion_branches(frames, RELATIONS) - assert result.resolved, result - assert len(result.constraints) == 2 - assert [v.pose for v in result.selections] == [v.candidates[-1] for v in frames[-1].roles] - assert sum(h.accepted for h in result.hypotheses) == 1 - assert all(model.source_stamps_ns for model in result.constraints) - with pytest.raises(FrozenInstanceError): - result.constraints[0].maximum_pivot_error_m = 1 - - -def test_postfreeze_selection_uses_same_immutable_model_and_original_current_candidates(): - frames = motion_frames() - result = resolve_motion_branches(frames[::2], RELATIONS) - assert result.resolved - models_before = result.constraints - for frame in frames[1::2]: - selected = select_motion_frame(result.constraints, frame.roles) - assert selected.resolved, selected - assert all(item.pose is frame.roles[i].candidates[-1] for i, item in enumerate(selected.selections)) - assert result.constraints == models_before - - -def test_constant_mount_ambiguity_is_not_resolved_by_lowest_error_or_smoothness(): - result = resolve_motion_branches(motion_frames(bad_alternative=False), RELATIONS) - assert not result.resolved - assert result.reason == "motion_geometry_ambiguous" - assert result.constraints == result.selections == () - - -@pytest.mark.parametrize("span", [0, 3, 10]) -def test_insufficient_rotation_cannot_freeze_even_with_many_frames(span): - result = resolve_motion_branches(motion_frames(span_deg=span, samples=300), RELATIONS) - assert not result.resolved - assert any(q.reason == "insufficient_rotation_evidence" for h in result.hypotheses for q in h.relations) - - -def test_stationary_endpoints_do_not_inflate_motion_evidence(): - samples = motion_frames(samples=5) - repeated = [replace(frame, stamp_ns=1_000_000_000 + i * 10_000_000) - for i, frame in enumerate(frame for frame in samples for _ in range(100))] - result = resolve_motion_branches(repeated, RELATIONS) - assert not result.resolved - assert all(q.motion_bins <= 5 for h in result.hypotheses for q in h.relations) - - -def test_early_late_validation_rejects_a_mid_approach_mount_shift(): - frames = motion_frames() - changed = [] - for index, frame in enumerate(frames): - roles = list(frame.roles) - if index > len(frames) // 2: - roles[1] = replace(roles[1], candidates=tuple(replace(p, - translation_xyz_m=tuple(np.asarray(p.translation_xyz_m) + [0, 0, 0.002])) - for p in roles[1].candidates)) - changed.append(replace(frame, roles=tuple(roles))) - result = resolve_motion_branches(changed, RELATIONS[:1]) - assert not result.resolved - quality = result.hypotheses[1].relations[0] - assert quality.pivot_rms_m < 0.0015 # A pooled fit alone would hide this shift. - assert quality.reason == "motion_split_validation_failed" - - -def test_rotation_without_resolved_position_arc_is_insufficient(): - result = resolve_motion_branches(motion_frames(radius=0), RELATIONS[:1]) - assert not result.resolved - assert all(h.relations[0].reason == "insufficient_pivot_translation" for h in result.hypotheses) - - -def test_image_identity_and_changed_frozen_contract_are_rejected(): - frames = motion_frames() - result = resolve_motion_branches([frames[0], *frames], RELATIONS) - assert result.reason == "motion_evidence_image_identity_invalid" - roles = frames[20].roles - frames[20] = replace(frames[20], roles=(replace(roles[0], frozen=False), roles[1], roles[2])) - assert resolve_motion_branches(frames, RELATIONS).reason == "motion_candidate_family_interrupted" - - -@pytest.mark.parametrize("invalid", ["reprojection", "tilt", "nan"]) -def test_invalid_alternative_does_not_discard_the_other_legal_candidate(invalid): - frames = motion_frames() - first = frames[0] - roles = list(first.roles) - target = roles[1].candidates[0] - if invalid == "reprojection": - target = replace(target, reprojection_error_px=1.50001) - elif invalid == "tilt": - target = replace(target, quaternion_xyzw=tuple(Rotation.from_euler("x", 76, degrees=True).as_quat())) - else: - target = replace(target, translation_xyz_m=(float("nan"), 0, 1)) - roles[1] = replace(roles[1], candidates=(target, roles[1].candidates[-1])) - frames[0] = replace(first, roles=tuple(roles)) - result = resolve_motion_branches(frames, RELATIONS) - assert result.resolved - selected = select_motion_frame(result.constraints, frames[0].roles) - assert selected.resolved and selected.selections[1].pose is frames[0].roles[1].candidates[-1] - bad_only = (*frames[0].roles[:1], replace(roles[1], candidates=(target,)), roles[2]) - assert not select_motion_frame(result.constraints, bad_only).resolved - - -def test_temporarily_missing_root_is_not_copied_to_another_family(): - frames = motion_frames() - roles = frames[20].roles - frames[20] = replace(frames[20], roles=(roles[0], - replace(roles[1], candidates=roles[1].candidates[:1]), roles[2])) - result = resolve_motion_branches(frames, RELATIONS) - assert result.resolved - assert all(frames[20].stamp_ns not in model.source_stamps_ns for model in result.constraints) - - -def test_existing_frozen_candidate_is_never_replaced_by_better_geometry(): - frames = motion_frames() - fixed_wrong = [replace(frame, roles=(frame.roles[0], - RoleCandidates("parent", (frame.roles[1].candidates[0],), frozen=True), frame.roles[2])) for frame in frames] - result = resolve_motion_branches(fixed_wrong, RELATIONS) - assert not result.resolved - assert result.constraints == result.selections == () - - -def test_frozen_role_cannot_offer_two_roots_to_training(): - frames = motion_frames() - frame = frames[0] - frames[0] = replace(frame, roles=(frame.roles[0], replace(frame.roles[1], frozen=True), frame.roles[2])) - assert resolve_motion_branches(frames, RELATIONS).reason == "motion_candidates_missing_or_invalid" - - -def test_postfreeze_tag_slide_is_rejected_without_model_update(): - frames = motion_frames() - result = resolve_motion_branches(frames, RELATIONS) - frame = frames[40] - roles = list(frame.roles) - roles[2] = replace(roles[2], candidates=tuple(replace(p, - translation_xyz_m=tuple(np.asarray(p.translation_xyz_m) + [0, 0, 0.01])) for p in roles[2].candidates)) - selected = select_motion_frame(result.constraints, tuple(roles)) - assert not selected.resolved - assert selected.reason == "motion_geometry_inconsistent" - - -def test_postfreeze_two_physical_choices_remain_ambiguous(): - frames = motion_frames() - result = resolve_motion_branches(frames, RELATIONS[:1]) - frame = frames[40] - # Two distinct points along the same previously fitted hinge both explain - # their candidate poses. Geometry alone must not pick the smaller error. - role = RoleCandidates("parent", (frame.roles[1].candidates[-1], frames[50].roles[1].candidates[-1])) - selected = select_motion_frame(result.constraints, (frame.roles[0], role)) - assert not selected.resolved and selected.reason == "motion_geometry_ambiguous" - - -def real_frames(direction, field="frames"): - source = Path(__file__).with_name("fixtures") / "o6_thumb_20260914_175815_motion_evidence.json" - fixture = json.loads(source.read_text()) - assert fixture["scope"] == "single_round_counterfactual_branch_regression_not_accuracy_certificate" - assert fixture["source_raw_sha256"] == "8bdbf216bd4135f1bac16dee0535a8b72ea0e6b0f31ab2b9a2e8cc4a83be9c0d" - return [MotionEvidenceFrame(frame["stamp_ns"], tuple(RoleCandidates(role["role"], tuple( - SquareTagPose(tuple(p["quaternion_xyzw"]), tuple(p["translation_xyz_m"]), p["reprojection_error_px"]) - for p in role["candidates"]), role["frozen"]) for role in frame["roles"])) - for frame in fixture[field] if frame["direction"] == direction] - - -def test_real_top_yaw_retry_has_unique_motion_evidence(): - frames = real_frames("decreasing", "yaw_retry_frames") - result = resolve_motion_branches(frames, (MotionRelation("yaw", "top_base", "thumb_yaw"),)) - assert result.resolved - assert len(frames) == 210 - assert sum(h.accepted for h in result.hypotheses) == 1 - quality = next(h.relations[0] for h in result.hypotheses if h.accepted) - assert quality.motion_rad > math.radians(65) - assert quality.pivot_rms_m < 0.0015 - - -@pytest.mark.parametrize("direction", ["decreasing", "increasing"]) -def test_real_capture_pitch_has_evidence_but_full_ip_chain_must_remain_unresolved(direction): - frames = real_frames(direction) - pitch = MotionRelation("pitch", "front_base", "thumb_pitch") - ip = MotionRelation("ip", "thumb_pitch", "thumb_ip") - # This is explicitly counterfactual replay of sweep candidates. Production - # must never use it to replace this real session's already frozen zero. - result = resolve_motion_branches(frames, (pitch,)) - assert result.resolved - assert sum(h.accepted for h in result.hypotheses) == 1 - assert all(h.relations[0].pivot_rms_m < 0.0015 for h in result.hypotheses if h.accepted) - joint = resolve_motion_branches(frames, (pitch, ip)) - assert not joint.resolved and joint.reason == "motion_geometry_unresolved" - assert all(h.relations[1].reason == "motion_geometry_residual" for h in joint.hypotheses) - assert joint.constraints == joint.selections == () - - -def test_real_reverse_direction_uses_first_direction_model_without_refitting(): - pitch = MotionRelation("pitch", "front_base", "thumb_pitch") - result = resolve_motion_branches(real_frames("decreasing"), (pitch,)) - assert result.resolved - accepted = [select_motion_frame(result.constraints, frame.roles).resolved for frame in real_frames("increasing")] - assert all(accepted) - - -@pytest.mark.parametrize("field,value", [("maximum_pivot_rms_m", float("nan")), - ("angular_bin_rad", 0), ("minimum_motion_bins", 11), ("minimum_motion_bins", 12.0)]) -def test_invalid_parameters_are_rejected(field, value): - with pytest.raises(ValueError): - MotionEvidenceParameters(**{field: value}) diff --git a/src/linkerhand_calibration/test/test_motion_capture_tracking.py b/src/linkerhand_calibration/test/test_motion_capture_tracking.py deleted file mode 100644 index a15d567..0000000 --- a/src/linkerhand_calibration/test/test_motion_capture_tracking.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Capture uses frozen motion evidence even while recording is not allowed. - -The fixture injects explicit IPPE candidates to isolate the real tracker, -constraint selector and capture boundary. It fits its immutable constraints -from the independently articulated synthetic trajectory in the numeric tests; -these checks do not claim image or hardware accuracy. -""" - -from concurrent.futures import ThreadPoolExecutor -from dataclasses import replace -from types import SimpleNamespace - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.tag_pose import tracking -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import ( - MotionRelation, resolve_motion_branches, -) -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime import capture as capture_module -from linkerhand_calibration.runtime.branch_initialization import MotionBranchModel -from linkerhand_calibration.runtime.capture import CaptureFrame, ObservationCapture -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.reference_lock import ReferenceLock - -from test_motion_branch_evidence import motion_frames - - -STEP = 40_000_000 -START = 10_000_000_000 -ROLE_NAMES = {"base": "front_base", "parent": "thumb_pitch", "child": "thumb_ip"} -MOVING = ("thumb_pitch", "thumb_ip") -IDS = {"thumb_pitch": "a" * 64, "thumb_ip": "b" * 64} -JOINTS = ("rh_thumb_cmc_pitch", "rh_thumb_ip") -TASK = "thumb_pitch_ip_front" - - -@pytest.fixture -def rig(monkeypatch): - class Rig: - def __init__(self): - self.profile = load_bundled_hand_profile("o6_right_8") - self.frames = [replace(frame, roles=tuple( - replace(item, role=ROLE_NAMES[item.role]) for item in frame.roles)) - for frame in motion_frames()] - relations = (MotionRelation(JOINTS[0], "front_base", "thumb_pitch"), - MotionRelation(JOINTS[1], "thumb_pitch", "thumb_ip")) - resolution = resolve_motion_branches(self.frames, relations) - assert resolution.resolved - self.model = MotionBranchModel(TASK, "front", resolution.constraints, tuple(IDS.items())) - # Fixed reference image verification is independently covered by - # test_fixed_reference_pose. Here base has one current valid root. - lock = ReferenceLock({view.name: next(tag.tag_id for tag in view.tags if tag.fixed_reference) - for view in self.profile.vision.views}) - self.capture = ObservationCapture(self.profile, reference_lock=lock, - extrinsics=SimpleNamespace(transform=lambda view: np.eye(4))) - self.tracker = self.capture.trackers["front"] - self.current = {item.role: item.candidates for item in self.frames[20].roles} - self.role_by_marker = {index: role for index, role in enumerate(ROLE_NAMES.values(), start=1)} - self.corners = {role: np.array([[index, 0.], [index + .1, 0.], - [index + .1, .1], [index, .1]]) for index, role in self.role_by_marker.items()} - self.motion = MotionCommand("joint_zero", tuple(self.profile.command.baseline_values), 40, - task_key=TASK, command_index=0, cycle=0, zero_joints=JOINTS) - - def solve(self, corners, **kwargs): - return list(self.current[self.role_by_marker[int(corners[0][0])]]) - - def frame(self, counter, *, index=20, omit=(), slide=False): - self.current = {item.role: item.candidates for item in self.frames[index].roles} - if slide: - self.current["thumb_ip"] = tuple(replace(pose, - translation_xyz_m=tuple(np.asarray(pose.translation_xyz_m) + [0, 0, .01])) - for pose in self.current["thumb_ip"]) - values = tuple(map(float, self.profile.command.baseline_values)) - return CaptureFrame("front", START + counter * STEP, np.eye(3), - {role: points for role, points in self.corners.items() if role not in omit}, values, values) - - def establish(self): - observation = self.frame(10) - rows, movement = self.capture.consume(observation, self.motion, allow_recording=False) - assert movement is None and not any(row["kind"] == "joint_zero_sample" for row in rows) - revisions = {role: self.tracker.motion_snapshot(role, observation.stamp_ns).revision for role in MOVING} - assert self.tracker.freeze_roles(revisions, require_motion_evidence=True) - return revisions - - instance = Rig() - monkeypatch.setattr(tracking, "solve_square_tag_ippe", instance.solve) - for counter in range(5): - instance.capture.consume(instance.frame(counter)) - # Both incorrect candidates have decisively smaller reprojection errors. - # The legacy tracker has actually confirmed those candidates during preview. - for role in MOVING: - assert instance.tracker._branches.states[role].pose == instance.current[role][0] - instance.capture.install_motion_model(instance.model) - return instance - - -def _joint_rows(rows): - return [row for row in rows if row["kind"] in {"joint_zero_sample", "joint_sample", "secondary_joint_sample"}] - - -def test_unstable_zero_and_recording_transitions_keep_frozen_geometry_and_identity(rig, monkeypatch): - original_update = rig.tracker._branches.update - - def only_fixed_independent_update(role, *args, **kwargs): - assert role not in MOVING, "an installed motion model must not fall back to image ranking" - return original_update(role, *args, **kwargs) - - monkeypatch.setattr(rig.tracker._branches, "update", only_fixed_independent_update) - revisions = rig.establish() - for counter, allowed in enumerate((False, False, True, False, False, True), start=11): - observation = rig.frame(counter, index=counter + 10) - rows, movement = rig.capture.consume(observation, rig.motion, allow_recording=allowed) - assert movement is None - assert len(_joint_rows(rows)) == (2 if allowed else 0) - for role in MOVING: - state = rig.tracker._branches.states[role] - diagnostics = rig.tracker.observation_snapshot(role, observation.stamp_ns)[1] - assert state.pose == rig.current[role][-1] - assert state.pose.reprojection_error_px > rig.current[role][0].reprojection_error_px + .03 - assert state.revision == revisions[role] and state.frozen - assert state.motion_evidence_id == IDS[role] - assert diagnostics["pose_constraint"] == "motion_geometry" - assert diagnostics["branch_status"] == "tracking" - for row in _joint_rows(rows): - for item in row["pnp_observation_evidence"].values(): - role = item["tag_role"] - if role in MOVING: - assert item["candidate_diagnostics"]["motion_evidence_id"] == IDS[role] - assert item["candidate_diagnostics"]["branch_revision"] == revisions[role] - assert item["selected_pose"]["quaternion_xyzw"] == list(rig.current[role][-1].quaternion_xyzw) - assert rig.capture._motion_models[(TASK, "front")] == rig.model - - -def test_capture_serializes_detached_commit_evidence_after_another_candidate_producer_advances_tracker(rig, monkeypatch): - rig.establish() - observation = rig.frame(11, index=21) - original_snapshot = capture_module.snapshot_pose_evidence - advanced = False - - def snapshot_after_next_candidate_frame(tags, *args, **kwargs): - nonlocal advanced - if "thumb_pitch" in tags and not advanced: - advanced = True - - def advance_candidates(): - for role in MOVING: - rig.tracker.estimate(role, observation.corners[role], tag_size_m=.016, - camera_matrix=observation.camera_matrix, stamp_ns=observation.stamp_ns + STEP, - motion_managed=True) - - # This specifically exercises the tracker/evidence race. Full - # capture-generation invalidation has separate coordinator tests. - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(advance_candidates).result(timeout=2) - return original_snapshot(tags, *args, **kwargs) - - monkeypatch.setattr(capture_module, "snapshot_pose_evidence", snapshot_after_next_candidate_frame) - rows, movement = rig.capture.consume(observation, rig.motion) - assert advanced and movement is None - samples = _joint_rows(rows) - assert len(samples) == 2 - for row in samples: - assert row["image_stamp_ns"] == observation.stamp_ns - for item in row["pnp_observation_evidence"].values(): - role = item["tag_role"] - if role in MOVING: - assert rig.tracker.observation_snapshot(role, observation.stamp_ns) is None - diagnostics = item["candidate_diagnostics"] - assert diagnostics["observation_stamp_ns"] == observation.stamp_ns - assert diagnostics["branch_status"] == "tracking" - assert diagnostics["motion_selection_committed"] is True - assert item["selected_pose"]["quaternion_xyzw"] == list(rig.current[role][-1].quaternion_xyzw) - - -def test_one_inconsistent_geometry_frame_is_discarded_without_refreezing_or_latching_conflict(rig): - revisions = rig.establish() - previous = {role: rig.tracker._branches.states[role].pose for role in MOVING} - rows, movement = rig.capture.consume(rig.frame(11, index=21, slide=True), rig.motion) - assert movement is None and not _joint_rows(rows) - events = [row for row in rows if row["kind"] == "pnp_branch_event"] - assert {row["tag_role"] for row in events} == set(MOVING) - assert all(row["reason"] == "pose_branch_motion_geometry_inconsistent" for row in events) - for role in MOVING: - state = rig.tracker._branches.states[role] - assert state.pose == previous[role] - assert state.revision == revisions[role] and state.motion_evidence_id == IDS[role] - assert state.frozen and not state.conflict - rows, movement = rig.capture.consume(rig.frame(12, index=22), rig.motion) - assert movement is None and len(_joint_rows(rows)) == 2 - assert rig.capture._motion_models[(TASK, "front")] == rig.model - assert all(rig.tracker._branches.states[role].revision == revisions[role] for role in MOVING) - - -def test_missing_articulated_tag_does_not_promote_the_other_moving_candidate(rig): - revisions = rig.establish() - observation = rig.frame(11, omit=("thumb_ip",)) - rows, movement = rig.capture.consume(observation, rig.motion) - assert movement is None and not _joint_rows(rows) - assert rig.capture.last_missing["front"] == (2,) - assert not rig.tracker._branches.states["thumb_pitch"].conflict - rows, movement = rig.capture.consume(rig.frame(12, index=21), rig.motion) - assert movement is None and len(_joint_rows(rows)) == 2 - assert all(rig.tracker._branches.states[role].revision == revisions[role] for role in MOVING) - - -def test_two_current_geometric_solutions_are_not_resolved_by_image_error(rig): - revisions = rig.establish() - observation = rig.frame(11, index=21) - alternative = {item.role: item.candidates[-1] for item in rig.frames[25].roles} - for role in MOVING: - # Both pairs lie on the same frozen articulated motion, so neither - # geometric consistency nor lower image error determines physical truth. - rig.current[role] = (rig.current[role][-1], replace(alternative[role], reprojection_error_px=.05)) - rows, movement = rig.capture.consume(observation, rig.motion) - assert movement is None and not _joint_rows(rows) - events = [row for row in rows if row["kind"] == "pnp_branch_event"] - assert {row["tag_role"] for row in events} == set(MOVING) - assert all(row["reason"] == "pose_branch_motion_geometry_ambiguous" for row in events) - assert all(rig.tracker._branches.states[role].revision == revisions[role] for role in MOVING) diff --git a/src/linkerhand_calibration/test/test_motion_capture_transitions.py b/src/linkerhand_calibration/test/test_motion_capture_transitions.py deleted file mode 100644 index b7c284d..0000000 --- a/src/linkerhand_calibration/test/test_motion_capture_transitions.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Model identity belongs to a physical Tag, including between task streams.""" - -from dataclasses import replace - -import pytest - -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from test_motion_capture_tracking import IDS, MOVING, rig - - -@pytest.mark.parametrize("transition", ["idle", "baseline", "return", "reference_return", - "mapping_probe", "clearance", "different_task"]) -def test_installed_tag_never_returns_to_ippe_during_nonrecording_transitions(rig, monkeypatch, transition): - revisions = rig.establish() - update = rig.tracker._branches.update - - def fixed_tags_only(role, *args, **kwargs): - assert role not in MOVING, "a model-authorized Tag must never return to independent IPPE" - return update(role, *args, **kwargs) - - monkeypatch.setattr(rig.tracker._branches, "update", fixed_tags_only) - motion = None - if transition != "idle": - task = rig.profile.motion.tasks[0] if transition == "different_task" else None - motion = MotionCommand("prepare" if task else transition, - rig.profile.command.baseline_values, 40., task_key=None if task is None else task.key) - for counter in range(11, 15): - frame = rig.frame(counter, index=counter + 10) - rows, movement = rig.capture.consume(frame, motion) - assert movement is None - assert not any(row["kind"] in {"joint_sample", "joint_zero_sample", "pnp_candidate_frame"} for row in rows) - for role in MOVING: - state = rig.tracker._branches.states[role] - assert state.pose == rig.current[role][-1] - assert state.revision == revisions[role] and state.motion_evidence_id == IDS[role] - assert state.frozen and not state.conflict - - -def test_a_later_task_still_keeps_prior_model_without_recording_prior_joint(rig): - rig.establish() - other = rig.profile.motion.tasks[0] - motion = replace(rig.motion, phase="sweep", task_key=other.key, direction="decreasing", zero_joints=()) - rows, _ = rig.capture.consume(rig.frame(11, index=21), motion) - assert not any(row.get("joint") in {"rh_thumb_cmc_pitch", "rh_thumb_ip"} for row in rows) - assert all(rig.tracker._branches.states[role].pose == rig.current[role][-1] for role in MOVING) diff --git a/src/linkerhand_calibration/test/test_motion_confirmed_tracking.py b/src/linkerhand_calibration/test/test_motion_confirmed_tracking.py deleted file mode 100644 index a790691..0000000 --- a/src/linkerhand_calibration/test/test_motion_confirmed_tracking.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Geometric branch authorization must remain tied to one image and reference. - -These tests exercise the metadata contract with synthetic geometric decisions; -physical motion-model selection has its own numeric tests. -""" - -from dataclasses import FrozenInstanceError, replace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose import tracking -from linkerhand_calibration.core.geometry.tag_pose.tracking import ( - MotionPoseSelection, SquareTagPoseTracker, -) -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - - -START = 1_000_000_000 -STEP = 33_000_000 -EVIDENCE = "a" * 64 - - -def pose(angle=0., error=.05, translation=(0., 0., .5)): - return SquareTagPose(tuple(Rotation.from_euler("y", angle, degrees=True).as_quat()), - translation, error) - - -@pytest.fixture -def source(monkeypatch): - class Source: - candidates = (pose(0., .20), pose(60., .05)) - - def observe(self, tracker, stamp, role="moving", managed=True): - result = tracker.estimate(role, ((0, 0), (1, 0), (1, 1), (0, 1)), - tag_size_m=.016, camera_matrix=np.eye(3), stamp_ns=stamp, - motion_managed=managed) - return result, tracker.motion_snapshot(role, stamp) - - instance = Source() - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, **k: list(instance.candidates)) - return instance - - -def selection(source, tracker, stamp, *, role="moving", candidate=0, evidence=EVIDENCE): - result, snapshot = source.observe(tracker, stamp, role) - assert result[0] is None - return MotionPoseSelection(snapshot, source.candidates[candidate], evidence) - - -def establish(source, tracker, stamp=START, *, role="moving"): - choice = selection(source, tracker, stamp, role=role) - result = tracker.commit_motion_frame((choice,), establish=True) - assert result.accepted - return tracker.motion_snapshot(role, stamp) - - -def test_motion_managed_images_never_confirm_an_image_winner_without_geometry(source): - tracker = SquareTagPoseTracker() - for i in range(20): - outcome, snapshot = source.observe(tracker, START + i * STEP) - assert outcome == (None, "pose_motion_evidence_required") - assert snapshot.revision == 0 and snapshot.evidence_id is None - assert not tracker.freeze_roles({"moving": 0}, require_motion_evidence=True) - - -def test_establishment_authorizes_current_candidate_without_increasing_point03_tolerance(source): - tracker = SquareTagPoseTracker() - choice = selection(source, tracker, START) - assert not tracker.commit_motion_frame((choice,)).accepted - # The failed authorization request consumed that decision's frame. - choice = selection(source, tracker, START + STEP) - result = tracker.commit_motion_frame((choice,), establish=True) - assert result.accepted and result.selections[0].pose == source.candidates[0] - detail = result.observation_snapshots[0].diagnostics - assert detail["motion_evidence_verified"] is True - assert detail["motion_evidence_id"] == EVIDENCE - assert detail["branch_revision"] == 1 - assert detail["branch_status"] == "tracking" - assert tracker.reprojection_tie_px == .03 - assert tracker.freeze_roles({"moving": 1}, require_motion_evidence=True) - - -def test_geometry_correction_is_explicit_and_only_before_freeze(source): - tracker = SquareTagPoseTracker() - for i in range(5): - result, _ = source.observe(tracker, START + i * STEP, managed=False) - assert result[0] == source.candidates[1] - old_revision = tracker.motion_snapshot("moving", START + 4 * STEP).revision - choice = selection(source, tracker, START + 5 * STEP) - result = tracker.commit_motion_frame((choice,), establish=True) - assert result.accepted - assert result.observation_snapshots[0].diagnostics["branch_revision"] == old_revision + 1 - assert result.observation_snapshots[0].corrections == 1 - assert not tracker.freeze_roles({"moving": old_revision}, require_motion_evidence=True) - - -def test_existing_image_only_frozen_reference_cannot_be_reauthorized(source): - tracker = SquareTagPoseTracker() - for i in range(5): - source.observe(tracker, START + i * STEP, managed=False) - assert not tracker.freeze_roles({"moving": 1}, require_motion_evidence=True) - assert tracker.freeze_roles({"moving": 1}) - choice = selection(source, tracker, START + 5 * STEP) - assert not tracker.commit_motion_frame((choice,), establish=True).accepted - assert tracker.motion_snapshot("moving", START + 5 * STEP).evidence_id is None - - -def test_frozen_geometry_keeps_current_candidate_when_image_winner_changes(source): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - for i in range(1, 190): - source.candidates = (pose(.001 * i, .20), pose(60., .05)) - result = tracker.commit_motion_frame((selection(source, tracker, START + i * STEP),)) - assert result.accepted - detail = result.observation_snapshots[0].diagnostics - assert detail["branch_revision"] == initial.revision - assert detail["branch_frozen"] is True - assert result.observation_snapshots[0].corrections == 0 - - -@pytest.mark.parametrize("candidate", [pose(0., 1.50001), pose(76., .05)]) -def test_geometry_cannot_bypass_reprojection_or_tilt_gate(source, candidate): - tracker = SquareTagPoseTracker() - source.candidates = (candidate,) - choice = selection(source, tracker, START) - assert choice.snapshot.candidates == () - result = tracker.commit_motion_frame((choice,), establish=True) - assert not result.accepted and result.reason == "pose_motion_selection_invalid" - - -def test_geometry_cannot_insert_an_unobserved_pose(source): - tracker = SquareTagPoseTracker() - choice = selection(source, tracker, START) - result = tracker.commit_motion_frame((replace(choice, pose=pose(1.)),), establish=True) - assert not result.accepted and result.reason == "pose_motion_selection_invalid" - - -def test_frozen_role_cannot_change_evidence_id_or_jump_branch(source): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - changed = selection(source, tracker, START + STEP, evidence="b" * 64) - assert tracker.commit_motion_frame((changed,), establish=True).reason == "pose_motion_evidence_changed" - discontinuous = selection(source, tracker, START + 2 * STEP, candidate=1) - assert tracker.commit_motion_frame((discontinuous,)).reason == "pose_motion_branch_discontinuous" - snapshot = tracker.motion_snapshot("moving", START + 2 * STEP) - assert snapshot.evidence_id == EVIDENCE and snapshot.revision == initial.revision - result = tracker.commit_motion_frame((selection(source, tracker, START + 3 * STEP),)) - assert result.accepted - - -@pytest.mark.parametrize("interruption", ["reset", "new_image", "freeze"]) -def test_late_geometry_result_cannot_commit_after_reference_or_image_changes(source, interruption): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - choice = selection(source, tracker, START + STEP) - if interruption == "reset": - tracker.reset() - elif interruption == "new_image": - source.observe(tracker, START + 2 * STEP) - else: - # Freeze needs a committed frame. This simulates a different authorized - # consumer committing and then freezing while an old solve was running. - assert tracker.commit_motion_frame((choice,)).accepted - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - result = tracker.commit_motion_frame((choice,)) - assert not result.accepted and result.reason == "pose_observation_superseded" - - -def test_partial_geometry_batch_never_establishes_only_one_role(source): - tracker = SquareTagPoseTracker() - first = selection(source, tracker, START, role="parent") - second = selection(source, tracker, START, role="child") - invalid = replace(second, pose=pose(2.)) - assert not tracker.commit_motion_frame((first, invalid), establish=True).accepted - assert tracker.motion_snapshot("parent", START).evidence_id is None - assert tracker.motion_snapshot("child", START).evidence_id is None - assert tracker.commit_motion_frame((first, second), establish=True).accepted - - -def test_batch_checks_all_versions_before_any_mutation(source): - first, second = SquareTagPoseTracker(), SquareTagPoseTracker() - choices = (selection(source, first, START, role="parent"), - selection(source, second, START, role="child")) - second.reset() - assert not first.commit_motion_frame(choices, establish=True).accepted - assert first.motion_snapshot("parent", START).evidence_id is None - - -def test_real_time_gap_retains_frozen_recovery_and_unique_image_requirement(source): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - source.candidates = (pose(2., .20), pose(60., .05)) - stamp = START + 6_000_000_000 - result = tracker.commit_motion_frame((selection(source, tracker, stamp),)) - assert result.reason == "pose_branch_recovery_required" - source.candidates = (pose(.2, .20), pose(60., .05)) - for i in range(1, 6): - choice = selection(source, tracker, stamp + i * STEP) - result = tracker.commit_motion_frame((choice,)) - assert result.accepted is (i == 5) - repeated = tracker.commit_motion_frame((choice,)) - assert not repeated.accepted and repeated.reason == "pose_observation_superseded" - assert tracker.motion_snapshot("moving", stamp + 5 * STEP).revision == initial.revision - - -def test_commit_returns_detached_pose_and_diagnostics_for_exact_image(source): - tracker = SquareTagPoseTracker() - result = tracker.commit_motion_frame((selection(source, tracker, START),), establish=True) - old = result.observation_snapshots[0] - source.observe(tracker, START + STEP) - assert tracker.observation_snapshot("moving", START) is None - assert old.stamp_ns == START - assert old.diagnostics["observation_stamp_ns"] == START - assert old.diagnostics["branch_status"] == "tracking" - assert result.selections[0].pose == source.candidates[0] - with pytest.raises(FrozenInstanceError): - result.selections[0].snapshot.revision = 99 - - -def test_rejected_geometric_frame_reports_reason_and_preserves_reference(source): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - choice = selection(source, tracker, START + STEP) - reason = "pose_branch_motion_geometry_ambiguous" - result = tracker.reject_motion_frame((choice.snapshot,), reason) - assert not result.accepted and result.reason == reason - detail = result.observation_snapshots[0].diagnostics - assert detail["branch_reason"] == reason - assert detail["branch_revision"] == initial.revision - assert detail["motion_evidence_id"] == EVIDENCE - assert detail["branch_frozen"] is True - assert tracker.commit_motion_frame((choice,)).reason == "pose_observation_superseded" - next_choice = selection(source, tracker, START + 2 * STEP) - assert tracker.commit_motion_frame((next_choice,)).accepted - - -def test_late_geometric_rejection_cannot_modify_a_newer_frame(source): - tracker = SquareTagPoseTracker() - establish(source, tracker) - old = selection(source, tracker, START + STEP) - current = selection(source, tracker, START + 2 * STEP) - result = tracker.reject_motion_frame((old.snapshot,), "pose_branch_motion_geometry_ambiguous") - assert result.reason == "pose_observation_superseded" - assert tracker.commit_motion_frame((current,)).accepted - - -def test_rejection_batch_is_atomic_when_another_view_has_reset(source): - first, second = SquareTagPoseTracker(), SquareTagPoseTracker() - snapshots = (selection(source, first, START).snapshot, selection(source, second, START).snapshot) - second.reset() - result = first.reject_motion_frame(snapshots, "pose_branch_motion_geometry_inconsistent") - assert result.reason == "pose_observation_superseded" - assert first.observation_snapshot("moving", START)[1]["branch_reason"] == "pose_motion_evidence_required" - - -def test_geometric_rejection_cannot_hide_a_preexisting_frozen_conflict(source): - tracker = SquareTagPoseTracker() - initial = establish(source, tracker) - assert tracker.freeze_roles({"moving": initial.revision}, require_motion_evidence=True) - source.candidates = (pose(60., .05), pose(70., .2)) - for i in range(1, 6): - source.observe(tracker, START + i * STEP, managed=False) - assert tracker._branches.states["moving"].conflict - choice = selection(source, tracker, START + 6 * STEP) - result = tracker.reject_motion_frame((choice.snapshot,), "pose_branch_motion_geometry_inconsistent") - assert result.reason == "pose_branch_conflict" - assert result.observation_snapshots[0].diagnostics["branch_status"] == "frozen_conflict" diff --git a/src/linkerhand_calibration/test/test_motion_image_diagnostics.py b/src/linkerhand_calibration/test/test_motion_image_diagnostics.py deleted file mode 100644 index 039db3d..0000000 --- a/src/linkerhand_calibration/test/test_motion_image_diagnostics.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Image-space diagnosis stays separate from trusted branch/zero decisions.""" -from dataclasses import asdict, replace -import json - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -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, resolve_motion_branches, -) -from linkerhand_calibration.core.geometry.tag_pose.motion_image_diagnostics import ( - ImageMotionFrame, ImageRoleObservation, MotionImageDiagnosticParameters, analyze_motion_images, -) -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - -MATRIX = ((1600., 0., 640.), (0., 1600., 480.), (0., 0., 1.)) -RELATIONS = (MotionRelation("hinge", "root", "moving"),) - - -def _pose(rotation, translation): - return SquareTagPose(tuple(rotation.as_quat()), tuple(translation), .1) - - -def frames(*, chain=False, alternatives=False, depth_error=0., count=49): - result = [] - for index, theta in enumerate(np.linspace(0, np.deg2rad(80), count)): - root_rotation, root_translation = Rotation.identity(), np.array([0., 0., .8]) - rotation = Rotation.from_rotvec([0., 0., theta]) - translation = root_translation + [0.01, 0.02, 0.0] + rotation.apply([.035, 0., 0.]) - actual = [("root", root_rotation, root_translation), ("moving", rotation, translation)] - if chain: - child_rotation = rotation * Rotation.from_rotvec([theta * .45, 0., 0.]) - child_translation = translation + rotation.apply([.025, 0, 0]) + child_rotation.apply([0., .035, 0.]) - actual.append(("terminal", child_rotation, child_translation)) - roles, observations = [], [] - for role, rotation, translation in actual: - points = rotation.apply(square_object_points(.016)) + translation - pixels = points @ np.asarray(MATRIX).T - corners = pixels[:, :2] / pixels[:, 2:] - observations.append(ImageRoleObservation(role, .016, tuple(map(tuple, corners)))) - error = depth_error * np.sin(theta * 5) if role != "root" else 0. - pose = _pose(rotation, translation + [0., 0., error]) - candidates = (pose,) - if alternatives and role != "root": - wrong = _pose(Rotation.from_euler("y", 25, degrees=True) * rotation, - translation + [0., 0., error]) - candidates = (pose, wrong) - roles.append(RoleCandidates(role, candidates, frozen=role == "root")) - result.append(ImageMotionFrame(MotionEvidenceFrame(1_000_000_000 + index * 50_000_000, - tuple(roles)), MATRIX, tuple(observations))) - return tuple(result) - - -def test_image_fit_explains_noisy_depth_without_upgrading_branch_authorization(): - source = frames(depth_error=.008) - before = resolve_motion_branches([f.evidence for f in source], RELATIONS) - assert not before.resolved - report = analyze_motion_images(source, RELATIONS) - assert report.status == "available", report - assert report.scope == "diagnostic_not_branch_authorization" - assert not report.branch_authorization_allowed and not report.calibration_accuracy_verified - result = report.hypotheses[0] - assert result.pnp_geometry[0].translation_rms_m > .003 - assert result.pnp_geometry[0].camera_depth_squared_error_fraction > .99 - assert max(q.rms_px for interval in result.intervals for q in interval.validation_quality) < 1e-5 - assert resolve_motion_branches([f.evidence for f in source], RELATIONS) == before - json.dumps(asdict(report), allow_nan=False) - - -def test_unseen_shift_cannot_be_hidden_by_refitting_validation_geometry(): - source = list(frames()) - for index in range(len(source) // 2, len(source)): - observations = list(source[index].observations) - observations[1] = replace(observations[1], corners_xy=tuple( - (x + 8., y) for x, y in observations[1].corners_xy)) - source[index] = replace(source[index], observations=tuple(observations)) - report = analyze_motion_images(source, RELATIONS) - interval = next(v for v in report.hypotheses[0].intervals if v.name == "early_to_late") - assert interval.converged, interval - assert interval.training_quality[0].rms_px < 1e-5 - assert interval.validation_quality[0].rms_px > 2 - assert set(interval.training_stamps_ns).isdisjoint(interval.validation_stamps_ns) - - -def test_serial_graph_and_all_candidate_families_survive_as_diagnostics(): - source = frames(chain=True, alternatives=True) - relations = (*RELATIONS, MotionRelation("distal", "moving", "terminal")) - report = analyze_motion_images(source, tuple(reversed(relations))) - assert len(report.hypotheses) == 4 - assert all(dict(h.branches)["root"] == 0 for h in report.hypotheses) - assert any(h.intervals and h.intervals[0].converged for h in report.hypotheses) - assert not report.branch_authorization_allowed - for hypothesis in report.hypotheses: - for interval in hypothesis.intervals: - assert set(interval.training_stamps_ns).isdisjoint(interval.validation_stamps_ns) - assert len(interval.training_stamps_ns) <= 36 - assert len(interval.validation_stamps_ns) <= 72 - - -@pytest.mark.parametrize("change, reason", [ - ("missing", "image_observation_missing_or_duplicate"), - ("size", "tag_size_changed"), - ("matrix", "camera_model_changed"), - ("identity", "image_identity_invalid"), - ("unfrozen", "root_pose_not_frozen"), - ("nan", "tag_geometry_invalid"), -]) -def test_incomplete_or_changed_optics_are_explicitly_unavailable(change, reason): - source = list(frames()) - target = source[-1] - if change == "missing": - target = replace(target, observations=target.observations[:1]) - elif change == "size": - target = replace(target, observations=(target.observations[0], - replace(target.observations[1], tag_size_m=.017))) - elif change == "matrix": - target = replace(target, camera_matrix=((1500., 0., 640.), *MATRIX[1:])) - elif change == "identity": - target = replace(target, evidence=replace(target.evidence, stamp_ns=source[0].evidence.stamp_ns)) - elif change == "unfrozen": - target = replace(target, evidence=replace(target.evidence, roles=( - replace(target.evidence.roles[0], frozen=False), *target.evidence.roles[1:]))) - elif change == "nan": - target = replace(target, observations=(target.observations[0], replace(target.observations[1], - corners_xy=((float("nan"), 1.), *target.observations[1].corners_xy[1:])))) - source[-1] = target - report = analyze_motion_images(source, RELATIONS) - assert report.status == "not_available" and report.reason == reason - assert not report.hypotheses - - -def test_graph_and_solver_complexity_are_bounded(): - source = frames() - cyclic = (MotionRelation("a", "root", "moving"), MotionRelation("b", "moving", "root")) - assert analyze_motion_images(source, cyclic).reason == "cyclic_motion_graph" - params = MotionImageDiagnosticParameters(maximum_solver_evaluations=1) - report = analyze_motion_images(frames(depth_error=.003), RELATIONS, parameters=params) - assert report.status == "partial" - assert all(i.solver_evaluations <= 1 for i in report.hypotheses[0].intervals) - for kwargs in ({"maximum_hypotheses": 100}, {"maximum_solver_evaluations": 151}, - {"minimum_frames": 3}, {"maximum_relations": 10}): - with pytest.raises(ValueError): - MotionImageDiagnosticParameters(**kwargs) - - -def test_stationary_data_is_not_evidence_of_a_unique_image_branch(): - source = frames() - still = tuple(replace(source[0], evidence=replace(source[0].evidence, - stamp_ns=f.evidence.stamp_ns)) for f in source) - report = analyze_motion_images(still, RELATIONS) - assert report.status == "not_available" - assert report.hypotheses[0].reason == "insufficient_rotation_evidence" - - -def test_previously_frozen_moving_parent_uses_its_current_frame_pose(): - source = frames(chain=True) - changed = [] - for frame in source: - roles = tuple(replace(role, frozen=True) if role.role == "moving" else role - for role in frame.evidence.roles) - changed.append(replace(frame, evidence=replace(frame.evidence, roles=roles))) - report = analyze_motion_images(changed, (MotionRelation("distal", "moving", "terminal"),)) - assert report.status == "available", report - for interval in report.hypotheses[0].intervals: - assert interval.converged - assert interval.validation_quality[0].rms_px < 1e-5 - assert not report.branch_authorization_allowed diff --git a/src/linkerhand_calibration/test/test_motion_reference_provenance.py b/src/linkerhand_calibration/test/test_motion_reference_provenance.py deleted file mode 100644 index cdbdb87..0000000 --- a/src/linkerhand_calibration/test/test_motion_reference_provenance.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Motion authorization survives zero serialization, holdout and resume.""" - -from copy import deepcopy -from dataclasses import replace -import hashlib -import json - -import pytest - -from linkerhand_calibration.core.domain.reference import build_joint_zero_reference, read_joint_zero_references -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.joint_resume import JointResume -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256, validate_motion_provenance -from test_joint_zero_runtime import reference_rows - - -def motion_capture(*, zero_stamp=100, revision=7, source_stamps=(10, 20), layout="o6_right_8", task_index=0): - profile = load_bundled_hand_profile(layout) - task = profile.motion.tasks[task_index] - name = task.joints[0] - profile = replace(profile, motion=replace(profile.motion, tasks=(task,))) - measurement = profile.measurement.measurements[name] - role = measurement.child_role - payload = {"task_name": task.key, "view": measurement.view, "tag_role": role, - "zero_joints": [name], "session_epoch": 1, "motion_version": 1, - "source_image_stamps": list(source_stamps), "constraints": [{"child_role": role, "axis": [1., 0., 0.]}]} - sources = [{"kind": "motion_branch_observation", "image_stamp_ns": stamp, - "command_vector": list(profile.command.baseline_values), "feedback_vector": list(profile.command.baseline_values), - "tags": {role: {"corners_xy": [[1., 1.], [2., 1.], [2., 2.], [1., 2.]], - "reprojection_valid_candidates": [{"quaternion_xyzw": [0., 0., 0., 1.], - "translation_xyz_m": [0., 0., .5]}]}}, - **{key: payload[key] for key in ("task_name", "view", "zero_joints", "session_epoch", "motion_version")}} - for stamp in source_stamps] - payload["source_frame_hashes"] = {str(row["image_stamp_ns"]): source_frame_sha256(row) for row in sources} - identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest() - audit = {"kind": "motion_branch_initialization", "status": "resolved", "reason": "", - **{key: value for key, value in payload.items() if key != "tag_role"}, - "evidence_ids": {role: identity}, "evidence_payloads": {role: payload}} - zeros = reference_rows(profile, name, stamp=zero_stamp) - for row in zeros: - row.update(kind="joint_zero_sample", joint=name) - row["pnp_observation_evidence"]["child"]["candidate_diagnostics"].update( - branch_revision=revision, motion_evidence_verified=True, motion_evidence_id=identity, branch_frozen=False) - reference = build_joint_zero_reference(profile, name, zeros, session_epoch=1, motion_version=2) - scans = [] - for cycle in range(4): - row = deepcopy(zeros[-1]) - row.update(kind="joint_sample", sample_phase="sweep", cycle=cycle, image_stamp_ns=zero_stamp+20+cycle) - for item in row["pnp_observation_evidence"].values(): - item["candidate_diagnostics"].update(observation_stamp_ns=row["image_stamp_ns"], branch_frozen=True) - scans.append(row) - return profile, reference, [*sources, audit, *zeros, reference.as_record(), *scans] - - -@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) -def test_motion_zero_roundtrip_and_all_four_cycles_remain_bound_without_header(layout): - profile, reference, records = motion_capture(layout=layout) - restored = read_joint_zero_references(profile, records)[reference.joint] - assert restored == reference - assert len(restored.branch_references) == 1 - report = validate_motion_provenance(profile, records, require_complete=True) - assert report["motion_evidence_verified"] is True - assert report["is_accuracy_certificate"] is False - - -@pytest.mark.parametrize("mutation", ["missing_audit", "missing_source", "model_tampered", "sample_id", - "holdout_revision", "holdout_not_frozen", "zero_provenance_removed", "false_verification"]) -def test_missing_or_changed_provenance_is_rejected_even_without_policy_header(mutation): - profile, reference, records = motion_capture() - if mutation == "missing_audit": - records = [row for row in records if row["kind"] != "motion_branch_initialization"] - elif mutation == "missing_source": - records.pop(0) - elif mutation == "model_tampered": - audit = next(row for row in records if row["kind"] == "motion_branch_initialization") - next(iter(audit["evidence_payloads"].values()))["constraints"][0]["axis"] = [0., 1., 0.] - elif mutation == "zero_provenance_removed": - next(row for row in records if row["kind"] == "joint_zero_reference").pop("branch_references") - else: - diagnostics = records[-1]["pnp_observation_evidence"]["child"]["candidate_diagnostics"] - field, value = {"sample_id": ("motion_evidence_id", "a"*64), - "holdout_revision": ("branch_revision", 8), "holdout_not_frozen": ("branch_frozen", False), - "false_verification": ("motion_evidence_verified", False)}[mutation] - diagnostics[field] = value - with pytest.raises(ValueError, match="motion_branch_provenance_"): - validate_motion_provenance(profile, records, require_complete=True) - - -def test_motion_support_must_precede_first_frozen_zero_image(): - profile, _, records = motion_capture(source_stamps=(100, 101)) - with pytest.raises(ValueError, match="initialization_not_before_zero"): - validate_motion_provenance(profile, records) - - -@pytest.mark.parametrize("field", ["candidate", "corners", "command_vector", "feedback_vector"]) -def test_source_frame_content_cannot_change_without_invalidating_motion_evidence(field): - profile, _, records = motion_capture() - frame = records[0] - if field in {"command_vector", "feedback_vector"}: - frame[field][0] += 1 - else: - tag = next(iter(frame["tags"].values())) - if field == "candidate": - tag["reprojection_valid_candidates"][0]["translation_xyz_m"][0] += .01 - else: - tag["corners_xy"][0][0] += 1 - with pytest.raises(ValueError, match="source_content_changed"): - validate_motion_provenance(profile, records) - - -@pytest.mark.parametrize("mutation", ["missing", "extra"]) -def test_source_hashes_must_cover_exactly_the_declared_support_frames(mutation): - profile, _, records = motion_capture() - audit = next(row for row in records if row["kind"] == "motion_branch_initialization") - # Mutate only the top-level declaration so the immutable payload still - # independently detects its binding; it cannot authorize a subset instead. - audit["source_frame_hashes"] = dict(audit["source_frame_hashes"]) - if mutation == "missing": - audit["source_frame_hashes"].pop("10") - else: - audit["source_frame_hashes"]["999"] = "a"*64 - with pytest.raises(ValueError, match="initialization_binding_changed|source_hash_coverage_invalid"): - validate_motion_provenance(profile, records) - - -def test_zero_cannot_mix_different_authorizations_across_reference_images(): - profile, reference, records = motion_capture() - rows = [row for row in records if row["kind"] == "joint_zero_sample"] - rows[-1]["pnp_observation_evidence"]["child"]["candidate_diagnostics"]["motion_evidence_id"] = "a"*64 - with pytest.raises(ValueError, match="joint_zero_motion_evidence_changed"): - build_joint_zero_reference(profile, reference.joint, rows, session_epoch=1, motion_version=2) - - -def test_legacy_zero_remains_readable_without_claiming_motion_confirmation(): - profile, reference, _ = motion_capture() - legacy = build_joint_zero_reference(profile, reference.joint, - reference_rows(profile, reference.joint, stamp=100), session_epoch=1, motion_version=2) - row = legacy.as_record() - row.pop("branch_references") - assert read_joint_zero_references(profile, [row])[reference.joint].branch_references == () - assert validate_motion_provenance(profile, [row])["motion_evidence_verified"] is False - with pytest.raises(ValueError, match="zero_evidence_missing"): - validate_motion_provenance(profile, [row], required=True) - with pytest.raises(ValueError, match="zero_evidence_missing"): - validate_motion_provenance(profile, [{"kind": "session_start", - "pose_tracking_policy_version": "confirmed_branch_v4_motion_geometry"}, row]) - - -def test_legacy_branch_events_are_diagnostics_and_new_events_cannot_downgrade(): - profile, _, _ = motion_capture() - event = {"kind": "pnp_branch_event", "pnp_observation_evidence": { - "tag_role": "thumb_yaw", "corners_xy": [[0, 0]]*4, - "candidate_diagnostics": {"branch_frozen": True}}} - assert validate_motion_provenance(profile, [event])["motion_evidence_verified"] is False - event["pnp_observation_evidence"]["candidate_diagnostics"]["motion_evidence_verified"] = True - with pytest.raises(ValueError, match="joint_zero_references_missing"): - validate_motion_provenance(profile, [event]) - - -def test_motion_confirmation_does_not_require_fixed_tag_motion_evidence(): - profile, _, records = motion_capture() - for row in records: - for side, item in row.get("pnp_observation_evidence", {}).items(): - if side == "parent": - item["candidate_diagnostics"].update(motion_evidence_verified=False, motion_evidence_id="") - assert validate_motion_provenance(profile, records, require_complete=True)["motion_evidence_verified"] is True - - -def test_repeated_physical_tag_keeps_one_original_evidence_payload(): - profile, _, records = motion_capture() - first = next(row for row in records if row["kind"] == "motion_branch_initialization") - repeated = {**first, "task_name": "later_task", "source_image_stamps": [500], - "evidence_payloads": {}} - source = {**records[0], "task_name": "later_task", "image_stamp_ns": 500} - repeated["source_frame_hashes"] = {"500": source_frame_sha256(source)} - records.append(source) - records.append(repeated) - assert validate_motion_provenance(profile, records)["evidence_count"] == 1 - - -def test_resume_rechecks_pose_and_preserves_old_zero_with_distinct_new_evidence_ids(): - profile, previous, original = motion_capture() - _, current, fresh = motion_capture(zero_stamp=300, revision=9, source_stamps=(210, 220)) - resume = JointResume(profile) - resume.references = {previous.joint: previous} - resume.rows = tuple(original) - assert resume.verify(current) is previous - authorized = resume.take_motion_provenance_records() - assert any(row["kind"] == "joint_zero_motion_reference_verified" for row in authorized) - assert resume.take_motion_provenance_records() == () - merged = [row for row in fresh if row["kind"] != "joint_zero_reference"] - merged.extend(authorized) - merged.append(previous.as_record()) - assert validate_motion_provenance(profile, merged, require_complete=True)["motion_evidence_verified"] is True - mapping = next(row for row in merged if row["kind"] == "joint_zero_motion_reference_verified") - # Swapping an unrelated held pose cannot authorize reuse merely because IDs exist. - mapping["current_reference"]["samples"][0]["parent_pose_common"]["translation_xyz_m"] = (1., 0., .5) - with pytest.raises(ValueError): - validate_motion_provenance(profile, merged) - - -def test_resume_imports_source_evidence_once_but_keeps_each_joint_verification(): - # G20 first two tasks each observe one independent motion role. This test - # checks journal delivery across joints, not the geometry solver itself. - profile = load_bundled_hand_profile("g20_right_19") - _, first, first_rows = motion_capture(layout="g20_right_19") - _, second, second_rows = motion_capture(layout="g20_right_19", task_index=1, - zero_stamp=200, source_stamps=(110, 120)) - resume = JointResume(profile) - resume.references = {first.joint: first, second.joint: second} - resume.rows = tuple([*first_rows, *second_rows]) - assert resume.verify(first) is first - first_batch = resume.take_motion_provenance_records() - assert any(row["kind"] == "motion_branch_observation" for row in first_batch) - assert resume.verify(second) is second - second_batch = resume.take_motion_provenance_records() - assert all(row["kind"] in {"joint_zero_sample", "joint_zero_motion_reference_verified"} for row in second_batch) - assert all(row["joint"] == second.joint for row in second_batch) - assert sum(row["kind"] == "joint_zero_motion_reference_verified" for row in second_batch) == 1 - assert resume.verify(first) is first - assert resume.take_motion_provenance_records() == () - # Failed group freezing may defer draining; retry replaces the pending - # verification and still does not copy the checkpoint sources again. - later = replace(second, motion_version=second.motion_version+1) - resume.verify(later) - resume.verify(later) - final_batch = resume.take_motion_provenance_records() - assert len(final_batch) == 1 and final_batch[0]["joint"] == second.joint - - -def test_partial_resume_of_an_already_resumed_session_keeps_unverified_joint_mappings_out(): - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine - - profile = load_bundled_hand_profile("g20_right_19") - _, first, first_rows = motion_capture(layout="g20_right_19") - _, second, second_rows = motion_capture(layout="g20_right_19", task_index=3, - zero_stamp=200, source_stamps=(110, 120)) - original = {first.joint: first, second.joint: second} - previous = JointResume(profile) - previous.references, previous.rows = original, tuple([*first_rows, *second_rows]) - previous_session = [] - for index, stamp in ((0, 1000), (3, 1200)): - _, current, fresh = motion_capture(layout="g20_right_19", task_index=index, - zero_stamp=stamp, source_stamps=(stamp-100, stamp-90)) - previous.verify(current) - previous_session.extend(row for row in fresh if row["kind"] != "joint_zero_reference") - previous_session.extend(previous.take_motion_provenance_records()) - previous_session.append(original[current.joint].as_record()) - assert validate_motion_provenance(profile, previous_session, required=True)["motion_evidence_verified"] is True - header = {"kind": "session_start", "profile_id": profile.key.profile_id, - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION, - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "capture_schedule_version": CalibrationEngine(profile).capture_schedule_version} - resuming = JointResume(profile) - resuming.stage(CalibrationEngine(profile), [header, *previous_session], set()) - _, current, fresh = motion_capture(layout="g20_right_19", zero_stamp=2000, source_stamps=(1900, 1910)) - preserved = resuming.verify(current) - partial = [header, *(row for row in fresh if row["kind"] != "joint_zero_reference"), - *resuming.take_motion_provenance_records(), preserved.as_record()] - assert all(row.get("joint") == first.joint for row in partial - if row.get("kind") in {"joint_zero_sample", "joint_zero_motion_reference_verified"}) - assert validate_motion_provenance(profile, partial, required=True)["joint_count"] == 1 - # An abort at this point must leave a valid next checkpoint. It must not - # require the second joint, whose installation has not been checked yet. - subsequent = JointResume(profile) - subsequent.stage(CalibrationEngine(profile), partial, set()) - assert set(subsequent.references) == {first.joint} - - -def test_motion_gate_requires_provenance_outside_articulated_candidate_selection(): - _, _, records = motion_capture() - profile = load_bundled_hand_profile("o6_right_8") - records = [row for row in records if row["kind"] != "motion_branch_initialization"] - profile = replace(profile, measurement=replace(profile.measurement, candidate_selection_tasks=frozenset())) - with pytest.raises(ValueError, match="motion_branch_provenance_"): - validate_motion_provenance(profile, records, required=True, require_complete=True) diff --git a/src/linkerhand_calibration/test/test_motion_transfer.py b/src/linkerhand_calibration/test/test_motion_transfer.py deleted file mode 100644 index 65d6894..0000000 --- a/src/linkerhand_calibration/test/test_motion_transfer.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Declared finger transfers preserve frozen motion and recipient CAD geometry. - -These synthetic frozen results exercise integration contracts, not hardware -accuracy or the acquisition/optimization stages covered by capture tests. -""" - -from dataclasses import replace -import hashlib -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy as np -import pytest - -from measured_capture_fixture import reordered_profile -from linkerhand_calibration.core.domain.measurement import JointCurveFit -from linkerhand_calibration.core.domain.profile import KnownBaselineGeometry, ProfileValidationError -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.domain.result import CalibrationResult, JointMapping, evaluate_mappings -from linkerhand_calibration.core.fitting.coupling import LinearMimicFit -from linkerhand_calibration.core.fitting.mimic_approximation import MimicApproximation -from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint -from linkerhand_calibration.core.fitting.motion_transfer import expand_profile_transfers -from linkerhand_calibration.core.fitting.session import fit_profile_calibration -from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult -from linkerhand_calibration.core.urdf.result_plan import prepare_standard_result -from linkerhand_calibration.profiles.loader import load_bundled_hand_profile - - -PACKAGE = Path(__file__).resolve().parents[1] -O6_SOURCE = PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf" -O12_SOURCE = PACKAGE / "urdf/o12_right/linkerhand_o12_t3_right-0703.urdf" - - -def _digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _geometry_only_o6_profile(): - """Keep default CAD safety bounds for metadata-only structure fixtures. - - The production nominal-envelope policy needs actual training/reference - evidence. Full measured O6 capture tests cover that separate contract. - """ - return replace(load_bundled_hand_profile("o6_right_8"), urdf_limit_policies={}) - - -def _frozen_fit(profile): - observed = {name for name, spec in profile.measurement.measurements.items() - if spec.kind != "validation_only" and name in profile.motion.joint_zero_references} - outputs, commands, curves, references = {}, {}, {}, {} - for name in sorted(observed): - channel = channel_for_joint(profile, name) - lo, hi = profile.command.minimum_values[channel], profile.command.maximum_values[channel] - baseline = profile.command.baseline_values[channel] - knots = np.arange(256.) if profile.command.unit == "u8" else np.unique([lo, (lo+hi)/2, baseline, hi]) - travel = (knots-baseline)/(hi-lo)*profile.command.joint_directions[channel] - # The passive table is deliberately nonlinear and not equal to mimic. - y = (.18*travel+.07*travel**2 if name in profile.zero.passive_joints - else .35*travel+.05*travel**2) - increasing, decreasing = tuple(y+.001*np.abs(travel)), tuple(y-.001*np.abs(travel)) - outputs[name] = JointMapping(name, channel, f"feedback_{profile.command.unit}", - tuple(knots), tuple(y), increasing, decreasing) - command_y = tuple(y+.002*travel**2) - commands[name] = replace(outputs[name], input_domain=f"command_{profile.command.unit}", - angle_rad=command_y) - curves[name] = JointCurveFit(tuple(y), decreasing, increasing, {}, 0., .002, {}) - spec = profile.motion.joint_zero_references[name] - # Samples are intentionally absent: these references test ownership, - # and this helper is never used as a publishable capture fixture. - references[name] = JointZeroReference(name, spec.task_key, baseline, channel, 1, 3, ()) - offsets = {name: .02 for name in profile.zero.direct_zero_joints} - methods = {name: "urdf_serial_axis_geometry" for name in offsets} - for name, geometry in profile.zero.known_baseline_geometry.items(): - offsets[name] = geometry.cad_angle_rad - methods[name] = "independent_known_baseline_geometry" - for name in profile.zero.cad_zero_assumptions: - offsets[name] = 0. - methods[name] = "assumed_source_cad_zero" - spatial = ZeroSolveResult(direct_offsets_rad={}, all_active_offsets_rad={}, - base_translation_xyz_m=(0., 0., 0.), base_quaternion_xyzw=(0., 0., 0., 1.), - validation_errors_rad=(), validation_error_by_joint_rad={}, validation_line_error_by_joint_m={}, - axis_line_rms_m=0., passed=True, cycle_offsets_rad={}, offset_uncertainty_rad={}, - offset_confidence_half_width_rad={}, training_cycles=(0, 1, 2), validation_cycle=3, - validation_original_error_by_joint_rad={}, validation_improvement_by_joint_rad={}, - validation_improvement_confidence_lower_rad={}, observability_rank=0, - observability_parameter_count=0, observability_condition_number=1., offset_covariance_rad2={}, - axis_cone_mismatch_by_joint_rad={}, axis_cone_bias_classification_by_joint={}, failure_reasons={}) - mimics = {name: MimicApproximation( - LinearMimicFit(parent, name, .6, 0., False, (0, 1, 2), (.001, -.002)), - (f"{name}:train",), (f"{name}:holdout",), (.003,)) - for name, parent in profile.zero.mimic_source_by_joint.items() - if name in observed and parent in observed} - return CalibrationResult(curves, outputs, offsets, methods, {}, - {name: (.003,) for name in observed}, spatial, - reference_inputs={name: ref.baseline_command for name, ref in references.items()}, - command_mappings=commands, - command_holdout_metrics={name: {"independently_measured": True} for name in observed}, - command_applicability={name: {"scope": "single_channel_at_declared_pose"} for name in observed}, - joint_zero_references=references, mimic_approximations=mimics) - - -@pytest.mark.parametrize("layout,count", [ - ("o6_right_8", 6), ("l6_right_8", 6), ("o12_right_16", 3), ("g20_right_19", 0), -]) -@pytest.mark.parametrize("reordered", [False, True], ids=["product", "virtual_reordered"]) -def test_three_observation_strategies_use_recipient_channels_without_refitting(layout, count, reordered): - profile = load_bundled_hand_profile(layout) - if reordered: - profile = reordered_profile(profile) - original = _frozen_fit(profile) - expanded = expand_profile_transfers(profile, original) - transfers = profile.measurement.transferred_motion_sources - assert len(transfers) == count - assert set(expanded.output_mappings) == set(profile.zero.active_joints) | set(profile.zero.passive_joints) - for field in ("curves", "joint_zero_references", "holdout_errors_rad", "command_holdout_metrics"): - assert getattr(expanded, field) == getattr(original, field) - assert not set(transfers) & getattr(expanded, field).keys() - for target, source in transfers.items(): - recipient_channel = channel_for_joint(profile, target) - assert recipient_channel != channel_for_joint(profile, source) - for field in ("output_mappings", "command_mappings"): - donor = getattr(original, field)[source] - mapping = getattr(expanded, field)[target] - assert mapping == replace(donor, joint=target, motor_index=recipient_channel, transferred_from=source) - assert mapping.evaluate(profile.command.baseline_values[recipient_channel]) == 0. - positions = list(profile.command.baseline_values) - positions[recipient_channel] = donor.knots[len(donor.knots)//2] - actual = evaluate_mappings(getattr(expanded, field), positions, {}) - assert actual[target] == donor.evaluate(positions[recipient_channel]) - assert actual[source] == 0. - assert target not in original.output_mappings - if target in profile.zero.passive_joints: - approximate = expanded.mimic_approximations[target] - source_fit = original.mimic_approximations[source] - assert approximate.transferred_from_joint == source - assert approximate.training_sample_ids == source_fit.training_sample_ids - assert approximate.holdout_sample_ids == source_fit.holdout_sample_ids - assert approximate.holdout_errors_rad == source_fit.holdout_errors_rad - assert approximate.output_fit.source_joint == profile.zero.mimic_source_by_joint[target] - parent = expanded.command_mappings[approximate.output_fit.source_joint] - child = expanded.command_mappings[target] - assert np.max(np.abs(np.asarray(child.angle_rad)-.6*np.asarray(parent.angle_rad))) > .005 - - -def test_old_zero_and_mimic_transfer_declarations_do_not_authorize_v3_motion_copy(): - profile = load_bundled_hand_profile("o6_right_8") - profile = replace(profile, measurement=replace(profile.measurement, transferred_motion_sources={})) - original = _frozen_fit(profile) - expanded = expand_profile_transfers(profile, original) - assert expanded == original - assert "rh_index_mcp_pitch" not in expanded.output_mappings - with pytest.raises(ValueError, match="unmeasured"): - prepare_standard_result(profile, O6_SOURCE, _digest(O6_SOURCE), expanded) - - -def test_o12_motion_transfer_does_not_invent_passive_pip_zero(): - profile = load_bundled_hand_profile("o12_right_16") - profile = replace(profile, zero=replace(profile.zero, cad_zero_assumptions={ - name: value for name, value in profile.zero.cad_zero_assumptions.items() - if name not in {"pinky_pip", "ring_pip", "thumb_mcp"}})) - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - for name in ("pinky_pip", "ring_pip"): - assert name in expanded.output_mappings - assert name not in expanded.zero_offsets_rad - assert name not in profile.zero.cad_zero_assumptions - with pytest.raises(ValueError, match="absolute_geometry.*pinky_pip.*ring_pip"): - prepare_standard_result(profile, O12_SOURCE, _digest(O12_SOURCE), expanded) - - -def test_o12_declared_cad_baselines_do_not_become_measured_zero_evidence(): - profile = load_bundled_hand_profile("o12_right_16") - fit = expand_profile_transfers(profile, _frozen_fit(profile)) - for name in ("thumb_mcp", "pinky_pip", "ring_pip"): - assert name in profile.zero.cad_zero_assumptions - assert name not in profile.zero.direct_zero_joints - assert fit.zero_offsets_rad[name] == 0 - assert fit.zero_method_by_joint[name] == "assumed_source_cad_zero" - assert fit.command_mappings["ring_pip"].transferred_from == "pinky_pip" - assert "ring_pip" not in fit.joint_zero_references - - -def test_passive_pip_zero_uses_separate_datums_while_motion_is_copied(): - profile = load_bundled_hand_profile("o12_right_16") - profile = replace(profile, zero=replace(profile.zero, cad_zero_assumptions={ - name: value for name, value in profile.zero.cad_zero_assumptions.items() - if name not in {"pinky_pip", "ring_pip"}})) - geometry = dict(profile.zero.known_baseline_geometry, - pinky_pip=KnownBaselineGeometry(.07, .001, "synthetic_pinky_datum")) - profile = replace(profile, zero=replace(profile.zero, known_baseline_geometry=geometry)) - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - assert expanded.zero_offsets_rad["pinky_pip"] == .07 - assert "ring_pip" not in expanded.zero_offsets_rad - geometry = dict(geometry, ring_pip=KnownBaselineGeometry(.11, .001, "synthetic_ring_datum")) - profile = replace(profile, zero=replace(profile.zero, known_baseline_geometry=geometry)) - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - assert expanded.zero_offsets_rad["pinky_pip"] == .07 - assert expanded.zero_offsets_rad["ring_pip"] == .11 - assert expanded.zero_method_by_joint["ring_pip"] == "independent_known_baseline_geometry" - assert "ring_pip" not in profile.zero.transferred_zero_sources - assert expanded.command_mappings["ring_pip"].angle_rad == expanded.command_mappings["pinky_pip"].angle_rad - assert "ring_pip" not in expanded.joint_zero_references - - -def test_o6_urdf_keeps_terminal_origins_and_each_recipient_link_geometry(tmp_path): - profile = _geometry_only_o6_profile() - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - source_hash = _digest(O6_SOURCE) - prepared = prepare_standard_result(profile, O6_SOURCE, source_hash, expanded) - destination = prepared.plan.write(O6_SOURCE, tmp_path/"corrected.urdf") - before, after = ET.parse(O6_SOURCE).getroot(), ET.parse(destination).getroot() - before_joints = {j.get("name"): j for j in before.findall("joint")} - after_joints = {j.get("name"): j for j in after.findall("joint")} - for name in profile.zero.cad_zero_assumptions: - assert expanded.zero_offsets_rad[name] == 0. - assert after_joints[name].find("origin").attrib == before_joints[name].find("origin").attrib - assert after_joints[name].find("mimic").get("joint") == before_joints[name].find("mimic").get("joint") - assert prepared.plan.mimic_policy == "preserve_source_mimic" - assert after_joints[name].find("mimic").attrib == before_joints[name].find("mimic").attrib - for name, original in before_joints.items(): - current = after_joints[name] - for tag in ("parent", "child", "axis"): - assert current.find(tag).attrib == original.find(tag).attrib - assert current.find("origin").get("xyz") == original.find("origin").get("xyz") - assert [ET.tostring(link) for link in before.findall("link")] == [ET.tostring(link) for link in after.findall("link")] - assert after_joints["rh_index_mcp_pitch"].find("origin").get("xyz") != after_joints["rh_pinky_mcp_pitch"].find("origin").get("xyz") - assert _digest(O6_SOURCE) == source_hash - - -@pytest.mark.parametrize("field", ["output_mappings", "command_mappings"]) -def test_changed_recipient_curve_cannot_pass_as_exact_transfer(field): - profile = load_bundled_hand_profile("o6_right_8") - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - mappings = dict(getattr(expanded, field)) - target = "rh_index_dip" - mappings[target] = replace(mappings[target], angle_rad=tuple(v*1.01 for v in mappings[target].angle_rad)) - with pytest.raises(ValueError, match="differs_from_declared_donor"): - prepare_standard_result(profile, O6_SOURCE, _digest(O6_SOURCE), replace(expanded, **{field: mappings})) - - -def test_transferred_curve_cannot_expand_recipient_mechanical_range(tmp_path): - # Without an explicit nominal-envelope declaration, the recipient's own - # CAD upper bound remains a mandatory physical limit after zero conversion. - profile = _geometry_only_o6_profile() - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - source = tmp_path/"narrow_recipient.urdf" - tree = ET.parse(O6_SOURCE) - tree.getroot().find("joint[@name='rh_index_mcp_pitch']/limit").set("upper", "0.2") - tree.write(source, encoding="utf-8", xml_declaration=True) - with pytest.raises(ValueError, match="range.*(mechanical|physical)|mapping exceeds"): - prepare_standard_result(profile, source, _digest(source), expanded) - - -def test_metadata_only_transfer_fit_cannot_authorize_measured_motion_envelopes(): - profile = load_bundled_hand_profile("o6_right_8") - expanded = expand_profile_transfers(profile, _frozen_fit(profile)) - assert profile.urdf_limit_policies - with pytest.raises(ValueError, match="motion_range_requires_training_provenance"): - prepare_standard_result(profile, O6_SOURCE, _digest(O6_SOURCE), expanded) - - -@pytest.mark.parametrize("problem", ["missing_donor", "wrong_channel", "already_expanded"]) -def test_invalid_donor_result_cannot_be_transferred(problem): - profile = load_bundled_hand_profile("o6_right_8") - fit = _frozen_fit(profile) - mappings = dict(fit.command_mappings) - if problem == "missing_donor": - mappings.pop("rh_pinky_dip") - fit = replace(fit, command_mappings=mappings) - elif problem == "wrong_channel": - mappings["rh_pinky_dip"] = replace(mappings["rh_pinky_dip"], motor_index=0) - fit = replace(fit, command_mappings=mappings) - else: - fit = expand_profile_transfers(profile, fit) - with pytest.raises(ValueError, match="motion_transfer"): - expand_profile_transfers(profile, fit) - - -@pytest.mark.parametrize("policy,joint,reason", [ - ("transferred", "rh_index_mcp_pitch", "transferred_zero_differs_from_declared_donor"), - ("assumed", "rh_pinky_dip", "assumed_cad_zero_differs_from_profile"), - ("known", "rh_thumb_ip", "known_baseline_geometry_differs_from_profile"), -]) -@pytest.mark.parametrize("changed", ["offset", "method"]) -def test_result_plan_rejects_zero_parameters_that_change_the_declared_evidence(policy, joint, reason, changed): - profile = _geometry_only_o6_profile() - if policy == "known": - assumptions = {name: value for name, value in profile.zero.cad_zero_assumptions.items() if name != joint} - geometry = dict(profile.zero.known_baseline_geometry, - **{joint: KnownBaselineGeometry(0., .001, "synthetic_independent_datum")}) - profile = replace(profile, zero=replace(profile.zero, - known_baseline_geometry=geometry, cad_zero_assumptions=assumptions)) - fit = expand_profile_transfers(profile, _frozen_fit(profile)) - # Verify the valid starting point, then change one field only. A copied - # dynamic curve must not authorize a new geometric zero or evidence label. - prepare_standard_result(profile, O6_SOURCE, _digest(O6_SOURCE), fit) - if changed == "offset": - offsets = dict(fit.zero_offsets_rad) - offsets[joint] += .04 - fit = replace(fit, zero_offsets_rad=offsets) - else: - methods = dict(fit.zero_method_by_joint) - methods[joint] = "urdf_serial_axis_geometry" - fit = replace(fit, zero_method_by_joint=methods) - with pytest.raises(ValueError, match=reason): - prepare_standard_result(profile, O6_SOURCE, _digest(O6_SOURCE), fit) - - -def test_public_fitting_rejects_conflicting_zero_sources_before_reading_inputs(tmp_path): - profile = load_bundled_hand_profile("o6_right_8") - geometry = dict(profile.zero.known_baseline_geometry, - rh_pinky_dip=KnownBaselineGeometry(.03, .001, "synthetic_independent_datum")) - profile = replace(profile, zero=replace(profile.zero, known_baseline_geometry=geometry)) - with pytest.raises(ProfileValidationError, match="CAD zero assumption conflicts.*rh_pinky_dip"): - fit_profile_calibration(profile, tmp_path/"nonexistent.urdf", {}) diff --git a/src/linkerhand_calibration/test/test_native_position_feedback.py b/src/linkerhand_calibration/test/test_native_position_feedback.py deleted file mode 100644 index 6690c79..0000000 --- a/src/linkerhand_calibration/test/test_native_position_feedback.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Command completion is not an unmeasured command-to-encoder calibration.""" - -from dataclasses import replace - -import numpy as np -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.adapters import HardwareHealth -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.safety import SafetyPolicy, SafetySample -from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit - - -def feedback_profile(): - """Exercise the production O30 contract, not a test-only override.""" - profile = load_bundled_hand_profile("o30_right_18", scope="full") - assert profile.acquisition.motion_observation == "feedback" - assert profile.command_based_release - assert not profile.acquisition.feedback_travel_matches_command - return profile - - -@pytest.mark.parametrize("phase", ["baseline", "prepare", "steady"]) -def test_endpoint_dead_zone_does_not_create_an_encoder_target(phase): - profile = feedback_profile() - start = list(profile.command.baseline_values) - start[15] = 11 - feedback = start.copy() - feedback[15] = 6 # Observed at native command zero; never relabel this as zero. - motion = MotionCommand(phase, profile.command.baseline_values, 200., command_index=15) - segment = MotionExecution(profile, motion, initial_command=start, - initial_feedback=feedback, now=0., identity="endpoint") - safety = SafetyPolicy(profile) - for stamp in range(150): - now = stamp / 30 - command = segment.sample(now) - segment.observe(feedback, stamp=stamp, now=now) - assert safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)).safe - assert command[15] == 0 and feedback[15] == 6 - assert segment.arrived(now) - assert not segment.goals - - -def test_declared_common_scale_keeps_stall_protection(): - profile = feedback_profile() - profile = replace(profile, acquisition=replace(profile.acquisition, feedback_travel_matches_command=True)) - start = list(profile.command.baseline_values) - start[15] = 100 - segment = MotionExecution(profile, MotionCommand("prepare", profile.command.baseline_values, 200.), - initial_command=start, initial_feedback=start, now=0., identity="stopped") - safety = SafetyPolicy(profile) - decisions = [] - for stamp in range(150): - now = stamp / 30 - command = segment.sample(now) - segment.observe(start, stamp=stamp, now=now) - decisions.append(safety.evaluate(SafetySample(now, now, command, tuple(start), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals))) - assert any(result.code == "mechanical_stall" for result in decisions) - assert not segment.arrived(now) - - -def test_unmapped_full_return_cannot_finish_without_real_feedback_motion(): - profile = feedback_profile() - start = list(profile.command.baseline_values) - start[0] = 253 - segment = MotionExecution(profile, MotionCommand("baseline", profile.command.baseline_values, 200.), - initial_command=start, initial_feedback=start, now=0., identity="recorded_blocked_return") - safety = SafetyPolicy(profile) - stopped_at = None - requested_at = None - for stamp in range(900): - now = stamp / 30 - command = segment.sample(now) - segment.observe(start, stamp=stamp, now=now) - if segment.motion_expected and requested_at is None: - requested_at = now - result = safety.evaluate(SafetySample(now, now, command, tuple(start), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)) - if not result.safe and stopped_at is None: - assert result.code == "mechanical_stall" - stopped_at = now - assert stopped_at is not None and requested_at is not None - expected_stop = requested_at + profile.acquisition.stall_timeout_seconds - assert expected_stop <= stopped_at <= expected_stop + 1/30 - assert not segment.arrived(now) - - -def test_unmapped_group_starts_each_motion_watchdog_from_its_own_demand(): - profile = feedback_profile() - start = list(profile.command.baseline_values) - target = start.copy() - target[0], target[2] = 255, 235 - segment = MotionExecution(profile, MotionCommand("prepare", tuple(target), 200.), - initial_command=start, initial_feedback=start, now=0., identity="coupled") - segment.sample(4.) - assert {goal.channel for goal in segment.goals} == {0} - segment.sample(16.) - assert {goal.channel for goal in segment.goals} == {0, 2} - - -@pytest.mark.parametrize("phase", ["steady", "joint_zero"]) -@pytest.mark.parametrize("common_scale", [False, True]) -def test_short_capture_cannot_advance_or_record_without_requested_motion(phase, common_scale): - profile = feedback_profile() - profile = replace(profile, acquisition=replace(profile.acquisition, - feedback_travel_matches_command=common_scale)) - start = list(profile.command.baseline_values) - start[0] = 255 - target, feedback = start.copy(), start.copy() - target[0], feedback[0] = 239, 253 - segment = MotionExecution(profile, MotionCommand(phase, tuple(target), 200., command_index=0), - initial_command=start, initial_feedback=feedback, now=0., identity="blocked_steady_return") - safety = SafetyPolicy(profile) - for stamp in range(300): - now = stamp/30 - command = segment.sample(now) - segment.observe(feedback, stamp=stamp, now=now) - assert not segment.steady_ready(now) - assert not segment.arrived(now) - decision = safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)) - if not decision.safe: - assert decision.code == "mechanical_stall" - assert now < profile.acquisition.stall_timeout_seconds + 2. - break - else: - pytest.fail("short steady segment reset its no-motion watchdog") - - -@pytest.mark.parametrize("sent_zero", [False, True]) -@pytest.mark.parametrize("measured_zero", [3., 10.]) -def test_measured_zero_return_needs_the_observed_encoder_zero(sent_zero, measured_zero): - profile = feedback_profile() - target = list(profile.command.baseline_values) - target[2] = 0 - start, feedback = target.copy(), target.copy() - start[0], feedback[0] = (0 if sent_zero else 255), 253 - segment = MotionExecution(profile, MotionCommand("task_exit", tuple(target), 200., - feedback_targets=((0, measured_zero),)), initial_command=start, initial_feedback=feedback, - now=0., identity="return_before_index_clearance") - safety = SafetyPolicy(profile) - for stamp in range(900): - now = stamp/30 - command = segment.sample(now) - feedback[0] = 240 # Some travel is insufficient for a known zero return. - segment.observe(feedback, stamp=stamp, now=now) - assert not segment.arrived(now) - decision = safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)) - if not decision.safe: - assert decision.code == "mechanical_stall" - break - else: - pytest.fail("a partial return released the avoidance joint") - # Recovery compares to the independently observed zero, not command 0. - for stamp in range(900, 1000): - now = stamp/30 - segment.sample(now) - feedback[0] = measured_zero - segment.observe(feedback, stamp=stamp, now=now) - assert segment.arrived(now) - - -@pytest.mark.parametrize("phase", ["baseline", "prepare", "steady", "joint_zero"]) -def test_ten_unit_tracking_offset_allows_motion_and_stable_capture(phase): - profile = feedback_profile() - start = profile.command.baseline_values - target = list(start) - target[0] = 80 - feedback = list(start) - feedback[0] = 10 - segment = MotionExecution(profile, MotionCommand(phase, tuple(target), 200., command_index=0), - initial_command=start, initial_feedback=feedback, now=0., identity="offset10") - safety = SafetyPolicy(profile) - for stamp in range(240): - now = stamp/30 - command = segment.sample(now) - feedback[0] = command[0]+10 - segment.observe(feedback, stamp=stamp, now=now) - assert safety.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)).safe - assert segment.arrived(now) - assert feedback[0]-command[0] == 10 - if phase in {"steady", "joint_zero"}: - assert segment.steady_ready(now) - - -def test_production_o30_requires_fresh_feedback_before_start_and_during_motion(tmp_path): - from runtime_host_fixture import coordinator_fixture - host, clock = coordinator_fixture(tmp_path, model="o30") - host.parameters = replace(host.parameters, reuse_camera_calibration=True) - try: - for view in host.profile.vision.view_names: - host.cameras.matrices[view] = np.eye(3) - host.cameras.image_sizes[view] = (640,480) - host.cameras.info_received_at[view] = clock.now - host.cameras.detections_received_at[view] = clock.now - host.tick() - assert not host.start().success - host.receive_feedback(host.command_names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - assert host.start().success - clock.now += 1.01 - host.tick() - assert host.state == "PAUSED" - assert "feedback_stale" in host.reason - assert host.visual_motion is None - finally: - host.close() - - -def test_task_exit_binds_its_own_zero_before_releasing_index_clearance(): - from types import SimpleNamespace - from linkerhand_calibration.runtime.execution import SessionExecution - from linkerhand_calibration.runtime.session import CalibrationPhase - profile = feedback_profile() - driver = SessionExecution(profile) - task = profile.motion.tasks[0] - zero_command = profile.motion.joint_zero_references["thumb_cmc_roll"].command - observed = list(zero_command) - observed[0] = 3. - driver.zero_references["thumb_cmc_roll"] = SimpleNamespace(task_key=task.key, channel=0, - samples=(SimpleNamespace(command=zero_command, feedback=tuple(observed)),)) - driver._entered_task = task.key - driver.session.phase = CalibrationPhase.PREPARE - driver.session._unit_index = next(i for i, unit in enumerate(driver.session.engine.scan_units()) - if unit.task_key == profile.motion.tasks[1].key) - # The command was already zero, but this provides no arrival evidence. - first = driver.motion(zero_command) - assert first.phase == "task_exit" and first.target[2] == 0 - assert first.feedback_targets == ((0, 3.),) - assert driver.motion(zero_command) is first # Nothing releases the second waypoint automatically. - driver.motion_complete() - second = driver.motion(first.target) - assert second.phase == "task_exit" and second.target[2] == 255 - assert not second.feedback_targets # A different complete pose cannot borrow the zero. - - -def test_capture_before_motion_completion_fix_is_not_resumable(): - from linkerhand_calibration.runtime.engine import capture_schedule_version - from linkerhand_calibration.core.domain.profile import ACQUISITION_POLICY_VERSION - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - profile = feedback_profile() - engine = CalibrationEngine(profile) - header = dict(profile_id=profile.key.profile_id, acquisition_policy_version=ACQUISITION_POLICY_VERSION, - pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, - capture_schedule_version="unified_schedule_v4_segmented_motion") - assert not engine.resume_compatible(header) - header["capture_schedule_version"] = capture_schedule_version(profile) - assert engine.resume_compatible(header) - - -def test_unmoving_encoder_cannot_pass_sweep_quality(): - profile = feedback_profile() - unit = CalibrationEngine(profile).scan_units()[0] - rows = [dict(joint="thumb_cmc_roll", view="front", task_name=unit.task_key, - cycle=unit.cycle, direction=unit.direction, attempt=1, image_stamp_ns=stamp, - sample_phase="sweep", feedback_u8=6.) for stamp in range(1, 100)] - quality = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={}) - assert not quality.passed - assert any("span" in reason or "bins" in reason for reason in quality.failures) - - -def test_independent_feedback_must_settle_before_preparation_completes(): - profile = feedback_profile() - start = profile.command.baseline_values - target = list(start) - target[0] = 40 - segment = MotionExecution(profile, MotionCommand("prepare", tuple(target), 200.), - initial_command=start, initial_feedback=start, now=0., identity="unsettled") - for stamp in range(300): - now = stamp / 30 - segment.sample(now) - feedback = list(start) - feedback[0] = 40 + (3 if stamp % 2 else -3) - segment.observe(feedback, stamp=stamp, now=now) - assert not segment.arrived(now) - - -def _observed_sweep(unit, joint, *, missing_command=None): - return [dict(joint=joint, view="front", task_name=unit.task_key, - cycle=unit.cycle, direction=unit.direction, attempt=1, image_stamp_ns=command + 1, - segment_key=unit.segment_key, sample_phase="sweep", command_u8=float(command), - feedback_u8=3. + 247. * command / 255.) - for command in range(256) if command != missing_command] - - -def test_native_encoder_endpoints_are_not_command_endpoints(): - profile = feedback_profile() - unit = CalibrationEngine(profile).scan_units()[0] - rows = _observed_sweep(unit, "thumb_cmc_roll") - result = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={}, include_steady=False) - assert result.passed, result.failures - metrics = next(iter(result.metrics.values())) - assert metrics["input_span"] == 1. - assert metrics["coverage_input_domain"] == "command_u8" - assert metrics["feedback_motion"]["observed_span"] == pytest.approx(247.) - assert metrics["endpoint_observation_domain"] == "command" - assert metrics["endpoint_observed_minimum_01"] == 0. - assert metrics["endpoint_observed_maximum_01"] == 1. - assert (rows[0]["feedback_u8"], rows[-1]["feedback_u8"]) == (3., 250.) - common = replace(profile, acquisition=replace(profile.acquisition, - feedback_travel_matches_command=True)) - assert any("endpoint_observation" in failure for failure in - evaluate_capture_unit(common, unit, 1, rows, first_cycle_spans={}).failures) - - -@pytest.mark.parametrize("missing", ["command", "endpoint_image", "feedback_motion"]) -def test_native_endpoint_gate_requires_both_command_observation_and_actual_travel(missing): - profile = feedback_profile() - unit = CalibrationEngine(profile).scan_units()[0] - rows = _observed_sweep(unit, "thumb_cmc_roll") - if missing == "command": - for row in rows: - row.pop("command_u8") - elif missing == "endpoint_image": - rows = [row for row in rows if 10 <= row["command_u8"] <= 245] - else: - for row in rows: - row["feedback_u8"] = 6. - result = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={}) - assert not result.passed - expected = "feedback_motion_feedback_span" if missing == "feedback_motion" else "endpoint_observation" - assert any(expected in failure for failure in result.failures) - - -def test_group_endpoint_observations_belong_to_each_moving_channel(): - profile = feedback_profile() - unit = next(unit for unit in CalibrationEngine(profile).scan_units() - if unit.segment_key == "linked_increasing") - rows = [] - for finger in ("index", "middle", "ring", "pinky"): - selected = _observed_sweep(unit, finger + "_mcp_roll") - if finger == "middle": - for row in selected: - row["command_u8"] = 80 + 175 * row["command_u8"] / 255. - row["feedback_u8"] = row["command_u8"] - elif finger == "index": - selected = [row for row in selected if row["command_u8"] < 245] - rows.extend(selected) - result = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={}) - failures = [failure for failure in result.failures if "endpoint_observation" in failure] - assert len(failures) == 1 and "index_mcp_roll" in failures[0] - - -@pytest.mark.parametrize("offset", [-10, 10]) -def test_each_group_segment_covers_commands_and_its_own_biased_feedback(offset): - from linkerhand_calibration.core.domain.motion_path import task_segment - from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint - profile = feedback_profile() - task = next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - for unit in (u for u in CalibrationEngine(profile).scan_units() if u.task_key == task.key and u.cycle == 0): - segment = task_segment(task, unit.segment_key) - rows = [] - for joint in segment.joints: - channel = channel_for_joint(profile, joint) - for i, progress in enumerate(np.linspace(0,1,256)): - start, end = dict(segment.start_commands)[channel], dict(segment.end_commands)[channel] - command = round(start+(end-start)*progress) - rows.append(dict(joint=joint, view="front", task_name=task.key, cycle=unit.cycle, - direction=unit.direction, segment_key=unit.segment_key, attempt=1, - image_stamp_ns=i+1, sample_phase="sweep", command_u8=command, - feedback_u8=max(0,min(255,command+offset)))) - quality = evaluate_capture_unit(profile,unit,1,rows,first_cycle_spans={},include_steady=False) - assert quality.passed, (segment.key, quality.failures) - assert all(m['coverage_input_domain']=='command_u8' and m['input_span']==1. - and m['feedback_motion']['observed_span']>0 for m in quality.metrics.values()) - missing_joint = segment.joints[0] - stuck = [dict(row,feedback_u8=77.) if row['joint']==missing_joint else row for row in rows] - quality = evaluate_capture_unit(profile,unit,1,stuck,first_cycle_spans={},include_steady=False) - assert not quality.passed - assert all(missing_joint in failure for failure in quality.failures) - - -def test_small_encoder_noise_cannot_be_normalized_into_full_motion(): - profile = feedback_profile() - unit = CalibrationEngine(profile).scan_units()[0] - rows = _observed_sweep(unit, "thumb_cmc_roll") - for row in rows: - row['feedback_u8'] = 6.+row['command_u8']/255. - quality = evaluate_capture_unit(profile,unit,1,rows,first_cycle_spans={},include_steady=False) - assert not quality.passed - assert any('below_motion_resolution' in reason for reason in quality.failures) diff --git a/src/linkerhand_calibration/test/test_native_rotation_curve.py b/src/linkerhand_calibration/test/test_native_rotation_curve.py deleted file mode 100644 index 65fd756..0000000 --- a/src/linkerhand_calibration/test/test_native_rotation_curve.py +++ /dev/null @@ -1,72 +0,0 @@ -import math -from dataclasses import replace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.rotation_curve import RotationObservation, fit_rotation_curve - - -def observations(unit="rad", slip_cycle=None, hysteresis=0.024): - # Independent generator: arbitrary fixed Tag installation on both links. - parent = Rotation.from_euler("xyz", (0.27, -0.18, 0.31)) - child = Rotation.from_euler("xyz", (-0.16, 0.24, -0.21)) - rows = [] - for cycle in range(4): - for direction in ("increasing", "decreasing"): - for index, phase in enumerate(np.linspace(0, 1, 100)): - angle = 0.13 + 0.9*phase + 0.08*phase**2 - angle += hysteresis/2 if direction == "increasing" else -hysteresis/2 - q = parent.inv() * Rotation.from_euler("z", angle) * child - if cycle == slip_cycle: - q = q * Rotation.from_euler("x", math.radians(5)) - value = -0.94 + 0.89*phase if unit == "rad" else 255*phase - rows.append(RotationObservation(f"{cycle}/{direction}/{index}", value, - tuple(q.as_quat()), cycle, direction)) - return rows - - -@pytest.mark.parametrize("unit", ["rad", "u8"]) -def test_native_domain_recovers_motion_with_frozen_arbitrary_tag_mounts(unit): - rows = observations(unit) - fit = fit_rotation_curve([row for row in rows if row.cycle < 3], - input_domain="feedback_" + unit, sdk_to_joint_direction=1) - assert max(fit.holdout_errors([row for row in rows if row.cycle == 3])) < math.radians(0.01) - assert len(fit.knots) == 65 - if unit == "rad": - assert fit.knots[0] == -0.94 - assert fit.knots[-1] == pytest.approx(-0.05) - assert np.max(np.subtract(fit.increasing_rad, fit.decreasing_rad)) == pytest.approx(0.024, abs=1e-9) - - -def test_fourth_cycle_tag_slip_cannot_be_absorbed_into_a_new_reference(): - rows = observations(slip_cycle=3) - fit = fit_rotation_curve(rows[:600], input_domain="feedback_rad", sdk_to_joint_direction=1) - assert min(fit.holdout_errors(rows[600:])) > math.radians(4.9) - - -def test_holdout_never_changes_training_domain_or_axis(): - rows = observations() - with pytest.raises(ValueError, match="partition"): - fit_rotation_curve(rows, input_domain="feedback_rad", sdk_to_joint_direction=1) - fit = fit_rotation_curve(rows[:600], input_domain="feedback_rad", sdk_to_joint_direction=1) - with pytest.raises(ValueError, match="outside"): - fit.holdout_errors([replace(rows[-1], input_value=0.1)]) - with pytest.raises(ValueError, match="overlaps"): - fit.holdout_errors([replace(rows[-1], sample_id=rows[0].sample_id)]) - - -def test_relative_tag_reference_is_not_published_as_cad_zero(): - rows = observations() - fit = fit_rotation_curve(rows[:600], input_domain="feedback_rad", sdk_to_joint_direction=-1) - assert fit.decreasing_rad[-1] < fit.decreasing_rad[0] - # The primitive exposes a measurement reference, not a fabricated zero. - assert not hasattr(fit, "zero_offset_rad") - - -def test_repeatable_hysteresis_keeps_two_branches_and_passes_independent_holdout(): - rows = observations(hysteresis=math.radians(4.2)) - fit = fit_rotation_curve(rows[:600], input_domain="feedback_rad", sdk_to_joint_direction=1) - assert np.max(np.subtract(fit.increasing_rad, fit.decreasing_rad)) == pytest.approx(math.radians(4.2)) - assert max(fit.holdout_errors(rows[600:])) < math.radians(0.01) diff --git a/src/linkerhand_calibration/test/test_o12_axis_residual_policy.py b/src/linkerhand_calibration/test/test_o12_axis_residual_policy.py deleted file mode 100644 index 7585f22..0000000 --- a/src/linkerhand_calibration/test/test_o12_axis_residual_policy.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Observable line residuals must not be confused with axial PnP drift.""" - -import inspect -import math -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting.spatial import ( - _fit_axis_point_from_pose_trajectory, fit_joint_axis_measurement, -) - - -def axis_fit(*, axial_drift=0., transverse_drift=0., separate=False): - axis = np.array([0., 0., 1.]) - point = np.array([.012, -.017, 0.]) - reference = np.array([.047, .009, .02]) - rows = [] - for index, angle in enumerate(np.linspace(0., 1.2, 128)): - rotation = Rotation.from_rotvec(axis * angle) - translation = point + rotation.apply(reference - point) - translation += axis * axial_drift * math.sin(angle * 3.) - translation[0] += transverse_drift * math.sin(angle * 11.) - rows.append({ - 'command_u8': 255 - 2 * index, 'direction': 'decreasing', - 'relative_translation_xyz_m': translation.tolist(), - 'relative_quaternion_xyzw': rotation.as_quat().tolist(), - }) - diagnostics = {} - fitted, rms, _ = _fit_axis_point_from_pose_trajectory( - rows, zero_command_u8=255, axis_parent_xyz=axis, - angle_axis_parent_xyz=axis, phase_reference_point_parent_xyz=point, - view_normal_common_xyz=None, canonical_zero_direction='decreasing', - allow_axial_translation=separate, residual_diagnostics=diagnostics, - ) - return fitted, rms, diagnostics - - -def test_axial_component_is_reported_but_not_used_as_transverse_fit_error(): - baseline, baseline_rms, _ = axis_fit(separate=True) - observed, rms, diagnostic = axis_fit(axial_drift=.015, separate=True) - assert observed == pytest.approx(baseline, abs=1.e-9) - assert rms == pytest.approx(baseline_rms, abs=1.e-9) - assert diagnostic['axial_rms_m'] > .005 - assert diagnostic['raw_rms_m'] > rms - assert diagnostic['transverse_rms_m'] == pytest.approx(rms, abs=1.e-9) - - -def test_transverse_model_error_is_not_discarded_by_projection(): - _, rms, diagnostic = axis_fit(transverse_drift=.01, separate=True) - assert rms > .0015 - assert diagnostic['transverse_rms_m'] > .0015 - - -def test_legacy_default_does_not_silently_enable_projection(): - assert inspect.signature(fit_joint_axis_measurement).parameters[ - 'separate_axial_residual'].default is False - _, rms, diagnostic = axis_fit(axial_drift=.015) - assert rms > .0015 - assert diagnostic['raw_rms_m'] == pytest.approx(rms) diff --git a/src/linkerhand_calibration/test/test_o12_full_hand_zero.py b/src/linkerhand_calibration/test/test_o12_full_hand_zero.py deleted file mode 100644 index e15bfd3..0000000 --- a/src/linkerhand_calibration/test/test_o12_full_hand_zero.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Non-zero recovery through G20 geometry, not zero-only CAD fixtures.""" - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import ( - CALIBRATED_ACTIVE_JOINTS, - GEOMETRIC_ZERO_JOINTS, - STATIC_ZERO_EXCLUDED_JOINTS, - build_typed_profile, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.zero import ( - AXIS_JOINTS, PHASE_PARENT, ZERO_OBSERVER, O12SpatialZeroError, - full_hand_zero_profile, motor_index, solve_full_hand_zero, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.kinematics import PASSIVE_SDK_SOURCE_BY_JOINT - -from test_o12_right_profile import SOURCE_URDF, _synthetic_records - - -def pose(t): - return {"translation_xyz_m": t[:3, 3].tolist(), - "quaternion_xyzw": Rotation.from_matrix(t[:3, :3]).as_quat().tolist()} - - -def transform(xyz, rpy): - t = np.eye(4) - t[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() - t[:3, 3] = xyz - return t - - -def observations(truth, *, coupled_passive=False): - model = UrdfKinematicModel(SOURCE_URDF) - common = transform([.03, -.02, .6], [.15, -.2, .3]) - angle = tuple(np.linspace(.7, 0., 256)) - curve = JointCurveFit(angle, angle, angle, {}, 0., 0., {}) - rows = {} - for i, name in enumerate(AXIS_JOINTS): - mount = transform([.013, -.011, .024], [.21, -.1 * i, .3]) - # Passive trajectories live in the parent Tag frame. A constant - # arbitrary mount must not be mistaken for an encoder zero. - parent = common @ transform([-.01, .015, -.02], [-.2, .11, -.15]) - rows[name] = [] - for cycle in range(4): - for direction, indices in (("decreasing", range(255, -1, -4)), - ("increasing", range(3, 256, 4))): - for index in indices: - state = [255.] * 12 - # A passive axis is independently rotated here to test - # the spatial kernel, not the model's SDK adapter. - if name in CALIBRATED_ACTIVE_JOINTS: - state[motor_index(name)] = index - joint_angles = {name: angle[index]} - sample_parent = parent - if coupled_passive and name in PASSIVE_SDK_SOURCE_BY_JOINT: - state[motor_index(name)] = index - joint_angles[PASSIVE_SDK_SOURCE_BY_JOINT[name]] = angle[index] - sample_parent = common @ model.link_transform( - PHASE_PARENT[name], zero_offsets=truth, joint_angles=joint_angles, - independent_mimic_angles=True, - ) @ transform([.01, .004, .014], [.2, -.1, .3]) - child = common @ model.link_transform( - name, zero_offsets=truth, joint_angles=joint_angles, - independent_mimic_angles=True, - ) @ mount - relative = np.linalg.inv(sample_parent) @ child - rows[name].append({ - "cycle": cycle, "direction": direction, "command_u8": index, - "state_u8": state, - "relative_quaternion_xyzw": pose(relative)["quaternion_xyzw"], - "relative_translation_xyz_m": relative[:3, 3].tolist(), - "parent_pose_common": pose(sample_parent), "child_pose_common": pose(child), - "view_normal_common_xyz": common[:3, 0].tolist(), - "camera_center_common_xyz_m": [0., 0., 0.], - }) - return rows, {name: curve for name in AXIS_JOINTS} - - -def test_profile_authorizes_every_observed_active_zero_without_endpoint_assumptions(): - zero = build_typed_profile().zero - assert set(zero.direct_zero_joints) == GEOMETRIC_ZERO_JOINTS == set(ZERO_OBSERVER) - assert CALIBRATED_ACTIVE_JOINTS - GEOMETRIC_ZERO_JOINTS == STATIC_ZERO_EXCLUDED_JOINTS - assert STATIC_ZERO_EXCLUDED_JOINTS == {"thumb_mcp"} - assert not zero.mechanical_endpoint_joints - assert zero.cad_frozen_joints & CALIBRATED_ACTIVE_JOINTS == STATIC_ZERO_EXCLUDED_JOINTS - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import get_right_19_thumb_zero_profile - assert not get_right_19_thumb_zero_profile().accept_validated_zero_in_confidence_interval - assert full_hand_zero_profile().accept_validated_zero_in_confidence_interval - assert full_hand_zero_profile().hand.stable_cross_view_cone_bias - model = UrdfKinematicModel(SOURCE_URDF) - for child, parent in PHASE_PARENT.items(): - a, _ = model.axis_line(child, zero_offsets={}, joint_angles={}) - b, _ = model.axis_line(parent, zero_offsets={}, joint_angles={}) - assert abs(float(a @ b)) == pytest.approx(1., abs=1.e-8), (parent, child) - - -@pytest.fixture(scope="module") -def full_solution(): - truth = {name: (.04 if i % 2 else -.06) for i, name in enumerate(ZERO_OBSERVER)} - rows, curves = observations(truth) - result = solve_full_hand_zero(SOURCE_URDF, rows, curves) - return truth, result - - -def test_all_ten_observable_nonzero_offsets_recovered_with_arbitrary_mounts(full_solution): - truth, result = full_solution - assert result.passed - assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4) - assert len(result.axis_residual_diagnostics) == 4 * len(AXIS_JOINTS) - assert all(item["axial_component_separated"] - for item in result.axis_residual_diagnostics.values()) - - -def test_coupled_passive_parent_motion_is_not_mistaken_for_a_static_zero(): - truth = {name: .04 for name in ZERO_OBSERVER} - rows, curves = observations(truth, coupled_passive=True) - result = solve_full_hand_zero(SOURCE_URDF, rows, curves) - assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4) - - -@pytest.mark.parametrize("move_base,remount", [(False, False), (True, False), - (False, True), (True, True)]) -def test_end_on_phase_is_invariant_to_pre_capture_base_and_tag_changes(move_base, remount): - # Previously the synthetic camera faced CAD X, perpendicular to the - # finger Y axes; that never exercised the real side-camera image branch. - truth = {name: .04 for name in ZERO_OBSERVER} - rows, curves = observations(truth, coupled_passive=True) - common = transform([.03, -.02, .6], [.15, -.2, .3]) - normal = common[:3, :3] @ np.array([.1, .99, .1]) - normal /= np.linalg.norm(normal) - body = transform([.03, -.02, .01], [.07, -.08, .05]) if move_base else np.eye(4) - parent_mount = transform([-.005, .004, .012], [-.1, .05, -.11]) if remount else np.eye(4) - child_mount = transform([.008, -.005, .011], [.1, -.12, .2]) if remount else np.eye(4) - def matrix(payload): - t = np.eye(4) - t[:3, :3] = Rotation.from_quat(payload["quaternion_xyzw"]).as_matrix() - t[:3, 3] = payload["translation_xyz_m"] - return t - for samples in rows.values(): - for row in samples: - parent = body @ matrix(row["parent_pose_common"]) @ parent_mount - child = body @ matrix(row["child_pose_common"]) @ child_mount - relative = np.linalg.inv(parent) @ child - row.update(parent_pose_common=pose(parent), child_pose_common=pose(child), - relative_translation_xyz_m=relative[:3, 3].tolist(), - relative_quaternion_xyzw=Rotation.from_matrix(relative[:3, :3]).as_quat().tolist(), - view_normal_common_xyz=normal.tolist()) - result = solve_full_hand_zero(SOURCE_URDF, rows, curves) - assert result.passed - assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4) - - -def test_active_parallel_axis_direction_bias_does_not_become_a_zero(): - truth = {name: .04 for name in ZERO_OBSERVER} - rows, curves = observations(truth, coupled_passive=True) - common = transform([.03, -.02, .6], [.15, -.2, .3]) - normal = common[:3, :3] @ np.array([.1, .99, .1]) - normal /= np.linalg.norm(normal) - for samples in rows.values(): - for row in samples: - row["view_normal_common_xyz"] = normal.tolist() - bias = Rotation.from_euler("xyz", [.06, -.04, .07]) - for name in ("index_pip", "middle_pip"): - ref = Rotation.from_quat(rows[name][0]["relative_quaternion_xyzw"]) - for row in rows[name]: - observed = Rotation.from_quat(row["relative_quaternion_xyzw"]) - delta = observed * ref.inv() - changed = bias * delta * bias.inv() * ref - row["relative_quaternion_xyzw"] = changed.as_quat().tolist() - parent = Rotation.from_quat(row["parent_pose_common"]["quaternion_xyzw"]) - row["child_pose_common"]["quaternion_xyzw"] = (parent * changed).as_quat().tolist() - result = solve_full_hand_zero(SOURCE_URDF, rows, curves) - assert result.passed - assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4) - - -def test_rotation_only_data_cannot_be_published_as_full_spatial_calibration(): - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.fitting import fit_o12_session - with pytest.raises(ValueError, match="requires identified common-frame pose observations"): - fit_o12_session(SOURCE_URDF, _synthetic_records(), require_full_hand_spatial_zero=True) - - -def test_small_uncertain_zero_is_kept_only_when_frozen_zero_passes_holdout(): - truth = {name: .04 for name in ZERO_OBSERVER} - rows, curves = observations({**truth, "index_mcp_roll": .003}) - for cycle, value in enumerate((.001, .005, .003, 0.)): - changed, _ = observations({**truth, "index_mcp_roll": value}) - for name in rows: - rows[name] = [r for r in rows[name] if r["cycle"] != cycle] + [ - r for r in changed[name] if r["cycle"] == cycle] - result = solve_full_hand_zero(SOURCE_URDF, rows, curves) - assert result.passed - assert result.direct_offsets_rad["index_mcp_roll"] == 0. - assert result.validation_error_by_joint_rad["index_mcp_pitch"] < 1.e-5 - changed, _ = observations({**truth, "index_mcp_roll": .15}) - for name in rows: - rows[name] = [r for r in rows[name] if r["cycle"] != 3] + [ - r for r in changed[name] if r["cycle"] == 3] - with pytest.raises(O12SpatialZeroError): - solve_full_hand_zero(SOURCE_URDF, rows, curves) - - -def test_independent_fourth_cycle_cannot_define_or_hide_a_bad_zero(): - truth = {name: .04 for name in ZERO_OBSERVER} - rows, curves = observations(truth) - moved, _ = observations({**truth, "index_pip": .18}) - for name in rows: - rows[name] = [r for r in rows[name] if r["cycle"] < 3] + [r for r in moved[name] if r["cycle"] == 3] - with pytest.raises(O12SpatialZeroError, match="spatial zero solve failed") as error: - solve_full_hand_zero(SOURCE_URDF, rows, curves) - assert error.value.diagnostics["passed"] is False - - - - -def test_failed_spatial_solve_writes_evidence_without_replacing_published_result(monkeypatch, tmp_path): - from linkerhand_calibration.runtime.artifacts import finalization - import json - previous = tmp_path / "previous" - previous.mkdir() - pointer = tmp_path / "latest_passed" - pointer.symlink_to(previous, target_is_directory=True) - diagnostic = {"passed": False, "stage": "axis_observation", "joint": "thumb_mcp"} - def fail(*args, **kwargs): - raise O12SpatialZeroError("unreliable geometry", diagnostic) - # This test isolates failure after the image-evidence boundary; frozen - # branch acceptance and rejection are exercised with actual candidates. - monkeypatch.setattr(finalization, "resolve_chain_observations", - lambda rows, **kwargs: (rows, {"status": "resolved"})) - monkeypatch.setattr(finalization, "fit_profile_calibration", fail) - session = tmp_path / "new" - with pytest.raises(O12SpatialZeroError): - finalization.finalize_profile_session(profile=build_typed_profile(), session_dir=session, serial_number="FULL", - source_urdf=SOURCE_URDF, protected_inputs={}, records=[], publish=True) - assert pointer.resolve() == previous - assert not list(session.glob("*.urdf")) - report = json.loads((session / "fit_diagnostics.json").read_text()) - assert not report["passed"] and not report["publication_allowed"] - assert "unreliable geometry" in report["reason"] diff --git a/src/linkerhand_calibration/test/test_o12_observation_resolution.py b/src/linkerhand_calibration/test/test_o12_observation_resolution.py deleted file mode 100644 index 67a30c2..0000000 --- a/src/linkerhand_calibration/test/test_o12_observation_resolution.py +++ /dev/null @@ -1,131 +0,0 @@ -import copy -from pathlib import Path -import numpy as np -import pytest -from scipy.spatial.transform import Rotation as R - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12 import observations as module -from linkerhand_calibration.pnp import SquareTagPose - -SOURCE_URDF = Path(__file__).resolve().parents[1]/"urdf/o12_right/linkerhand_o12_t3_right-0703.urdf" - -def build_data(monkeypatch, *, wrong_holdout=False): - """Ambiguous initial poses: the wrong distal branch has lower image error.""" - rows=[]; pose_lookup={}; idx=0 - camera=R.from_euler('xyz',[.2,-.3,.4]) - mounts=[R.from_euler('xyz',angles) for angles in ([.1,.3,.2],[-.3,.5,-.4],[.2,.6,-.7])] - def payload(rotation,t,error): - return dict(quaternion_xyzw=(camera*rotation).as_quat().tolist(), - translation_xyz_m=(camera.apply(t)+[0,0,1]).tolist(),reprojection_error_px=error) - for cycle in range(4): - for theta in np.linspace(0,.9,24): - idx+=1 - driver=R.from_euler('z',theta) - good=[payload(mounts[0],[0,0,0],.05), - payload(driver*mounts[1],driver.apply([.03,0,0]),.05), - payload(R.from_euler('z',2*theta)*mounts[2],driver.apply([.04,0,0])+R.from_euler('z',2*theta).apply([.02,0,0]),.08)] - bad=payload(R.from_euler('x',2*theta)*R.from_euler('y',.8)*mounts[2],[0,0,0],.01) - bad['translation_xyz_m']=good[2]['translation_xyz_m'] - # Holdout is allowed to disagree; it may never choose the training branch. - if wrong_holdout and cycle==3: - good[2]['quaternion_xyzw']=bad['quaternion_xyzw'] - evidence={'kind':'o12_pnp_candidate_frame','task_name':'thumb_mcp_dip_front','cycle':cycle,'direction':'decreasing','attempt':1, - 'image_stamp_ns':idx,'camera_matrix':np.eye(3).tolist(), - 'camera_matrix_source':module.MATRIX_SOURCE,'tag_size_m':.016,'roles':{}} - for j,role in enumerate(module.THUMB_ROLES): - marker=idx*10+j - candidates=[good[j],good[j]] if j<2 else [bad,good[j]] - pose_lookup[marker]=[SquareTagPose(tuple(p['quaternion_xyzw']),tuple(p['translation_xyz_m']),p['reprojection_error_px']) for p in candidates] - evidence['roles'][role]={'corners_xy':[[marker,0]]*4,'selected':candidates[0], - 'maximum_reprojection_error_px':1.5} - rows.append(evidence) - for j,name in enumerate(('thumb_mcp','thumb_dip')): - pa=evidence['roles'][module.THUMB_ROLES[j]]['selected'] - ch=evidence['roles'][module.THUMB_ROLES[j+1]]['selected'] - q,t=module._relative(pa,ch) - rows.append({'kind':'o12_joint_sample','task_name':'thumb_mcp_dip_front', - 'joint':name,'cycle':cycle,'direction':'decreasing','attempt':1, - 'image_stamp_ns':idx,'parent_pose_common':pa,'child_pose_common':ch, - 'relative_quaternion_xyzw':q.tolist(),'relative_translation_xyz_m':t.tolist()}) - monkeypatch.setattr(module,'solve_square_tag_ippe',lambda xy,**kw:pose_lookup[xy[0][0]]) - return rows - - -def test_complete_training_can_reconsider_wrong_initial_branch(monkeypatch): - rows=build_data(monkeypatch);original=copy.deepcopy(rows) - resolved,report=module.resolve_thumb_observations(rows) - assert rows==original - assert report['status']=='resolved' and report['selected_branches'][2]==1 - assert report['training_frames']==72 and report['holdout_frames']==24 - assert report['changed_joint_rows']>0 - assert resolved!=rows - - -def test_holdout_cannot_change_training_branch_or_scores(monkeypatch): - _,a=module.resolve_thumb_observations(build_data(monkeypatch)) - _,b=module.resolve_thumb_observations(build_data(monkeypatch,wrong_holdout=True)) - assert a['selected_branches']==b['selected_branches'] - assert a['hypotheses']==b['hypotheses'] - - -def test_legacy_projection_is_not_silently_relabelled(monkeypatch): - rows=build_data(monkeypatch) - for r in rows:r.pop('camera_matrix_source',None) - result,report=module.resolve_thumb_observations(rows) - assert report['status']=='legacy_projection_unverified' and result==rows - assert module.resolve_thumb_observations([])[1]['status']=='legacy_no_corners' - - -def test_missing_evidence_cannot_be_silently_dropped(monkeypatch): - rows=build_data(monkeypatch) - rows=[r for r in rows if not(r['kind']=='o12_pnp_candidate_frame' and r['image_stamp_ns']==20)] - with pytest.raises(ValueError,match='lacks evidence'):module.resolve_thumb_observations(rows) - - -def test_other_task_evidence_is_not_mixed_into_thumb(monkeypatch): - rows=build_data(monkeypatch) - rows.append({'kind':'o12_pnp_candidate_frame','task_name':'pinky_chain_side','image_stamp_ns':1}) - assert module.resolve_thumb_observations(rows)[1]['status']=='resolved' - - -def test_partial_external_projection_cannot_finalize_whole_hand(tmp_path): - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.pipeline import finalize_o12_session - with pytest.raises(ValueError,match='diagnostic-only'): - finalize_o12_session(session_dir=tmp_path/'out',serial_number='test',source_urdf=SOURCE_URDF, - protected_inputs={},records=[{'projection_reprocessing_scope':'thumb_only_external_projection'}],publish=False) - - -def test_known_unverified_projection_cannot_publish_even_if_fit_passes(monkeypatch,tmp_path): - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12 import pipeline - from linkerhand_calibration.runtime.artifacts import finalization - def must_not_fit(*args, **kwargs): - raise AssertionError('unverified projection must be rejected before fitting') - monkeypatch.setattr(finalization,'fit_profile_calibration',must_not_fit) - with pytest.raises(ValueError, match='camera_projection_unverified'): - pipeline.finalize_o12_session(session_dir=tmp_path/'out',serial_number='test',source_urdf=SOURCE_URDF, - protected_inputs={},records=[{'kind':'o12_pnp_candidate_frame','task_name':'thumb_mcp_dip_front','image_stamp_ns':1}]) - assert not (tmp_path/'latest_passed').exists() - - -def test_frozen_joint_zero_is_never_reselected_from_training_or_holdout(monkeypatch): - rows = build_data(monkeypatch) - zero = dict(next(r for r in rows if 'joint' in r), kind='joint_zero_sample', - sample_phase='joint_zero', image_stamp_ns=10000, direction=None) - result, report = module.resolve_thumb_observations([zero]+rows) - assert result[0] == zero - assert report['training_frames'] == 72 and report['holdout_frames'] == 24 - - -def test_only_declared_fixed_roles_may_use_a_cached_pose(monkeypatch): - from linkerhand_calibration.core.geometry.candidate_selection import resolve_chain_observations - rows = build_data(monkeypatch) - role = module.THUMB_ROLES[0] - for row in rows: - if 'roles' in row: - row['roles'][role].update(locked_reference=True, corners_xy=None) - kwargs = dict(task_key='thumb_mcp_dip_front', - role_pairs={'thumb_mcp': module.THUMB_ROLES[:2], 'thumb_dip': module.THUMB_ROLES[1:]}, - solve_pose=module.solve_square_tag_ippe) - with pytest.raises(ValueError, match='cached fixed reference'): - resolve_chain_observations(rows, **kwargs) - assert resolve_chain_observations(rows, fixed_roles=(role,), **kwargs)[1]['status'] == 'resolved' diff --git a/src/linkerhand_calibration/test/test_o12_readonly_bridge.py b/src/linkerhand_calibration/test/test_o12_readonly_bridge.py deleted file mode 100644 index 30b362a..0000000 --- a/src/linkerhand_calibration/test/test_o12_readonly_bridge.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Transport tests never construct a hand or open HCAN.""" - -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.runtime.adapters.o12_bridge import ERROR_FIELDS, read_health - - -def test_hardware_health_is_read_only_and_distinguishes_historical_bit(): - reports = [SimpleNamespace(**{name: False for name in ERROR_FIELDS}) for _ in range(12)] - reports[1].commu_except = True - reads = [] - hand = SimpleNamespace( - get_all_control_modes=lambda: reads.append("mode") or [0]*12, - get_all_error_reports=lambda: reads.append("errors") or reports) - value = read_health(hand) - assert reads == ["mode", "errors"] - assert value["position_mode"] - assert value["communication_latches"] == [1] - assert not value["active_faults"] - reports[2].overheat = True - assert read_health(hand)["active_faults"] == ["channel=2:overheat"] - - -def test_partial_report_is_not_treated_as_good_health(): - hand = SimpleNamespace(get_all_control_modes=lambda: [0]*12, get_all_error_reports=lambda: []) - with pytest.raises(ValueError, match="incomplete"): - read_health(hand) - - -def test_vendor_package_is_pinned_before_any_import(tmp_path): - from linkerhand_calibration.runtime.adapters.vendor_package import load_vendor_sdk - path = tmp_path/"untrusted.whl" - path.write_bytes(b"not a wheel") - with pytest.raises(ValueError, match="changed"): - load_vendor_sdk(path, "0"*64) diff --git a/src/linkerhand_calibration/test/test_o12_recorded_replay.py b/src/linkerhand_calibration/test/test_o12_recorded_replay.py deleted file mode 100644 index e743258..0000000 --- a/src/linkerhand_calibration/test/test_o12_recorded_replay.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Optional local-data replay: deterministic recovery, NOT physical certification. - -Set O12_REPLAY_RAW and O12_REPLAY_REFERENCE to use a captured session and an -independently reviewed model. The test never publishes or launches hardware. -""" - -import os -from pathlib import Path - -import pytest - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.pipeline import finalize_o12_session, load_o12_raw_samples -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.zero import O12SpatialZeroError -from linkerhand_calibration.product import sha256_file -from linkerhand_calibration.storage import atomic_write_json -from linkerhand_calibration.urdf_comparison import compare_urdfs -from test_o12_right_profile import SOURCE_URDF - - -@pytest.mark.skipif(not os.environ.get("O12_REPLAY_RAW") or not os.environ.get("O12_REPLAY_REFERENCE"), - reason="local raw recording/reference not supplied") -def test_recorded_replay_matches_reviewed_geometry_without_overriding_quality(tmp_path): - raw = Path(os.environ["O12_REPLAY_RAW"]).resolve(strict=True) - reference = Path(os.environ["O12_REPLAY_REFERENCE"]).resolve(strict=True) - output = Path(os.environ.get("O12_REPLAY_OUTPUT", str(tmp_path / "replay"))).resolve() - assert not output.exists(), "use a new output directory" - hashes = {str(p): sha256_file(p) for p in (raw, reference, SOURCE_URDF)} - try: - _, fit, correction = finalize_o12_session( - session_dir=output, serial_number="O12_RIGHT_001", source_urdf=SOURCE_URDF, - protected_inputs=hashes, records=load_o12_raw_samples(raw), publish=False, - ) - candidate = correction.path - assert fit.full_hand_zero_result.passed - except O12SpatialZeroError as error: - assert error.review_fit is not None - assert not error.review_fit.full_hand_zero_result.passed - assert error.review_fit.full_hand_zero_result.failure_reasons - candidate = Path(error.diagnostics["review_urdf"]) - assert "REVIEW_ONLY" in candidate.name - assert not list(output.glob("*calibration.json")) - report = compare_urdfs(reference, candidate) - atomic_write_json(output / "reference_geometry_comparison.json", report) - assert report["maximum_origin_distance_mm"] < 1.e-5 - assert report["maximum_orientation_difference_deg"] < 1.e-5 - assert report["link_elements_identical"] - for ranges in report["active_joint_ranges_rad"].values(): - assert ranges["reference"] == pytest.approx(ranges["candidate"], abs=1.e-10) - assert all(sha256_file(p) == digest for p, digest in hashes.items()) diff --git a/src/linkerhand_calibration/test/test_o12_right_profile.py b/src/linkerhand_calibration/test/test_o12_right_profile.py deleted file mode 100644 index 38dff29..0000000 --- a/src/linkerhand_calibration/test/test_o12_right_profile.py +++ /dev/null @@ -1,465 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.health import ( - assess_o12_error_report, - decoded_faults, - historical_communication_latch_confirmed, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.motion import ( - cosine_position_trajectory_rad, - cosine_ramp_velocity_trajectory_rad, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.kinematics import ( - evaluate_vendor_passive_joint, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.runner import ( - _log_exception_summary, - render_o12_progress_zh, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.runner import _launch_command -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import ( - CALIBRATED_ACTIVE_JOINTS, - COMMAND_NAMES, - EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION, - ENDPOINT_ANCHOR_BY_JOINT, - FEEDBACK_DOMAIN_MARGIN_RAD, - FEEDBACK_LOWER_RAD, - FEEDBACK_UPPER_RAD, - FORMAL_SPEED_CAP_RAD_S, - INITIAL_FEEDBACK_SPAN_FRACTION, - MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX, - MEASURED_PASSIVE_JOINTS, - NORMALIZED_SWEEP_BIN_COUNT, - OUTER_CLEARANCE_FRACTION, - PARK_FINGER_RAD, - PARK_MIDDLE_MCP_RAD, - PARK_MIDDLE_PIP_RAD, - PARK_PINKY_MCP_RAD, - PARK_RING_MCP_RAD, - ROLL_CROSS_VIEW_BY_TASK, - SAFE_UPPER_RAD, - SDK_TO_URDF_JOINT, - build_typed_profile, -) - - - - -ROOT = Path(__file__).resolve().parents[1] -SOURCE_URDF = ROOT / "urdf/o12_right/linkerhand_o12_t3_right-0703.urdf" - - -def _synthetic_records(): - profile = build_typed_profile() - task_by_joint = { - joint: task for task in profile.motion.tasks for joint in task.joints - } - ratios = { - "thumb_dip": 1.01, - "index_dip": 0.88, - "middle_dip": 0.77, - "pinky_pip": 1.11, - "pinky_dip": 0.95, - } - source = { - "thumb_dip": "thumb_mcp", - "index_dip": "index_pip", - "middle_dip": "middle_pip", - "pinky_pip": "pinky_mcp_pitch", - "pinky_dip": "pinky_pip", - } - travel: dict[str, float] = {} - for name in CALIBRATED_ACTIVE_JOINTS: - task = task_by_joint[name] - travel[name] = abs(task.end_value - task.start_value) - # SDK coordinates and physical URDF joint travel are not interchangeable - # on O12. Use the reviewed CAD/mechanical travel in this fixture, just as - # the real Tag curves identify it independently of the commanded range. - travel.update({ - "thumb_cmc_roll": 0.720, - "thumb_cmc_yaw": 0.987, - "thumb_cmc_pitch": 0.588, - "thumb_mcp": 1.184, - "index_mcp_pitch": 1.290, - "index_pip": 1.635, - "middle_mcp_pitch": 1.318, - "middle_pip": 1.604, - "pinky_mcp_pitch": 1.472, - }) - travel["pinky_pip"] = ratios["pinky_pip"] * travel["pinky_mcp_pitch"] - travel["pinky_dip"] = ratios["pinky_dip"] * travel["pinky_pip"] - for name in ("thumb_dip", "index_dip", "middle_dip"): - travel[name] = ratios[name] * travel[source[name]] - result = {name: [] for name in CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS} - for name, rows in result.items(): - task = task_by_joint[name] - for cycle in range(4): - for direction, phases in ( - ("decreasing", np.linspace(0.0, 1.0, 65)), - ("increasing", np.linspace(1.0, 0.0, 65)), - ): - for sample_index, phase in enumerate(phases): - feedback = task.start_value + phase * (task.end_value - task.start_value) - angle = phase * travel[name] - rows.append({ - "sample_id": f"{task.key}:{cycle}:{direction}:{sample_index}", - "cycle": cycle, - "direction": direction, - "feedback_rad": float(feedback), - "relative_quaternion_xyzw": Rotation.from_rotvec( - [0.0, angle, 0.0] - ).as_quat().tolist(), - }) - return result - - -def test_o12_fixed_mapping_tags_and_radian_domain() -> None: - profile = build_typed_profile() - assert profile.key.profile_id == "O12/right/o12_right_16/v1" - assert profile.command.unit == "rad" - assert profile.command.feedback_by_index is True - assert profile.command.names == COMMAND_NAMES - assert SDK_TO_URDF_JOINT[:4] == ( - "thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch", "thumb_mcp" - ) - assert profile.vision.tag_ids == frozenset(range(16)) - assert len(profile.motion.tasks) == 11 - assert profile.motion.speed_parameters["command_rate_hz"] == 50.0 - assert NORMALIZED_SWEEP_BIN_COUNT == 64 - assert OUTER_CLEARANCE_FRACTION == pytest.approx(1.0) - assert PARK_RING_MCP_RAD == pytest.approx(SAFE_UPPER_RAD[10]) - assert PARK_PINKY_MCP_RAD == pytest.approx(SAFE_UPPER_RAD[11]) - assert PARK_FINGER_RAD == pytest.approx(PARK_PINKY_MCP_RAD) - assert PARK_MIDDLE_MCP_RAD == pytest.approx(SAFE_UPPER_RAD[8]) - assert PARK_MIDDLE_PIP_RAD == pytest.approx(SAFE_UPPER_RAD[9]) - assert INITIAL_FEEDBACK_SPAN_FRACTION == pytest.approx(0.99) - assert EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION == pytest.approx(0.90) - assert MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX == pytest.approx(2.0) - assert profile.vision.extrinsics_quality_limits[ - "reprojection_rms_px" - ] == pytest.approx(2.0) - assert FORMAL_SPEED_CAP_RAD_S[4] == pytest.approx(0.12) - assert FORMAL_SPEED_CAP_RAD_S[7] == pytest.approx(0.12) - assert ENDPOINT_ANCHOR_BY_JOINT == {} - assert [task.formal_speed for task in profile.motion.tasks] == [ - 0.10, 0.16, 0.32, 0.16, 0.32, 0.12, 0.32, 0.32, 0.12, 0.32, 0.32 - ] - index_mcp_pitch = profile.measurement.measurements["index_mcp_pitch"] - assert ( - index_mcp_pitch.view, - index_mcp_pitch.parent_role, - index_mcp_pitch.child_role, - ) == ("side", "side_base", "index_pip") - side_tag_ids = { - tag.role: tag.tag_id - for view in profile.vision.views - if view.name == "side" - for tag in view.tags - } - assert side_tag_ids["side_base"] == 4 - assert side_tag_ids["index_pip"] == 10 - assert side_tag_ids["index_dip"] == 11 - assert { - key: (value.view, value.parent_role, value.child_role) - for key, value in ROLL_CROSS_VIEW_BY_TASK.items() - } == { - "middle_roll_front": ("side", "side_base", "middle_pip"), - "index_roll_front": ("side", "side_base", "index_pip"), - } - - -def test_o12_cosine_speed_is_bounded() -> None: - speed = 0.03 - _, _, duration = cosine_position_trajectory_rad(0.0, -0.8, 0.0, speed) - samples = np.asarray([ - cosine_position_trajectory_rad(0.0, -0.8, t, speed)[0] - for t in np.linspace(0.0, duration, 2001) - ]) - measured = np.max(np.abs(np.diff(samples))) / (duration / 2000.0) - assert measured <= speed + 1.0e-6 - - -def test_o12_fast_trajectory_has_bounded_speed_and_zero_endpoint_velocity() -> None: - speed = 0.10 - _, _, old_duration = cosine_position_trajectory_rad( - 0.0, -0.827286065, 0.0, speed - ) - _, _, duration = cosine_ramp_velocity_trajectory_rad( - 0.0, -0.827286065, 0.0, speed, 0.4 - ) - times = np.linspace(0.0, duration, 5001) - samples = np.asarray([ - cosine_ramp_velocity_trajectory_rad( - 0.0, -0.827286065, t, speed, 0.4 - )[0] - for t in times - ]) - velocity = np.diff(samples) / np.diff(times) - assert duration < 0.70 * old_duration - assert np.max(np.abs(velocity)) <= speed + 1.0e-6 - assert abs(velocity[0]) < 1.0e-4 - assert abs(velocity[-1]) < 1.0e-4 - - -def test_o12_hcan_launch_does_not_emit_empty_socketcan_argument(tmp_path: Path) -> None: - profile = build_typed_profile() - config = SimpleNamespace( - model="O12", side="right", tag_layout="o12_right_16", - serial_number="O12_TEST", can_interface="", source_urdf=SOURCE_URDF, - source_urdf_sha256="a" * 64, - camera_extrinsics=tmp_path / "extrinsics.yaml", - camera_extrinsics_sha256="b" * 64, - calibration_config=tmp_path / "calibration.yaml", - calibration_config_sha256="c" * 64, - tag_config=tmp_path / "tags.yaml", tag_config_sha256="d" * 64, - sdk_config=tmp_path / "sdk.yaml", sdk_config_sha256="e" * 64, - output_root=tmp_path, cameras={ - view: { - "serial_number": f"camera-{view}", - "camera_name": f"camera_{view}", - "camera_info": str(tmp_path / f"{view}.yaml"), - } - for view in ("front", "side", "top") - }, - calibration_contract=SimpleNamespace(typed_profile=profile), - ) - command = _launch_command( - config, tmp_path / "session", record_bag=False, commands_enabled=False - ) - assert not any(item.startswith("can_interface:=") for item in command) - - resumed = _launch_command( - config, - tmp_path / "new_session", - record_bag=False, - commands_enabled=False, - resume_from=tmp_path / "old_session", - ) - assert ( - f"resume_raw_samples_path:={tmp_path / 'old_session/raw_samples.jsonl'}" - in resumed - ) - - -def test_o12_vendor_node_is_launched_in_documented_namespace() -> None: - launch_text = (ROOT / "launch/three_camera_calibration.launch.py").read_text() - assert 'executable="o12_sdk_bridge"' in launch_text - assert 'sdk_package_expected_sha256' in launch_text - from linkerhand_calibration.runtime.adapters.ros_topics import sdk_topics - topics = sdk_topics(build_typed_profile()) - assert topics.command == "/o12/right/joint_cmd" - assert topics.feedback == "/o12/right/joint_states" - - -def test_o12_vendor_stream_is_not_artificially_throttled() -> None: - import hashlib - import yaml - from linkerhand_calibration.compat.paths import resolve_renamed_package_path - - product = yaml.safe_load((ROOT/"config/o12_right_product.yaml").read_text()) - sdk_path = resolve_renamed_package_path(product["sdk"]["config"], ROOT.parent.parent) - assert sdk_path == ROOT/"config/o12_sdk.yaml" - assert hashlib.sha256(sdk_path.read_bytes()).hexdigest() == product["sdk"]["config_sha256"] - parameters = yaml.safe_load(sdk_path.read_text())["/**"]["ros__parameters"] - assert "left_hand" not in parameters - assert parameters["right_hand"]["request_interval_ms"] == 0 - assert parameters["right_hand"]["connection_type"] == product["sdk"]["transport"] - - -def test_o12_error_health_separates_active_faults_from_historical_comm_latch( -) -> None: - communication = assess_o12_error_report([0, 16, *([0] * 10)]) - assert communication.communication_only - assert communication.communication_channels == (1,) - assert not communication.active_fault_channels - assert historical_communication_latch_confirmed( - assessment=communication, - matching_report_count=3, - observation_seconds=2.0, - feedback_hz=49.0, - feedback_age_seconds=0.01, - minimum_feedback_hz=15.0, - ) - assert not historical_communication_latch_confirmed( - assessment=communication, - matching_report_count=3, - observation_seconds=2.0, - feedback_hz=49.0, - feedback_age_seconds=1.1, - minimum_feedback_hz=15.0, - ) - - active = assess_o12_error_report([0, 16 | 2, *([0] * 10)]) - assert not active.communication_only - assert active.active_fault_channels == (1,) - assert decoded_faults(active.codes)[0]["flags"] == ( - "overheat", "commu_except" - ) - - -def test_o12_log_exception_summary_reports_child_traceback( - tmp_path: Path, -) -> None: - log_path = tmp_path / "calibration.log" - log_path.write_text( - "Traceback (most recent call last):\n" - "[node-1] AttributeError: missing motion metadata\n" - "[ERROR] [node-1]: process has died\n", - encoding="utf-8", - ) - assert _log_exception_summary(log_path) == ( - "AttributeError: missing motion metadata" - ) - - - - - - - - - - - - - - -def test_o12_feedback_domain_contains_but_does_not_expand_command_domain() -> None: - command = build_typed_profile().command - assert command.minimum_values[2] == pytest.approx(-0.8272860654453121) - assert command.minimum_feedback_values == pytest.approx(FEEDBACK_LOWER_RAD) - assert command.maximum_feedback_values == pytest.approx(FEEDBACK_UPPER_RAD) - assert command.minimum_feedback_values[2] == pytest.approx( - command.minimum_values[2] - FEEDBACK_DOMAIN_MARGIN_RAD - ) - assert all( - feedback <= commanded - for feedback, commanded in zip( - command.minimum_feedback_values, command.minimum_values - ) - ) - assert all( - feedback >= commanded - for feedback, commanded in zip( - command.maximum_feedback_values, command.maximum_values - ) - ) - - -def test_o12_operator_progress_uses_unified_layout_and_radians() -> None: - text = render_o12_progress_zh({ - "state": "RUNNING", - "serial_number": "O12_TEST", - "step_index": 20, - "step_count": 190, - "step_fraction": 0.5, - "task_name": "thumb_pitch_front", - "phase": "sweep", - "cycle": 1, - "direction": "decreasing", - "attempt": 1, - "command_unit": "rad", - "target_rad": -0.827, - "current_command_u8": -0.400, - "actual_u8": -0.395, - "speed_rad_s": 0.060, - "command_trajectory_duration_seconds": 21.7, - "valid_frames": 80, - "total_frames": 82, - "tag_detection_rate": 80 / 82, - "joint_frame_rate": 80 / 82, - "recognized_tag_ids": [0, 1], - "unrecognized_tag_ids": [], - "feedback_hz": 20.0, - "position_mode_verified": True, - "error_report_verified": True, - "temperature_fallback_active": True, - "motion_speed_scale": 2.0, - }) - assert "[O12_TEST] 标定中" in text - assert "拇指 CMC pitch(正面 ID0→ID1)" in text - assert "阶段:正式扫描(第 2/4 轮)" in text - assert "命令/反馈:-0.400 rad/-0.395 rad" in text - assert "峰值 0.060 rad/s;本段 21.7 秒平滑限速轨迹" in text - assert "温度策略=错误码 bit1 过热保护;速度倍率=2.0x" in text - - roll_status = { - "state": "RUNNING", "serial_number": "O12_TEST", - "step_index": 50, "step_count": 190, "step_fraction": 0.2, - "task_name": "middle_roll_front", "phase": "sweep", - "cycle": 0, "direction": "decreasing", "attempt": 1, - "command_unit": "rad", "target_rad": -0.26, - "current_command_u8": 0.1, "actual_u8": 0.09, - "speed_rad_s": 0.12, "valid_frames": 50, - "total_frames": 50, "tag_detection_rate": 1.0, - "joint_frame_rate": 1.0, "recognized_tag_ids": [0, 12], - "unrecognized_tag_ids": [], - "roll_cross_view": { - "active": True, "recognized_tag_ids": [4, 8], - "unrecognized_tag_ids": [], "valid_frames": 48, - "total_frames": 50, "joint_frame_rate": 0.96, - }, - } - roll_text = render_o12_progress_zh(roll_status) - assert "正面 ID0→ID12 + 侧面 ID4→ID8" in roll_text - assert "侧摆双机位:侧面已识别 ID4/ID8" in roll_text - - roll_status["locked_reference_tag_ids"] = [0] - locked_text = render_o12_progress_zh(roll_status) - assert "固定基准:ID0 已锁定,避让遮挡期间复用" in locked_text - - coupling_status = dict(roll_status) - coupling_status["feedback_coupling"] = { - "active": True, - "coupled_channel": "middle_pip", - "reference_kind": "training_observation", - "displacement_rad": 0.028, - "residual_rad": 0.004, - "hard_displacement_limit_rad": 0.0855, - } - coupling_text = render_o12_progress_zh(coupling_status) - assert ( - "O12耦合:middle_pip 位移 0.028/0.086 rad;" - "训练采集;vendor偏差 0.004 rad(仅诊断)" - ) in coupling_text - - - - -def test_o12_vendor_passive_solver_regression() -> None: - assert evaluate_vendor_passive_joint( - "thumb_dip", -1.29, sdk_to_urdf_sign=-1.0 - ) == pytest.approx(1.094445515, abs=1.0e-9) - assert evaluate_vendor_passive_joint( - "pinky_pip", 1.54, sdk_to_urdf_sign=1.0 - ) == pytest.approx(1.360545431, abs=1.0e-9) - assert evaluate_vendor_passive_joint( - "pinky_dip", 1.54, sdk_to_urdf_sign=1.0 - ) == pytest.approx(1.291363184, abs=1.0e-9) - - - - - - - - - - - - - - - - -def test_immutable_o12_cad_ring_mimic_conflict_is_not_waived(): - from linkerhand_calibration.core.urdf.patch import validate_urdf_mimic_ranges - with pytest.raises(ValueError, match="ring_pip"): - validate_urdf_mimic_ranges(SOURCE_URDF) diff --git a/src/linkerhand_calibration/test/test_o12_thumb_pnp.py b/src/linkerhand_calibration/test/test_o12_thumb_pnp.py deleted file mode 100644 index 9d95cef..0000000 --- a/src/linkerhand_calibration/test/test_o12_thumb_pnp.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest -from scipy.spatial.transform import Rotation as R - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.pnp import O12ThumbPoseTracker, THUMB_ROLES -from linkerhand_calibration.pnp import SquareTagPose - - -def pose(rotation, error=0.05): - return SquareTagPose(tuple(rotation.as_quat()), (0., 0., 1.), error) - - -@pytest.mark.parametrize('remount', [False, True]) -@pytest.mark.parametrize('passive_travel', [12, 28, 45]) -def test_parallel_branch_disambiguation_without_angle_ratio(remount, passive_travel): - tracker=O12ThumbPoseTracker() - mounts=[R.identity()]*3 if not remount else [ - R.from_euler('xyz', angles) for angles in - ([.3,-.7,.2],[-.5,.4,.8],[.6,.2,-.4])] - camera=R.from_euler('xyz',[.4,-.8,.7]) - baseline={role:(pose(camera*mount),) for role,mount in zip(THUMB_ROLES,mounts)} - for i in range(8): - selected,reason=tracker.select(baseline,stamp_ns=1_000_000_000+i*30_000_000) - assert selected is not None - driver=R.from_euler('z',20,degrees=True) - follower=R.from_euler('z',passive_travel,degrees=True) - wrong=R.from_euler('x',passive_travel,degrees=True) - good_pose=pose(camera*driver*follower*mounts[2],.08) - candidates={THUMB_ROLES[0]:baseline[THUMB_ROLES[0]], - THUMB_ROLES[1]:(pose(camera*driver*mounts[1]),), - THUMB_ROLES[2]:(pose(camera*driver*wrong*mounts[2]),good_pose)} - selected,reason=tracker.select(candidates,stamp_ns=1_250_000_000) - assert reason == '' - assert selected[THUMB_ROLES[2]] == good_pose - # Angles are observed candidates, not replaced with driver*mimic. - assert tracker.coupled_rotation_pairs == () - - -def test_no_credible_parallel_candidate_does_not_stop_or_invent_pose(): - tracker=O12ThumbPoseTracker() - baseline={role:(pose(R.identity()),) for role in THUMB_ROLES} - for i in range(8): - tracker.select(baseline,stamp_ns=i*30_000_000) - bad=pose(R.from_euler('zx',[20,45],degrees=True)) - selected,reason=tracker.select({THUMB_ROLES[0]:baseline[THUMB_ROLES[0]], - THUMB_ROLES[1]:(pose(R.from_euler('z',20,degrees=True)),), - THUMB_ROLES[2]:(bad,)},stamp_ns=250_000_000) - assert reason == '' - assert selected[THUMB_ROLES[2]] == bad - tracker.reset() - assert tracker.reference is None - - -def test_missing_tag_discards_observation_and_gap_reinitializes(): - tracker=O12ThumbPoseTracker() - baseline={role:(pose(R.identity()),) for role in THUMB_ROLES} - for i in range(8): - tracker.select(baseline,stamp_ns=i*30_000_000) - selected,reason=tracker.select({},stamp_ns=250_000_000) - assert selected is None and reason == 'group_missing_pose_candidates' - selected,_=tracker.select(baseline,stamp_ns=10_000_000_000) - assert selected is None and tracker.reference is None diff --git a/src/linkerhand_calibration/test/test_o30_artifacts.py b/src/linkerhand_calibration/test/test_o30_artifacts.py deleted file mode 100644 index fa41887..0000000 --- a/src/linkerhand_calibration/test/test_o30_artifacts.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Twenty independently observed active channels through JSON and URDF read-back.""" - -from dataclasses import replace -import hashlib -import json -import xml.etree.ElementTree as ET - -import pytest - -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session -from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json - - -def test_o30_nonlinear_twenty_channel_finalization(tmp_path, o30_capture): - from o30_formal_fixture import formal_capture - profile, source, records, hashes, camera_file, offsets = formal_capture(o30_capture, tmp_path, adaptive=True) - source_hash = hashes['source_urdf_sha256'] - result, fit, artifact = finalize_profile_session(profile=profile, source_urdf=source, - records=records, protected_inputs=hashes, serial_number='SYNTHETIC_FORMAL_O30', - session_dir=tmp_path/'session', standard_loader=ET.parse, - require_motion_evidence=True, camera_extrinsics_file=camera_file) - capture = json.loads((tmp_path/'session'/'capture_validation.json').read_text()) - assert capture['passed'] - assert result['capture_plan']['policy'] == 'adaptive_2_to_3' - assert {tuple(task['training_cycles']) for task in result['capture_plan']['tasks'].values()} == {(0, 1), (0, 1, 2)} - assert any(row.get('decision') == 'add_training' for row in records) - assert all(row['cycle'] != 2 for row in records - if row.get('kind') == 'joint_sample' and row['task_name'] == profile.motion.tasks[-1].key) - from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper - mapper = load_unified_mapper(artifact.staged_release.calibration_json) - assert max(abs(v) for v in mapper.map_positions(profile.command.baseline_values)) < 1e-10 - assert len(mapper.map_positions([0]*20)) == 20 - assert result['schema_version'] == 3 - assert len(fit.output_mappings) == len(fit.command_mappings) == 20 - for name, expected in offsets.items(): - assert fit.zero_offsets_rad[name] == pytest.approx(expected, abs=.004), name - for name in profile.zero.cad_zero_assumptions: - assert fit.zero_method_by_joint[name] == 'assumed_source_cad_zero' - original_joints = {j.get('name'): j for j in ET.parse(source).findall('joint')} - corrected = ET.parse(artifact.path) - assert not corrected.findall('.//mimic') - for joint in corrected.findall('joint'): - old = original_joints[joint.get('name')] - assert joint.find('origin').get('xyz') == old.find('origin').get('xyz') - assert joint.find('axis').attrib == old.find('axis').attrib - assert all(joint.find('limit').get(k) == old.find('limit').get(k) for k in ('effort','velocity')) - if joint.get('name') in profile.zero.cad_zero_assumptions: - assert joint.find('origin').attrib == old.find('origin').attrib - rebuilt = rebuild_urdf_from_json(artifact.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() == artifact.path.read_bytes() - assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash - accepted = json.loads(artifact.staged_release.validation_json) - assert accepted['urdf_rebuilt_from_json'] - assert accepted['final_file_image_holdout_verified'] - assert accepted['release_basis'] == 'steady_command' - assert set(accepted['final_file_image_holdout']) == {'command'} - assert not accepted['feedback_mapping_required'] - report = json.loads((tmp_path/'session'/'calibration_report.json').read_text()) - assert report['quality']['motion_observation'] == profile.acquisition.motion_observation - assert report['quality']['position_feedback_required'] is True - assert all('feedback_to_rad' not in row for row in report['joints'].values()) - assert all(row['feedback_bounds_rad'] is None for row in report['motion_range_evidence'].values()) - - -def test_segmented_offline_acceptance_and_resume_restart(tmp_path, o30_capture): - from linkerhand_calibration.runtime.engine import CalibrationEngine, ACQUISITION_POLICY_VERSION - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - from linkerhand_calibration.core.domain.motion_path import segment_metadata, record_scan_identity - from linkerhand_calibration.runtime.artifacts.replay import validate_capture_units - from linkerhand_calibration.runtime.joint_resume import JointResume - profile, _, rows, _ = o30_capture - engine = CalibrationEngine(profile) - header = dict(kind='session_start',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) - # The synthetic FK fixture is explicitly pose-only; this test exercises - # quality/resume, leaving live image-provenance replay to its own tests. - header_without_motion_claim = dict(header, pose_tracking_policy_version='fixture') - complete = [dict(kind='scan_unit_complete',task_name=u.task_key,cycle=u.cycle, - direction=u.direction,attempt=1,passed=True,**segment_metadata(u.segment_key)) - for u in engine.scan_units()] - validate_capture_units(profile,[*rows,*complete]) - from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit - spans = {} - for unit in engine.scan_units(): - if unit.segment_key and unit.cycle == 0: - assert evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans=spans).passed - middle_spans = {key for key in spans if ':middle_mcp_roll:' in key} - assert len(middle_spans) == 4 # Upper/lower travel in both directions remains independent. - missing = [r for r in complete if not (r['task_name']=='fingers_mcp_roll_front' - and r['cycle']==3 and r.get('segment_key')=='middle_lower_decreasing')] - with pytest.raises(ValueError,match='capture_incomplete'): - validate_capture_units(profile,[*rows,*missing]) - # Use a pre-motion-policy header only for staging these pose-only fixtures; - # production rejects it before reaching this boundary. - from unittest.mock import patch - resume=JointResume(profile) - with patch.object(engine,'resume_compatible',return_value=True): - resume.stage(engine,[header_without_motion_claim,*rows,*missing], - {record_scan_identity(r) for r in missing}) - group=next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - assert not set(group.joints) & resume.references.keys() - assert not any(key[0]==group.key for key in resume.units) - assert len(resume.units)==16*8 - # Legacy feedback-certified profiles still reject an unsupported endpoint. - profile = replace(profile, acquisition=replace(profile.acquisition, motion_observation="feedback"), - measurement=replace(profile.measurement, release_basis="feedback_and_command")) - engine = CalibrationEngine(profile) - resume = JointResume(profile) - # Individual sweeps can pass while the independent endpoint lies outside - # every training sweep. Such a task must restart with a fresh zero. - changed = [dict(row, feedback_u8=3.+247.*row['feedback_u8']/255.) - if row.get('joint')=='thumb_cmc_roll' and row.get('sample_phase')=='sweep' - and row.get('cycle', 99)<3 else row for row in rows] - with patch.object(engine,'resume_compatible',return_value=True): - resume.stage(engine,[header_without_motion_claim,*changed,*complete], - {record_scan_identity(r) for r in complete}) - assert not any(key[0]=='thumb_cmc_roll_front' for key in resume.units) - assert 'thumb_cmc_roll' not in resume.references - assert len(resume.units)==15*8 - assert not engine.resume_compatible(dict(header,capture_schedule_version='unified_schedule_v3_separate_mapping')) diff --git a/src/linkerhand_calibration/test/test_o30_launch.py b/src/linkerhand_calibration/test/test_o30_launch.py deleted file mode 100644 index 1e04694..0000000 --- a/src/linkerhand_calibration/test/test_o30_launch.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Construct protected O30 launch actions using synthetic cameras, without ROS processes.""" - -from pathlib import Path -import hashlib -import yaml -import pytest - -from launch import LaunchContext -from launch.actions import DeclareLaunchArgument -from launch.utilities import perform_substitutions -from launch_ros.actions import Node -from launch_ros.utilities import evaluate_parameters, to_parameters_list -from ros2launch.api.api import parse_launch_arguments - -from linkerhand_calibration.product import load_product_config, _camera_info_fingerprint -from linkerhand_calibration.runtime.runner_support import launch_command -from test_unified_launch import launch_module -from o30_capture_fixture import PACKAGE - - -def synthetic_product(tmp_path): - product=yaml.safe_load((PACKAGE/'config/o30_right_product.yaml').read_text()) - product['profile_config']=str(PACKAGE/'config/profiles/o30_right_18.yaml') - for key in ('source_urdf','calibration_config','tag_config'): - product['artifacts'][key]=str(PACKAGE/product['artifacts'][key].split('package://linkerhand_calibration/')[1]) - extrinsics=dict(schema_version=1,reference_view='front',cameras={},front_from_view={},quality=dict( - passed=True,reprojection_rms_px=.1,maximum_rotation_repeatability_deg=.01, - maximum_translation_repeatability_m=.0001,front_side_captures=15,front_top_captures=15)) - for n,(view,config) in enumerate(product['cameras'].items()): - info=tmp_path/f'{view}.yaml' - info.write_text(yaml.safe_dump(dict(image_width=1280,image_height=960, - camera_matrix=dict(data=[1200,0,640,0,1200,480,0,0,1]), - distortion_coefficients=dict(data=[0.]*5),rectification_matrix=dict(data=[1,0,0,0,1,0,0,0,1]), - projection_matrix=dict(data=[1200,0,640,0,0,1200,480,0,0,0,1,0])))) - config.update(serial_number=f'SYNTHETIC_{view}',camera_info=str(info)) - extrinsics['cameras'][view]=dict(serial_number=config['serial_number'],width=1280,height=960, - intrinsics_sha256=_camera_info_fingerprint(info)) - extrinsics['front_from_view'][view]=dict(translation_xyz_m=[.1*n,0,0],quaternion_xyzw=[0,0,0,1]) - path=tmp_path/'synthetic_extrinsics.yaml';path.write_text(yaml.safe_dump(extrinsics)) - product['artifacts'].update(camera_extrinsics=str(path),camera_extrinsics_sha256=hashlib.sha256(path.read_bytes()).hexdigest()) - path=tmp_path/'product.yaml';path.write_text(yaml.safe_dump(product)) - return load_product_config(path,workspace=PACKAGE.parents[1],check_can=False) - - -def test_o30_real_launch_and_right_hand_settings(tmp_path,monkeypatch): - monkeypatch.setenv('ROS_LOG_DIR',str(tmp_path/'logs')) - config=synthetic_product(tmp_path) - context=LaunchContext() - context.launch_configurations.update(dict(parse_launch_arguments(launch_command(config,tmp_path/'session', - record_bag=False,commands_enabled=False)[4:]))) - module=launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action,DeclareLaunchArgument):action.execute(context) - actions=module._launch_stack(context) - nodes=[a for a in actions if isinstance(a,Node)] - sdk=next(a for a in nodes if a.node_package=='linker_hand_o30_ros2_sdk') - params={p.name:p.value for p in to_parameters_list(context,'linker_hand_o30_sdk','', - evaluate_parameters(context,sdk._Node__parameters))} - assert params['hand_type']=='right' - assert params['comm_type']=='libcanbus' - assert params['canfd_device']==0 and params['state_rate']==30. - assert not params['auto_init_pose'] and not params['is_touch'] - assert not (tmp_path/'session/raw_samples.jsonl').exists() - - -@pytest.mark.parametrize('policy', ['taskwise_2_plus_1', 'fixed', 'staged_2_to_3', 'occlusion_aware_witness_v1']) -def test_session_profile_survives_real_launch_parameter_chain(tmp_path, monkeypatch, policy): - from dataclasses import replace - from linkerhand_calibration.product import ProductCalibrationContract - from linkerhand_calibration.core.domain.capture_plan import CapturePlan, configure_training - from linkerhand_calibration.profiles.preparation import configure_preparation - from linkerhand_calibration.runtime.profile_contract import resolve_runtime_profile - from linkerhand_calibration.runtime.ros.parameters import parameter_defaults - - monkeypatch.setenv('ROS_LOG_DIR', str(tmp_path/'logs')) - config = synthetic_product(tmp_path) - original = config.calibration_contract.typed_profile - profile = configure_training(original, - 'staged_2_to_3' if policy == 'occlusion_aware_witness_v1' else policy) - if policy == 'occlusion_aware_witness_v1': - profile = configure_preparation(profile, policy) - config = replace(config, calibration_contract=ProductCalibrationContract(declarative=profile)) - context = LaunchContext() - context.launch_configurations.update(dict(parse_launch_arguments(launch_command( - config, tmp_path/'session', record_bag=False, commands_enabled=False)[4:]))) - module = launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - owner, = [action for action in module._launch_stack(context) - if isinstance(action, Node) and action.node_package == 'linkerhand_calibration' - and action.node_executable == 'three_camera_calibration_node'] - effective = parameter_defaults(original) - effective.update({p.name: p.value for p in to_parameters_list(context, 'o30_calibration', '', - evaluate_parameters(context, owner._Node__parameters))}) - restored = resolve_runtime_profile(original, effective.__getitem__) - assert CapturePlan.from_profile(restored).as_dict() == CapturePlan.from_profile(profile).as_dict() - assert restored.motion.preparation_witnesses == profile.motion.preparation_witnesses - assert not (tmp_path/'session/raw_samples.jsonl').exists() - if policy == 'occlusion_aware_witness_v1': - assert len(restored.motion.preparation_witnesses) == 4 - # Reproduce the real omitted-parameter failure, then explicit removal/tampering. - for name, replacement in [('preparation_witnesses_json', ''), - ('preparation_witnesses_json', '{}'), ('training_policy', 'fixed')]: - with pytest.raises(ValueError, match='runtime_capture_plan_mismatch'): - resolve_runtime_profile(original, {**effective, name: replacement}.__getitem__) - - -def test_runtime_profile_preserves_legacy_yaml_and_rejects_malformed_overrides(): - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.profile_contract import resolve_runtime_profile - from linkerhand_calibration.runtime.ros.parameters import parameter_defaults - from linkerhand_calibration.core.domain.capture_plan import CapturePlan - profile = load_bundled_hand_profile('o30_right_18') - defaults = parameter_defaults(profile) - assert CapturePlan.from_profile(resolve_runtime_profile(profile, defaults.__getitem__)) == CapturePlan.from_profile(profile) - for encoded in ['[1]', '{', '{"ring_mcp_pitch": {}}']: - with pytest.raises(ValueError, match='invalid preparation_witnesses_json'): - resolve_runtime_profile(profile, {**defaults, 'preparation_witnesses_json': encoded}.__getitem__) - - -def test_parent_model_preparation_freezes_branch_without_replacing_joint_zero(tmp_path,monkeypatch): - from types import SimpleNamespace - from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime.branch_initialization import BranchInitialization - from linkerhand_calibration.runtime import coordinator as module - from linkerhand_calibration.profiles import load_bundled_hand_profile - profile=load_bundled_hand_profile('o30_right_18') - # Legacy explicit parent motion remains supported even though the current - # product now declares a stationary parent_reference instead. - from linkerhand_calibration.core.domain.profile import ModelPreparation - target=profile.motion.joint_zero_references['thumb_ip'].command - approach=list(target);approach[6]=255 - preparation=ModelPreparation('thumb_mcp',target,(tuple(approach),)) - motion=MotionCommand('joint_zero',preparation.command,200,'thumb_ip_front', - zero_joints=('thumb_mcp',),reference_only=True,reference_command=preparation.command, - reference_approach=preparation.approach_commands) - initialization=BranchInitialization(profile) - from dataclasses import replace - initialization.begin(replace(motion,phase='zero_approach',zero_joints=(),reference_joints=('thumb_mcp',)), - session_epoch=1,motion_version=1) - assert initialization.reference_spec==(preparation.command,preparation.approach_commands) - assert [(r.joint,r.parent_role,r.child_role) for r in initialization._relations('front')]==[ - ('thumb_mcp','front_base','thumb_mcp')] - original_zero=object(); frozen=[] - host=SimpleNamespace(profile=profile,branch_initialization=SimpleNamespace(ready=lambda _:True), - execution=SimpleNamespace(zero_references={'thumb_mcp':original_zero}), - _steady_rows=[],_freeze_reference_branches=lambda revisions:frozen.append(revisions) or True) - revisions={('front','thumb_mcp'):2,('front','front_base'):1} - monkeypatch.setattr(module,'reference_branch_revisions',lambda *args,**kwargs:revisions) - for stamp in range(9): - host._steady_rows.append(dict(kind='joint_zero_sample',joint='thumb_mcp',view='front',image_stamp_ns=stamp)) - assert not CalibrationCoordinator._confirm_model_reference(host,motion) - host._steady_rows.append(dict(kind='joint_zero_sample',joint='thumb_mcp',view='front',image_stamp_ns=9)) - assert CalibrationCoordinator._confirm_model_reference(host,motion) - assert frozen==[revisions] - assert host.execution.zero_references['thumb_mcp'] is original_zero diff --git a/src/linkerhand_calibration/test/test_o30_profile.py b/src/linkerhand_calibration/test/test_o30_profile.py deleted file mode 100644 index 8ba7b9d..0000000 --- a/src/linkerhand_calibration/test/test_o30_profile.py +++ /dev/null @@ -1,359 +0,0 @@ -"""O30 declarations, SDK boundary and synchronized motion execution.""" - -from dataclasses import replace -from pathlib import Path -from types import SimpleNamespace -import hashlib -import json -import math - -import numpy as np -import pytest -import yaml - -from linkerhand_calibration.profiles import load_bundled_hand_profile, dump_hand_profile, load_hand_profile -from linkerhand_calibration.profiles.validator import validate_executable_profile, require_release_geometry -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.steady import steady_targets -from linkerhand_calibration.runtime.visual_motion import VisualMotionObserver -from linkerhand_calibration.runtime.trajectory import build_calibration_motion_command -from linkerhand_calibration.core.domain.motion_path import task_segment, record_scan_identity -from linkerhand_calibration.core.domain.sampling import command_nodes -from o30_capture_fixture import SOURCE, PACKAGE - - -@pytest.fixture -def profile(): - return load_bundled_hand_profile('o30_right_18', scope='full') - - -def test_profile_source_bindings_and_product_hashes(profile, tmp_path): - assert load_bundled_hand_profile('o30_right_18').scope.default_scope == 'full' - report = validate_executable_profile(profile, SOURCE) - require_release_geometry(report) - assert profile.key.profile_id == 'O30/right/o30_right_18/v1' - assert len(profile.zero.active_joints) == 20 - assert not profile.zero.passive_joints - assert not profile.zero.mimic_source_by_joint - assert not profile.measurement.transferred_motion_sources - assert len(profile.zero.direct_zero_joints) == 15 - assert profile.vision.tag_ids == frozenset(range(18)) - assert all(t.size_m == .016 for v in profile.vision.views for t in v.tags) - assert report['tag_links']['thumb_mcp'] == 'thumb_proximal' - assert profile.command.baseline_u8 == (0,0,255,196,127,52) + (0,)*14 - assert profile.acquisition.fixed_reference_mode == 'stationary_image' - assert profile.command.joint_directions == (1,1,-1,-1,-1,-1) + (1,)*14 - assert len(profile.motion.tasks) == 17 - ids = {tag.role: tag.tag_id for view in profile.vision.views for tag in view.tags} - expected = { - 'thumb_cmc_roll': (0, 'front', 0, 1), - 'thumb_cmc_yaw': (1, 'top', 16, 17), - 'thumb_mcp': (6, 'front', 0, 1), - 'thumb_ip': (15, 'front', 1, 2), - } - for n, finger in enumerate(('pinky', 'ring', 'middle', 'index')): - expected.update({ - finger+'_mcp_roll': (5-n, 'front', 0, 12+n), - finger+'_mcp_pitch': (10-n, 'side', 3, 4+2*n), - finger+'_pip': (14-n, 'side', 3, 4+2*n), - finger+'_dip': (19-n, 'side', 4+2*n, 5+2*n), - }) - for joint, (channel, view, parent, child) in expected.items(): - measurement = profile.measurement.measurements[joint] - assert profile.command.command_index_by_joint[joint] == channel - assert (measurement.view, ids[measurement.parent_role], ids[measurement.child_role]) == (view,parent,child) - for finger in ('pinky', 'ring', 'middle', 'index'): - task = next(t for t in profile.motion.tasks if t.key == finger+'_dip_side') - assert task.parent_reference.pose_joint == finger+'_mcp_pitch' - assert task.parent_reference.geometry_joint == finger+'_pip' - assert profile.command.names == ('thumb_roll','thumb_yaw','index_yaw','middle_yaw','ring_yaw','little_yaw', - 'thumb_root1','index_root1','middle_root1','ring_root1','little_root1', - 'index_root2','middle_root2','ring_root2','little_root2', - 'thumb_tip','index_tip','middle_tip','ring_tip','little_tip') - path = tmp_path/'roundtrip.yaml'; path.write_text(dump_hand_profile(profile)) - assert load_hand_profile(path) == profile - product = yaml.safe_load((PACKAGE/'config/o30_right_product.yaml').read_text()) - for key in ['source_urdf', 'calibration_config', 'tag_config']: - file = PACKAGE/product['artifacts'][key].split('package://linkerhand_calibration/')[1] - assert hashlib.sha256(file.read_bytes()).hexdigest() == product['artifacts'][key+'_sha256'] - assert hashlib.sha256((PACKAGE/'config/profiles/o30_right_18.yaml').read_bytes()).hexdigest() == product['profile_config_sha256'] - extrinsics = PACKAGE.parents[1]/product['artifacts']['camera_extrinsics'] - assert hashlib.sha256(extrinsics.read_bytes()).hexdigest() == product['artifacts']['camera_extrinsics_sha256'] - - -def test_tag_sizes_agree_across_profile_detector_and_calibration(profile): - expected = {tag.tag_id: tag.size_m for view in profile.vision.views for tag in view.tags} - detectors = yaml.safe_load((PACKAGE/'config/o30_right_18_tags.yaml').read_text()) - detected = {} - for node in detectors.values(): - params = node['ros__parameters'] - assert params['size'] == .016 - tags = params['tag'] - assert len(tags['ids']) == len(tags['sizes']) - detected.update(zip(tags['ids'], tags['sizes'])) - assert detected == expected - calibration = yaml.safe_load((PACKAGE/'config/o30_three_camera_calibration.yaml').read_text()) - params = calibration['o30_calibration']['ros__parameters'] - assert params['tag_size_m'] == .016 - assert len(params['tag_size_override_ids']) == len(params['tag_size_overrides_m']) - assert dict(zip(params['tag_size_override_ids'], params['tag_size_overrides_m'])) == expected - - -def test_group_zero_approaches_preserve_baseline_and_use_observable_travel(profile): - task = next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - for name in task.joints: - spec = profile.motion.joint_zero_references[name] - assert spec.command == profile.command.baseline_values - channel = profile.command.command_index_by_joint[name] - assert spec.evidence_scope == 'path' - assert [tuple(pose[2:6]) for pose in spec.approach_commands] == [ - (0,80,0,0), (255,255,255,255), (0,80,0,0)] - values = [pose[channel] for pose in spec.approach_commands] - assert max(values)-min(values) == (175 if name == 'middle_mcp_roll' else 255) - - -def test_group_schedule_native_coverage_nodes_and_peak_velocity(profile): - task = next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - units = [u for u in CalibrationEngine(profile).scan_units() if u.task_key == task.key] - assert len(units) == 12 - for cycle in (0, 1, 3): - selected = [u for u in units if u.cycle == cycle] - assert [u.segment_key for u in selected] == [s.key for s in task.segments] - middle = [(dict(s.start_commands)[3], dict(s.end_commands)[3]) for s in task.segments] - assert middle == [(80,255),(255,80),(80,0),(0,80)] - assert {255.,196.,127.,52.,80.} <= set(command_nodes(profile, task)) - for unit in units: - segment = task_segment(task, unit.segment_key) - targets = [segment.commands_at(v, quantize=True) for v in steady_targets(profile, unit)] - for channel in segment.moving_channels: - lo, hi = sorted((dict(segment.start_commands)[channel], dict(segment.end_commands)[channel])) - expected = {v for v in command_nodes(profile, task, holdout=unit.cycle==3) if lo<=v<=hi} - assert expected <= {t[channel] for t in targets} - segment = task.segments[0] - start = build_calibration_motion_command(task, segment.start, profile=profile, segment_key=segment.key) - end = build_calibration_motion_command(task, segment.end, profile=profile, segment_key=segment.key) - execution = MotionExecution(profile, MotionCommand('sweep', tuple(end), 200, - measured_channels=segment.moving_channels), - initial_command=start, initial_feedback=start, now=0., identity='linked', - visual_observer=VisualMotionObserver(profile)) - previous = execution.sample(0) - for t in np.linspace(.01,21,2100): - actual = execution.sample(t) - progress = actual[5]/255 - assert actual[3] == pytest.approx(80+175*progress, abs=1.) - assert actual[2] == actual[4] == actual[5] - # Integer bytes carry at most one unit of quantization error. - assert max(abs(a-b) for a,b in zip(actual,previous)) <= 20*.01+1. - assert math.pi*255/(2*execution.duration) <= 20.000001 - previous = actual - - -@pytest.mark.parametrize(('scope', 'expected_units'), [('full', 108), ('without_finger_roll', 96)]) -def test_complete_motion_driver_clearances_own_channels_and_retry(scope, expected_units): - profile = load_bundled_hand_profile('o30_right_18', scope=scope) - from linkerhand_calibration.core.domain.capture_plan import configure_training, TASKWISE_TWO_ROUND - profile = configure_training(profile, TASKWISE_TWO_ROUND) - driver = SessionExecution(profile) - driver.session.device_ready();driver.session.start() - command = profile.command.baseline_values - rows, motions, stamp, omitted = [], [], 1, False - for _ in range(6000): - phase = driver.session.phase - if phase == Phase.FIT:break - if phase == Phase.REFERENCE_LOCKING: - driver.session.reference_locked();continue - if phase == Phase.EVALUATE: - driver.evaluate(rows);continue - motion = driver.motion(command) - assert motion is not None - motions.append(motion) - before = command - command = motion.target - if motion.phase == 'joint_zero' and not motion.reference_only: - driver.zero_references.update({name: SimpleNamespace(joint=name, task_key=motion.task_key, - channel=profile.command.command_index_by_joint[name], - samples=(SimpleNamespace(command=motion.target, feedback=motion.target),)) - for name in motion.zero_joints}) - if motion.phase in {'sweep','steady'}: - unit = driver.action.scan_unit - task = next(t for t in profile.motion.tasks if t.key == unit.task_key) - segment = task_segment(task, unit.segment_key) if unit.segment_key else None - names = segment.joints if segment else task.joints - for name in names: - channel = profile.command.command_index_by_joint[name] - start = before[channel] - end = dict(segment.end_commands)[channel] if segment else unit.end - # One lower-middle segment fails; valid upper-middle rows cannot conceal it. - if unit.segment_key == 'middle_lower_decreasing' and unit.cycle == 0 and motion.attempt == 1: - omitted = True;continue - for value in (np.linspace(start,command[channel],129) if motion.phase == 'sweep' else [command[channel]]*3): - stamp += 1 - rows.append(dict(joint=name,view=task.view,task_name=task.key,cycle=unit.cycle, - direction=unit.direction,segment_key=unit.segment_key,attempt=motion.attempt, - image_stamp_ns=stamp,feedback_u8=value,command_u8=value,sample_phase=motion.phase, - steady_index=motion.steady_index,steady_target=command[channel])) - driver.motion_complete() - assert driver.session.phase == Phase.FIT - assert len(driver.completed_units) == expected_units - task_order = list(dict.fromkeys(m.task_key for m in motions if m.phase == 'sweep')) - assert task_order[:4] == ['thumb_cmc_roll_front', 'thumb_mcp_front', 'thumb_ip_front', 'thumb_cmc_yaw_top'] - assert task_order[4:] == [ - f'{finger}_{joint}_side' for finger in ('pinky', 'ring', 'middle', 'index') - for joint in ('mcp_pitch', 'pip', 'dip')] + (['fingers_mcp_roll_front'] if scope == 'full' else []) - if scope == 'full': - roll_zeros = [m for m in motions if m.task_key == 'fingers_mcp_roll_front' and m.phase == 'joint_zero'] - assert roll_zeros and all(m.target[6:] == (0,) * 14 for m in roll_zeros) - for motion in motions: - if motion.task_key == 'thumb_cmc_yaw_top': - assert motion.target[:1] + motion.target[2:] == ( - profile.command.baseline_values[:1] + profile.command.baseline_values[2:]) - exits = [m for m in motions if m.phase == 'task_exit' and m.task_key == 'thumb_cmc_roll_front'] - assert len(exits) == 2 - assert exits[0].target[0] == 0 and exits[0].target[2] == 0 - assert exits[1].target[2] == 255 - mcp_exits = [m for m in motions if m.phase == 'task_exit' and m.task_key == 'thumb_mcp_front'] - assert [(m.target[6], m.target[1]) for m in mcp_exits] == [(0, 80), (0, 0)] - parent_checks = [m for m in motions if m.reference_reuse] - expected_checks = [('thumb_ip', 'thumb_cmc_roll'), - *((finger+'_dip', finger+'_mcp_pitch') for finger in ('pinky','ring','middle','index'))] - assert len(parent_checks) == len(expected_checks) - for motion, (joint, source) in zip(parent_checks, expected_checks): - assert motion.zero_joints == (source,) - assert motion.target == profile.motion.joint_zero_references[joint].command - assert all(m.target[6] == 0 for m in motions if m.task_key == 'thumb_ip_front') - fingers = ['pinky','ring','middle','index'] - for n,finger in enumerate(fingers): - held = [profile.command.command_index_by_joint[f+'_'+s] for f in fingers[:n] - for s in ('mcp_pitch','pip','dip')] - for motion in motions: - if motion.task_key and motion.task_key.startswith(finger+'_') and motion.task_key.endswith('_side'): - assert all(motion.target[i] == 255 for i in held), motion - assert {m.segment_key for m in motions if m.attempt==2} == ( - {'middle_lower_decreasing'} if scope == 'full' else set()) - - -def test_sdk_native_names_device_identity_and_topics(profile): - from linkerhand_calibration.runtime.adapters.ros_binding import bind_ros_sdk, SdkBindingPorts - from linkerhand_calibration.runtime.adapters.o30_ros import launch_parameters - from linkerhand_calibration.runtime.adapters.ros_topics import sdk_topics - callback, now, sent = [], [0.], [] - adapter = bind_ros_sdk(profile, SdkBindingPorts(lambda: True,lambda: now[0],callback.append), - publish=sent.append,set_speed=lambda i,v:sent.append((i,v))) - assert not adapter.health().connected - info = dict(hand_type='right',side='RIGHT',model='O30',uid='TEST_ONLY',joint_names=list(profile.command.names),online=True) - callback[0](json.dumps(info));assert adapter.health().connected - callback[0](json.dumps(dict(info,joint_faults={'thumb_tip':['执行器过流']}))) - assert adapter.health().active_faults == ('thumb_tip:执行器过流',) - assert not adapter.health().safe - callback[0](json.dumps(dict(info,joint_faults={'thumb_tip':['灵巧手层判定堵转']}))) - assert adapter.health().safe # Firmware endpoint telemetry is retained in diagnostic. - callback[0](json.dumps(info)) - names=list(reversed(profile.command.names)); values=list(reversed(range(20))) - assert adapter.parse_feedback(names,values) == tuple(range(20)) - assert adapter.parse_feedback([],values) is None - assert adapter.parse_feedback(names[:-1]+[names[0]],values) is None - assert adapter.parse_feedback(names,[float('nan')]*20) is None - callback[0](json.dumps(dict(info,side='LEFT')));assert not adapter.health().connected - callback[0](json.dumps(dict(info,uid='CHANGED')));assert not adapter.health().connected - callback[0](json.dumps(info));now[0]=4.;assert not adapter.health().connected - topics=sdk_topics(profile) - assert topics.setting == '/cb_right_hand_setting_cmd' - assert topics.health == '/cb_right_hand_info' - params=launch_parameters() - assert params['comm_type']=='libcanbus' and params['canfd_device']==0 - assert not params['auto_init_pose'] and not params['is_touch'] - assert params['state_rate']==30. - - -def test_capture_uses_each_feedback_and_middle_only_observations(profile): - from linkerhand_calibration.runtime.capture import ObservationCapture, CaptureFrame - from linkerhand_calibration.runtime.reference_lock import ReferenceLock - from test_observation_capture import projected_square - lock = ReferenceLock({'front':0,'side':3,'top':16}) - lock.begin_session() - lock.start_locking() - capture = ObservationCapture(profile,reference_lock=lock, - extrinsics=SimpleNamespace(transform=lambda _:np.eye(4))) - task=next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - all_roles=['front_base','pinky_mcp_roll','ring_mcp_roll','middle_mcp_roll','index_mcp_roll'] - matrix=np.array([[1200,0,640],[0,1200,480],[0,0,1.]]) - corners={role:projected_square(.016,(.024*i, .02, .45)) for i,role in enumerate(all_roles)} - vector=list(profile.command.baseline_values) - vector[2:6]=[146.,171.,146.,146.] - feedback=list(vector);feedback[2:6]=[143.,169.,140.,141.] - motion=MotionCommand('sweep',tuple(vector),200,task.key,5,0,'increasing', - segment_key='linked_increasing',observed_joints=task.joints,measured_channels=(2,3,4,5)) - for stamp in range(1,15): - rows,_=capture.consume(CaptureFrame('front',stamp*40000000,matrix,corners,tuple(feedback),tuple(vector)),motion) - samples={r['joint']:r for r in rows if r['kind']=='joint_sample'} - assert set(samples)==set(task.joints) - for name,row in samples.items(): - channel=profile.command.command_index_by_joint[name] - assert row['motor_index']==channel - assert row['feedback_u8']==feedback[channel] - assert row['command_u8']==vector[channel] - assert row['segment_key']=='linked_increasing' - lower=replace(motion,segment_key='middle_lower_decreasing',direction='decreasing', - observed_joints=('middle_mcp_roll',),measured_channels=(3,),command_index=3) - visible={r:c for r,c in corners.items() if r in {'front_base','middle_mcp_roll'}} - rows,_=capture.consume(CaptureFrame('front',600000000,matrix,visible,tuple(feedback),tuple(vector)),lower) - assert {r['joint'] for r in rows if r['kind']=='joint_sample'}=={'middle_mcp_roll'} - rows,_=capture.consume(CaptureFrame('front',640000000,matrix,visible,tuple(feedback),tuple(vector)),motion) - assert {r['joint'] for r in rows if r['kind']=='joint_sample'}=={'middle_mcp_roll'} - - -def test_model_scope_replaces_only_prepared_roles_and_schedules_parent_dependencies(profile): - from linkerhand_calibration.runtime.capture import ObservationCapture - from linkerhand_calibration.runtime.reference_lock import ReferenceLock - from linkerhand_calibration.runtime.branch_initialization import MotionBranchModel - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation - capture=ObservationCapture(profile,reference_lock=ReferenceLock({'front':0,'side':3,'top':16}), - extrinsics=SimpleNamespace(transform=lambda _:np.eye(4))) - def model(task,joint,parent,child,identity): - return MotionBranchModel(task,'front',(SimpleNamespace(relation=MotionRelation(joint,parent,child)),),((child,identity),)) - roll=model('thumb_cmc_roll_front','thumb_cmc_roll','front_base','thumb_mcp','roll-id') - mcp=model('thumb_mcp_front','thumb_mcp','front_base','thumb_mcp','mcp-id') - capture.install_motion_model(roll) - with pytest.raises(ValueError,match='replacement_forbidden'): - capture.install_motion_model(mcp) - preparation=MotionCommand('zero_approach',profile.command.baseline_values,200, - 'thumb_mcp_front',reference_joints=('thumb_mcp',)) - capture.begin_motion(preparation) - assert not capture._motion_models - capture.install_motion_model(mcp) - capture.begin_motion(preparation) # Other waypoints of the same preparation cannot erase it. - assert list(capture._motion_models.values())==[mcp] - ip_preparation=replace(preparation,task_key='thumb_ip_front') - capture.begin_motion(ip_preparation) - parent=replace(mcp,task_name='thumb_ip_front',evidence_ids=(('thumb_mcp','parent-at-yaw-zero'),)) - child=model('thumb_ip_front','thumb_ip','thumb_mcp','thumb_ip','ip-id') - capture.install_motion_model(parent);capture.install_motion_model(child) - motion=MotionCommand('sweep',profile.command.baseline_values,200,'thumb_ip_front',15,0,'increasing') - assert capture._motion_model_schedule('front',motion)==(parent,child) - # Separately frozen four-finger models do not impose unrelated visibility. - group = next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - for name in group.joints: - capture.install_motion_model(model('fingers_mcp_roll_front',name,'front_base',name,name+'-id')) - lower=replace(motion,task_key='fingers_mcp_roll_front',segment_key='middle_lower_increasing', - observed_joints=('middle_mcp_roll',)) - assert len(capture._motion_model_schedule('front',lower))==1 - assert capture._motion_model_schedule('front',lower)[0].evidence_ids[0][0]=='middle_mcp_roll' - - -def test_old_or_misattributed_group_records_rejected(profile): - from linkerhand_calibration.runtime.acquisition import accepted_joint_records - task=next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - row=dict(joint='middle_mcp_roll',task_name=task.key,cycle=0,direction='increasing', - motor_index=3,feedback_u8=100.,command_u8=100.,image_stamp_ns=1,view='front', - relative_quaternion_xyzw=[0,0,0,1],command_vector_u8=[0.]*20) - with pytest.raises(ValueError,match='unknown motion segment'): - accepted_joint_records(profile,[row]) - row.update(segment_key='middle_lower_increasing',motor_index=5) - with pytest.raises(ValueError,match='motor or direction'): - accepted_joint_records(profile,[row]) - row.update(motor_index=3,command_vector_u8=[0,0,90,50]+[0]*16) - with pytest.raises(ValueError,match='multi-channel path'): - accepted_joint_records(profile,[row]) diff --git a/src/linkerhand_calibration/test/test_o30_side_observer.py b/src/linkerhand_calibration/test/test_o30_side_observer.py deleted file mode 100644 index 60ab895..0000000 --- a/src/linkerhand_calibration/test/test_o30_side_observer.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The recorded side failure reuses the front solver with a proximal Tag.""" -from dataclasses import asdict, replace -import gzip -import json -from pathlib import Path - -from linkerhand_calibration.core.geometry.tag_pose.ippe import solve_square_tag_ippe -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation, RoleCandidates -from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageRoleObservation -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion -from linkerhand_calibration.runtime.diagnostic_analysis import image_frames -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 - - -def test_same_recorded_mcp_arc_resolves_with_middle_link_tag(): - path = Path(__file__).parent/'fixtures/o30_side_mcp_observers.json.gz' - data = json.loads(gzip.decompress(path.read_bytes())) - rows = sorted(data['original_records'], key=lambda r: r['image_stamp_ns']) - assert len(rows) == 129 - for row in rows: - assert source_frame_sha256(row) == data['original_frame_hashes'][str(row['image_stamp_ns'])] - original = image_frames(rows, (MotionRelation('pinky_mcp_pitch', 'side_base', 'pinky_dip'),)) - failed = resolve_image_motion(original, (MotionRelation('pinky_mcp_pitch', 'side_base', 'pinky_dip'),)) - assert not failed.resolved and failed.reason == 'image_motion_families_not_distinguishable' - changed = [] - for frame in original: - tag = data['secondary_corners'][str(frame.evidence.stamp_ns)] - assert tag['tag_id'] == 4 - corners = tuple(map(tuple, tag['corners_xy'])) - candidates = tuple(solve_square_tag_ippe(corners, tag_size_m=tag['tag_size_m'], - camera_matrix=frame.camera_matrix)) - changed.append(replace(frame, evidence=replace(frame.evidence, roles=(frame.evidence.roles[0], - RoleCandidates('pinky_pip', candidates))), observations=(frame.observations[0], - ImageRoleObservation('pinky_pip', tag['tag_size_m'], corners)))) - result = resolve_image_motion(changed, (MotionRelation('pinky_mcp_pitch', 'side_base', 'pinky_pip'),)) - assert result.resolved, result - assert len(result.hypotheses) == 2 - 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) - winner = next(h for h in result.hypotheses if h.branches == result.model.branches) - assert winner.validation_quality[0].rms_px < .37 - assert max(p for _, p in winner.comparison_adjusted_p_values) < .01 - - # The real preparation owner must select the same recorded observations, - # including held-channel guards and the stationary side reference. - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from o30_capture_fixture import SOURCE - profile = load_bundled_hand_profile('o30_right_18') - owner = BranchInitialization(profile, source_urdf=SOURCE) - spec = profile.motion.joint_zero_references['pinky_mcp_pitch'] - epoch, version = rows[0]['session_epoch'], rows[0]['motion_version'] - motion = MotionCommand('zero_approach', spec.command, 200., task_key=spec.task_key, - reference_joints=('pinky_mcp_pitch',)) - owner.begin(motion, session_epoch=epoch, motion_version=version) - for row, frame in zip(rows, changed): - tag = data['secondary_corners'][str(frame.evidence.stamp_ns)] - item = {**tag, 'tag_role': 'pinky_pip', 'selected_pose': None, - 'reprojection_valid_candidates': [asdict(p) for p in frame.evidence.roles[1].candidates], - 'candidate_diagnostics': {'branch_frozen': False, 'observation_stamp_ns': frame.evidence.stamp_ns}} - owner.observe({**row, 'tags': {**row['tags'], 'pinky_pip': item}}) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=('pinky_mcp_pitch',)), - session_epoch=epoch, motion_version=version+1) - assert not request.invalid_reason - assert len(request.image_frames) == 129 - assert request.relations == (MotionRelation('pinky_mcp_pitch', 'side_base', 'pinky_pip'),) - assert solve_initialization(request).resolved diff --git a/src/linkerhand_calibration/test/test_o6_right_profile.py b/src/linkerhand_calibration/test/test_o6_right_profile.py deleted file mode 100644 index 5976231..0000000 --- a/src/linkerhand_calibration/test/test_o6_right_profile.py +++ /dev/null @@ -1,377 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.calibrated_joint_state_bridge import CalibratedCommandMapper -from linkerhand_calibration.core import validate_profile -from linkerhand_calibration.extrinsics import matrix_payload, transform_matrix -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.artifacts import ( - validate_o6_runtime_payload, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.fitting import _zero_profile -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.profile import ( - ACTIVE_JOINTS, - BASELINE_SPEED_U8, - COMMAND_NAMES, - FORMAL_SPEED_U8, - GEOMETRIC_ZERO_JOINTS, - KEY, - MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M, - MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX, - MAXIMUM_HYSTERESIS_DEG, - MIMIC_SOURCE_BY_JOINT, - PASSIVE_JOINTS, - PREFLIGHT_SPEED_U8, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, - build_typed_profile, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.runner import render_o6_progress_zh - - -PACKAGE = Path(__file__).resolve().parents[1] -SOURCE = PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf" -LEFT_SOURCE = PACKAGE / "urdf/o6_left/linkerhand_o6_left.urdf" -KINEMATIC_MODEL = UrdfKinematicModel(SOURCE) - - -def _joint_elements(path: Path) -> dict[str, ET.Element]: - return { - str(joint.get("name")): joint - for joint in ET.parse(path).getroot().findall("joint") - if joint.get("type") == "revolute" - } - - -def test_o6_profile_declares_reviewed_six_channel_contract() -> None: - profile = build_typed_profile() - validate_profile(profile) - assert profile.key == KEY - assert profile.namespace == "/o6_calibration" - assert profile.command.names == COMMAND_NAMES - assert profile.command.baseline_u8 == (255,) * 6 - assert profile.command.feedback_name_aliases == {} - assert profile.motion.speed_parameters["baseline_u8"] == BASELINE_SPEED_U8 == 80 - assert profile.motion.speed_parameters["preflight_u8"] == PREFLIGHT_SPEED_U8 == 60 - assert profile.motion.speed_parameters["formal_u8"] == FORMAL_SPEED_U8 == 40 - assert MAXIMUM_HYSTERESIS_DEG == 3.5 - assert MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX == 1.5 - assert MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M == 0.020 - assert GEOMETRIC_ZERO_JOINTS == frozenset(profile.zero.direct_zero_joints) - assert not profile.zero.endpoint_anchor_by_joint - assert profile.vision.extrinsics_quality_limits[ - "reprojection_rms_px" - ] == 1.5 - assert _zero_profile().directed_base_axis_joints == frozenset({ - "rh_thumb_cmc_yaw", "rh_pinky_mcp_pitch" - }) - assert all( - task.preflight_speed_u8 == PREFLIGHT_SPEED_U8 - and task.formal_speed_u8 == FORMAL_SPEED_U8 - for task in profile.motion.tasks - ) - assert profile.vision.tag_ids == frozenset(range(8)) - assert [task.key for task in profile.motion.tasks] == [ - "thumb_yaw_top", "thumb_pitch_ip_front", "pinky_pitch_dip_side" - ] - assert profile.zero.coupling_model_by_joint == { - name: "linear_mimic" for name in PASSIVE_JOINTS - } - - -def test_o6_profile_matches_source_urdf_topology() -> None: - root = ET.parse(SOURCE).getroot() - joints = { - str(joint.get("name")): joint - for joint in root.findall("joint") - if joint.get("type") == "revolute" - } - assert set(joints) == set(ACTIVE_JOINTS + PASSIVE_JOINTS) - for target, source in MIMIC_SOURCE_BY_JOINT.items(): - assert joints[target].find("mimic").get("joint") == source - assert all( - joints[target].find("limit").get("upper") - == joints[source].find("limit").get("upper") - for target, source in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items() - ) - assert all( - joints[target].find("limit").get("upper") - == joints[source].find("limit").get("upper") - for target, source in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items() - ) - - -def test_o6_progress_uses_the_shared_operator_layout() -> None: - text = render_o6_progress_zh({ - "state": "RUNNING", - "serial_number": "O6_TEST", - "step_index": 12, - "step_count": 61, - "step_fraction": 0.5, - "task_name": "thumb_yaw_top", - "phase": "sweep", - "cycle": 2, - "direction": "decreasing", - "attempt": 1, - "target_u8": 0, - "current_command_u8": 127.0, - "actual_u8": 128.0, - "valid_frames": 45, - "total_frames": 48, - "tag_detection_rate": 0.96, - "joint_frame_rate": 0.9375, - "recognized_tag_ids": [6, 7], - "unrecognized_tag_ids": [], - "feedback_hz": 29.8, - }) - assert "[O6_TEST] 标定中" in text - assert "拇指 CMC yaw(上面机位,ID6→ID7)" in text - assert "阶段:正式扫描(第 3/4 轮)" in text - assert "方向:递减" in text - assert "已识别 ID6/ID7" in text - - -def test_o6_baseline_failure_identifies_the_slowest_channel() -> None: - text = render_o6_progress_zh({ - "state": "PAUSED", - "reason": "mechanical_stall:baseline:all", - "serial_number": "O6_TEST", - "step_index": 0, - "step_count": 61, - "step_fraction": 0.25, - "phase": "baseline", - "cycle": None, - "direction": None, - "attempt": 1, - "target_u8": 255, - "speed_u8": BASELINE_SPEED_U8, - "command_trajectory_full_range_seconds": 6.0, - "current_command_u8": 249.381, - "actual_u8": 233.3, - "valid_frames": 0, - "total_frames": 0, - "feedback_hz": 58.9, - "command_names": list(COMMAND_NAMES), - "latest_state_u8": [255, 125, 255, 255, 255, 255], - "maximum_error_channel": "thumb_cmc_yaw", - "maximum_error_u8": 130.0, - }) - assert "六路反馈:" in text - assert "运动:速度档 80;全行程 6.0 秒余弦轨迹" in text - assert "thumb_cmc_yaw=125.0" in text - assert "最大偏差:thumb_cmc_yaw=130.0" in text - assert "原因:目标电机连续两秒没有向目标推进" in text - - -def _valid_payload() -> dict: - profile = build_typed_profile() - curves = list(np.linspace(1.0, 0.0, 256)) - joints = {} - for name in ACTIVE_JOINTS + PASSIVE_JOINTS: - source = MIMIC_SOURCE_BY_JOINT.get(name, name) - motor = profile.command.command_index_by_joint[source] - passive = name in PASSIVE_JOINTS - item = { - "urdf_joint": name, - "sdk_channel": COMMAND_NAMES[motor], - "motor_index": motor, - "passive": passive, - "calibration_status": profile.joint_coverage[name], - "zero_command_u8": 255, - "zero_angles": {"policy": "test"}, - "angle_rad": curves, - "decreasing_rad": curves, - "increasing_rad": curves, - } - if passive: - item.update({ - "source_joint": source, - "mimic_offset_rad": 0.0, - "coupling_model": "quadratic_runtime", - "coupling_coefficients": [0.0, 1.0, 0.0, 0.0, 0.0, 0.0], - "urdf_mimic_enabled": True, - "urdf_mimic_policy": "endpoint_linear_fallback", - "mimic_multiplier": 1.0, - }) - donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name) or TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name) - if donor: - item["transferred_from_joint"] = donor - joints[name] = item - return { - "schema_version": 6, - "profile_id": KEY.profile_id, - "layout_id": KEY.layout, - "model": "O6", - "side": "right", - "serial_number": "O6_TEST", - "calibration_scope": "partial", - "publication_pointer": "latest_partial_passed", - "angle_unit": "rad", - "command_range": [0, 255], - "curve_input_domain": "feedback_u8", - "runtime_curve_policy": "direction_aware", - "command_names": list(COMMAND_NAMES), - "baseline_command_u8": [255] * 6, - "protected_inputs": { - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - }, - "joints": joints, - "quality": {"passed": True, "scope": "partial"}, - } - - -def test_o6_schema_v6_and_bridge_use_native_yaw_channel() -> None: - payload = _valid_payload() - validate_o6_runtime_payload(payload) - mapper = CalibratedCommandMapper(payload, expected_side="right") - assert mapper.feedback_name_aliases == {} - assert mapper.command_names == COMMAND_NAMES - assert len(mapper.map_positions([255] * 6, COMMAND_NAMES)) == 11 - mismatched = {**payload, "model": "L6"} - with pytest.raises(ValueError, match="identity or command contract"): - CalibratedCommandMapper(mismatched) - - -def _curve_records(travel: float) -> list[dict]: - rows = [] - for cycle in range(4): - for direction, commands in ( - ("decreasing", range(255, -1, -4)), - ("increasing", range(0, 256, 4)), - ): - values = list(commands) - endpoint = 0 if direction == "decreasing" else 255 - if values[-1] != endpoint: - values.append(endpoint) - for feedback in values: - angle = travel * (255.0 - feedback) / 255.0 - rows.append({ - "cycle": cycle, - "direction": direction, - "command_u8": feedback, - "feedback_u8": float(feedback), - "relative_quaternion_xyzw": Rotation.from_rotvec( - [angle, 0.0, 0.0] - ).as_quat().tolist(), - }) - return rows - - - - -def _pose(xyz: list[float], rpy: list[float]) -> np.ndarray: - return transform_matrix(xyz, Rotation.from_euler("xyz", rpy).as_quat()) - - -def _geometric_records() -> dict[str, list[dict]]: - model = KINEMATIC_MODEL - travels = { - "rh_thumb_cmc_pitch": 0.5, - "rh_thumb_cmc_yaw": 1.2, - "rh_pinky_mcp_pitch": 1.2, - } - offsets = { - "rh_thumb_cmc_pitch": -0.02, - "rh_thumb_cmc_yaw": 0.03, - "rh_pinky_mcp_pitch": 0.0, - } - views = { - "rh_thumb_cmc_pitch": "front", - "rh_thumb_cmc_yaw": "top", - "rh_thumb_ip": "front", - "rh_pinky_mcp_pitch": "side", - "rh_pinky_dip": "side", - } - motors = { - "rh_thumb_cmc_pitch": 0, - "rh_thumb_cmc_yaw": 1, - "rh_thumb_ip": 0, - "rh_pinky_mcp_pitch": 5, - "rh_pinky_dip": 5, - } - passive_sources = { - "rh_thumb_ip": "rh_thumb_cmc_pitch", - "rh_pinky_dip": "rh_pinky_mcp_pitch", - } - base = _pose([0.11, -0.04, 0.72], [0.11, -0.08, 0.17]) - base_mounts = { - "front": _pose([0.0, 0.008, 0.055], [0.2, -0.1, 0.3]), - "side": _pose([-0.01, 0.002, 0.045], [-0.1, 0.2, -0.2]), - "top": _pose([0.012, -0.006, 0.05], [0.1, 0.3, 0.15]), - } - link_mounts = { - "rh_thumb_cmc_yaw": _pose([0.012, 0.019, 0.006], [0.2, 0.1, -0.1]), - "rh_thumb_cmc_pitch": _pose([0.009, -0.028, 0.011], [-0.2, 0.1, 0.25]), - "rh_thumb_ip": _pose([0.006, -0.025, 0.018], [0.15, -0.1, 0.2]), - "rh_pinky_mcp_pitch": _pose([0.006, 0.004, 0.021], [-0.1, 0.2, 0.1]), - "rh_pinky_dip": _pose([0.004, 0.003, 0.019], [0.12, 0.08, -0.2]), - } - result = {name: [] for name in views} - for name, view in views.items(): - for cycle in range(4): - for direction, commands in ( - ("decreasing", range(255, -1, -8)), - ("increasing", range(0, 256, 8)), - ): - values = list(commands) - endpoint = 0 if direction == "decreasing" else 255 - if values[-1] != endpoint: - values.append(endpoint) - for feedback in values: - state = [255.0] * 6 - state[motors[name]] = float(feedback) - active = { - joint: travel * (255.0 - state[motor]) / 255.0 - for joint, travel, motor in ( - ("rh_thumb_cmc_pitch", 0.5, 0), - ("rh_thumb_cmc_yaw", 1.2, 1), - ("rh_pinky_mcp_pitch", 1.2, 5), - ) - } - angles = { - **active, - "rh_thumb_ip": active["rh_thumb_cmc_pitch"] * 1.86, - "rh_pinky_dip": active["rh_pinky_mcp_pitch"] * 0.89, - } - if name in passive_sources: - parent_name = passive_sources[name] - parent = base @ model.link_transform( - parent_name, - zero_offsets=offsets, - joint_angles=angles, - independent_mimic_angles=True, - ) @ link_mounts[parent_name] - else: - parent = base @ base_mounts[view] - child = base @ model.link_transform( - name, - zero_offsets=offsets, - joint_angles=angles, - independent_mimic_angles=True, - ) @ link_mounts[name] - relative = np.linalg.inv(parent) @ child - result[name].append({ - "cycle": cycle, - "direction": direction, - "command_u8": feedback, - "feedback_u8": float(feedback), - "relative_quaternion_xyzw": Rotation.from_matrix( - relative[:3, :3] - ).as_quat().tolist(), - "relative_translation_xyz_m": relative[:3, 3].tolist(), - "parent_pose_common": matrix_payload(parent), - "child_pose_common": matrix_payload(child), - "view_normal_common_xyz": [0.577350269] * 3, - "camera_center_common_xyz_m": [0.0, 0.0, -0.5], - "state_u8": state, - }) - return result diff --git a/src/linkerhand_calibration/test/test_o6_tag_installation.py b/src/linkerhand_calibration/test/test_o6_tag_installation.py deleted file mode 100644 index d489917..0000000 --- a/src/linkerhand_calibration/test/test_o6_tag_installation.py +++ /dev/null @@ -1,25 +0,0 @@ -"""The top thumb Tag measures yaw but is physically on the distal link.""" - -from pathlib import Path - -import numpy as np -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.profiles.observations import compile_tag_links - - -def test_top_thumb_tag_shares_the_distal_link_and_follows_flexion(): - package=Path(__file__).resolve().parents[1] - config=load_product_config(package/"config/o6_right_product.yaml",check_can=False) - profile=config.calibration_contract.typed_profile - model=UrdfKinematicModel(config.source_urdf) - links=compile_tag_links(profile,model) - assert links["thumb_yaw"]==links["thumb_ip"]=="rh_thumb_distal" - joint=model.parent_joint_by_child[links["thumb_yaw"]] - before=model.link_transform(joint,zero_offsets={},joint_angles={},independent_mimic_angles=True) - angles={"rh_thumb_cmc_yaw":0.,"rh_thumb_cmc_pitch":.35,"rh_thumb_ip":.45} - after=model.link_transform(joint,zero_offsets={},joint_angles=angles,independent_mimic_angles=True) - assert np.linalg.norm(after[:3,3]-before[:3,3])>.01 - assert Rotation.from_matrix(before[:3,:3].T@after[:3,:3]).magnitude()>.3 diff --git a/src/linkerhand_calibration/test/test_observation_capture.py b/src/linkerhand_calibration/test/test_observation_capture.py deleted file mode 100644 index 6afb19e..0000000 --- a/src/linkerhand_calibration/test/test_observation_capture.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Independent pinhole projections exercise the real PnP/capture boundary.""" - -from dataclasses import replace -from types import SimpleNamespace - -import numpy as np -from scipy.spatial.transform import Rotation -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.capture import CaptureFrame, ObservationCapture -from linkerhand_calibration.runtime.reference_lock import ReferenceLock -from linkerhand_calibration.runtime.motion_execution import MotionCommand - - -def projected_square(size, translation, rpy=(.1, -.15, .03)): - # IPPE square order: top-left, top-right, bottom-right, bottom-left. - s = size/2 - points = np.array([[-s, s, 0], [s, s, 0], [s, -s, 0], [-s, -s, 0]]) - xyz = points @ Rotation.from_euler("xyz", rpy).as_matrix().T + translation - return (xyz[:, :2]/xyz[:, 2:])*1200 + [640, 480] - - -def make_capture(): - profile = load_bundled_hand_profile("l6_right_8") - front = profile.vision.views[0] - tags = tuple(replace(tag, size_m=.024 if tag.role == "thumb_pitch" else .016) for tag in front.tags) - profile = replace(profile, vision=replace(profile.vision, - views=(replace(front, tags=tags),)+profile.vision.views[1:])) - lock = ReferenceLock({v.name: next(t.tag_id for t in v.tags if t.fixed_reference) for v in profile.vision.views}) - capture = ObservationCapture(profile, reference_lock=lock, - extrinsics=SimpleNamespace(transform=lambda _: np.eye(4))) - return profile, lock, capture - - -def frame(profile, stamp, *, omit=(), shift=0, feedback=True): - roles = {"front_base": (.0, .0, .45), "thumb_pitch": (.05, .03, .45), "thumb_dip": (.08, .05, .45)} - sizes = {tag.role: tag.size_m for tag in profile.vision.views[0].tags} - corners = {role: projected_square(sizes[role], xyz)+[shift, 0] for role, xyz in roles.items() if role not in omit} - values = tuple(map(float, profile.command.baseline_values)) - return CaptureFrame("front", stamp*40_000_000, np.array([[1200, 0, 640], [0, 1200, 480], [0, 0, 1]]), - corners, values if feedback else None, values, command_directions=("increasing",)*len(values)) - - -def test_only_ten_unique_post_start_valid_frames_lock_and_mounts_keep_per_tag_size(): - profile, lock, capture = make_capture() - for index in range(20): - capture.consume(frame(profile, index+1)) - assert not lock.references # Preview is not a reference. - lock.begin_session() - capture.reset() - lock.start_locking() - for _ in range(20): - capture.consume(frame(profile, 100), collect_installations=True) - assert not lock.references - # Five independent images confirm a branch; ten accepted images lock it. - for index in range(13): - capture.consume(frame(profile, 101+index), collect_installations=True) - fixed, moving = capture.fingerprint_poses() - assert "front" in fixed - np.testing.assert_allclose(fixed["front"]["translation_xyz_m"], [0, 0, .45], atol=1e-7) - assert "front:thumb_pitch" in moving - assert len(capture.installations["front:thumb_pitch"]) == 10 - - -def test_short_visual_loss_discards_frames_cached_base_is_not_fake_image_data(): - profile, lock, capture = make_capture() - lock.begin_session() - lock.start_locking() - for i in range(14): - capture.consume(frame(profile, i+1), collect_installations=True) - motion = MotionCommand("sweep", tuple(profile.command.baseline_values), 1, - "thumb_pitch_dip_front", 0, 0, "decreasing") - rows, moved = capture.consume(frame(profile, 20, omit=("front_base",)), motion) - assert moved is None and len(rows) == 2 - assert rows[0]["locked_reference_roles"] == ["front_base"] - assert "image_relative_xy_px" not in rows[0] - assert capture.consume(frame(profile, 21, feedback=False), motion)[0] == () - assert capture.consume(frame(profile, 22, omit=("thumb_pitch",)), motion)[0] == () - assert len(capture.consume(frame(profile, 23), motion)[0]) == 2 - assert len(capture.installations["front:thumb_pitch"]) == 10 # Frozen during motion. - - -@pytest.mark.parametrize("drift", [6, 200]) -def test_fixed_reference_displacement_requires_ten_confirming_images(drift): - profile, lock, capture = make_capture() - lock.begin_session() - lock.start_locking() - for i in range(14): - capture.consume(frame(profile, i+1)) - for i in range(9): - assert capture.consume(frame(profile, 20+i, shift=drift))[1] is None - assert capture.consume(frame(profile, 29, shift=drift))[1] is not None - - -def test_joint_zero_only_requires_its_own_parent_child_tags(): - from linkerhand_calibration.core.domain.reference import build_joint_zero_reference - profile, lock, capture = make_capture() - lock.begin_session() - lock.start_locking() - for i in range(14): - capture.consume(frame(profile, i+1)) - name = "rh_thumb_cmc_pitch" - spec = profile.motion.joint_zero_references[name] - motion = MotionCommand("joint_zero", spec.command, 1, task_key=spec.task_key, - command_index=0, cycle=0, zero_joints=(name,)) - rows = [] - for i in range(10): - samples, movement = capture.consume(frame(profile, 20+i, omit=("thumb_dip",)), motion) - assert movement is None - rows.extend(samples) - assert len(rows) == 10 and all(row["kind"] == "joint_zero_sample" for row in rows) - reference = build_joint_zero_reference(profile, name, rows, session_epoch=1, motion_version=2) - assert reference.joint == name and len(reference.samples) == 10 - - -def test_articulated_capture_discards_partial_moving_tags_and_records_cached_base(): - profile, lock, capture = make_capture() - profile = replace(profile, measurement=replace(profile.measurement, - candidate_selection_tasks=frozenset({"thumb_pitch_dip_front"}))) - capture.profile = profile - lock.begin_session() - lock.start_locking() - for i in range(14): - capture.consume(frame(profile, i+1)) - motion = MotionCommand("sweep", profile.command.baseline_values, 1, - "thumb_pitch_dip_front", 0, 0, "decreasing") - assert capture.consume(frame(profile, 20, omit=("thumb_dip",)), motion)[0] == () - samples, _ = capture.consume(frame(profile, 21, omit=("front_base",)), motion) - candidate = next(r for r in samples if r["kind"] == "pnp_candidate_frame") - assert candidate["roles"]["front_base"]["locked_reference"] is True - assert candidate["roles"]["front_base"]["corners_xy"] is None - assert candidate["roles"]["front_base"]["selected"] diff --git a/src/linkerhand_calibration/test/test_observation_feedback_dependencies.py b/src/linkerhand_calibration/test/test_observation_feedback_dependencies.py deleted file mode 100644 index 8059f08..0000000 --- a/src/linkerhand_calibration/test/test_observation_feedback_dependencies.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Tag mounting topology, rather than hand identity, bounds geometry holds.""" -from pathlib import Path - -import pytest -import yaml - -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.observations import compile_tag_feedback_channels -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization -from test_branch_initialization import arrival - - -PACKAGE = Path(__file__).resolve().parents[1] - - -def product_model(model): - product = yaml.safe_load((PACKAGE/f'config/{model}_right_product.yaml').read_text()) - source = PACKAGE/product['artifacts']['source_urdf'].split('package://linkerhand_calibration/')[1] - return load_bundled_hand_profile(product['tag_layout']), UrdfKinematicModel(source) - - -@pytest.mark.parametrize('model', ['g20', 'l6', 'o6', 'o12', 'o30']) -def test_all_products_resolve_tag_dependencies_including_passive_sources(model): - profile, source = product_model(model) - dependencies = compile_tag_feedback_channels(profile, source) - for view in profile.vision.views: - for tag in view.tags: - assert tag.role in dependencies - assert dependencies[tag.role] <= set(range(profile.command.command_count)) - if not tag.fixed_reference: - assert dependencies[tag.role] - - -def test_shared_proximal_tag_depends_on_all_upstream_axes_but_not_ip(): - profile, source = product_model('o30') - dependencies = compile_tag_feedback_channels(profile, source) - assert dependencies['thumb_mcp'] == {0, 1, 6} - assert dependencies['thumb_ip'] == {0, 1, 6, 15} - assert dependencies['front_base'] == set() - - -@pytest.mark.parametrize('channel, invalid', [(15, False), (5, False), (6, True), (1, True)]) -def test_only_feedback_that_can_move_observed_tag_invalidates_preparation(channel, invalid): - profile, source = product_model('o30') - approach, zero, rows = arrival(profile) - owner = BranchInitialization(profile, image_geometry=False, source_urdf=source.source) - owner.begin(approach, session_epoch=1, motion_version=7) - for index, row in enumerate(rows): - if index >= len(rows)//2: - row['feedback_vector'][channel] += 2. - owner.observe(row) - owner.begin(zero, session_epoch=1, motion_version=8) - request = owner.request('front', zero, session_epoch=1, motion_version=8) - assert (request.invalid_reason == 'motion_held_feedback_changed') is invalid - if not invalid: - assert not request.invalid_reason - assert request.frames - # Out-of-chain feedback is still retained verbatim as diagnostic evidence. - assert any(row['feedback_vector'][channel] != row['command_vector'][channel] - for row in owner._source_records['front'].values()) - - -def test_unrelated_command_change_still_rejects_wrong_avoidance_pose(): - profile, source = product_model('o30') - approach, zero, rows = arrival(profile) - owner = BranchInitialization(profile, image_geometry=False, source_urdf=source.source) - owner.begin(approach, session_epoch=1, motion_version=7) - for row in rows: - row['command_vector'][5] += 1. - owner.observe(row) - owner.begin(zero, session_epoch=1, motion_version=8) - request = owner.request('front', zero, session_epoch=1, motion_version=8) - assert not request.frames diff --git a/src/linkerhand_calibration/test/test_observation_strategy.py b/src/linkerhand_calibration/test/test_observation_strategy.py deleted file mode 100644 index 0f23adf..0000000 --- a/src/linkerhand_calibration/test/test_observation_strategy.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Measured and explicitly reused motions share one profile contract.""" - -from dataclasses import replace -import hashlib -from pathlib import Path -import xml.etree.ElementTree as ET - -import pytest -import yaml - -from linkerhand_calibration.core.domain.profile import ProfileKey, validate_profile -from linkerhand_calibration.profiles import dump_hand_profile, load_bundled_hand_profile, load_hand_profile -from linkerhand_calibration.profiles.validator import validate_executable_profile - - -PACKAGE = Path(__file__).resolve().parents[1] -PROFILES = ( - ("o6_right_8", "o6_right/linkerhand_o6_right.urdf", 6, ()), - ("l6_right_8", "l6_right/linkerhand_l6v3.1_right.urdf", 6, ()), - ("o12_right_16", "o12_right/linkerhand_o12_t3_right-0703.urdf", 3, ()), - ("g20_right_19", "g20_right/linkerhand_g20_right.urdf", 0, ()), -) - - -@pytest.mark.parametrize("layout,urdf,transfer_count,geometry_gaps", PROFILES) -def test_common_strategy_retains_measured_coverage_and_explicit_gaps(layout, urdf, transfer_count, geometry_gaps): - profile = load_bundled_hand_profile(layout) - report = validate_executable_profile(profile, PACKAGE / "urdf" / urdf) - coverage = report["independent_measurement_coverage"] - transfers = profile.measurement.transferred_motion_sources - assert len(transfers) == transfer_count - assert coverage["unmeasured_joints"] == sorted(transfers) - assert coverage["unresolved_motion_joints"] == [] - assert coverage["transferred_motion_sources"] == transfers - assert coverage["missing_baseline_geometry"] == list(geometry_gaps) - observed = {name for task in profile.motion.tasks for name in task.joints} - assert set(profile.motion.joint_zero_references) == observed - assert not set(transfers) & observed - assert set(transfers.values()) <= observed - assert not set(profile.zero.cad_zero_assumptions) & set(profile.zero.known_baseline_geometry) - product = yaml.safe_load((PACKAGE / "config" / f"{profile.key.model.lower()}_right_product.yaml").read_text()) - assert product["profile_config_sha256"] == hashlib.sha256( - (PACKAGE / "config" / "profiles" / f"{layout}.yaml").read_bytes()).hexdigest() - from linkerhand_calibration.profiles.validator import require_release_geometry - require_release_geometry(report) - assert profile.acquisition.command_capture_mode == "separate" - - -def test_old_geometry_transfer_does_not_authorize_copying_motion(): - profile = load_bundled_hand_profile("o6_right_8") - profile = replace(profile, measurement=replace(profile.measurement, transferred_motion_sources={})) - coverage = validate_executable_profile(profile, - PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf")["independent_measurement_coverage"] - assert len(coverage["unresolved_motion_joints"]) == 6 - assert coverage["transferred_motion_sources"] == {} - assert set(coverage["missing_baseline_geometry"]) == set(profile.zero.transferred_zero_sources) - from linkerhand_calibration.profiles.validator import require_release_geometry - with pytest.raises(ValueError, match="release_geometry_incomplete"): - require_release_geometry({"independent_measurement_coverage": coverage}) - - -@pytest.mark.parametrize("target,source", [ - ("missing_joint", "rh_pinky_mcp_pitch"), - ("rh_index_mcp_pitch", "missing_joint"), - ("rh_index_mcp_pitch", "rh_middle_mcp_pitch"), - ("rh_pinky_mcp_pitch", "rh_pinky_mcp_pitch"), - ("rh_thumb_cmc_pitch", "rh_pinky_mcp_pitch"), - ("rh_index_mcp_pitch", "rh_pinky_dip"), - ("rh_index_dip", "rh_thumb_ip"), -]) -def test_invalid_donors_types_and_chains_are_rejected(target, source): - profile = load_bundled_hand_profile("o6_right_8") - profile = replace(profile, measurement=replace(profile.measurement, - transferred_motion_sources={**profile.measurement.transferred_motion_sources, target: source})) - with pytest.raises(ValueError, match="motion"): - validate_profile(profile) - - -@pytest.mark.parametrize("field,value", [ - ("baseline_u8", 254), ("lower_bounds", 1), ("upper_bounds", 254), - ("sdk_to_joint_direction", 1), -]) -def test_recipient_domain_baseline_and_sign_must_match_donor(field, value): - profile = load_bundled_hand_profile("o6_right_8") - original = getattr(profile.command, field) - if not original: - original = profile.command.minimum_values if field == "lower_bounds" else profile.command.maximum_values - changed = list(original) - changed[2] = value - profile = replace(profile, command=replace(profile.command, **{field: tuple(changed)})) - with pytest.raises(ValueError, match="motion transfer requires equal SDK"): - validate_profile(profile) - - -def test_native_recipient_range_cannot_silently_rescale_donor_curve(): - profile = load_bundled_hand_profile("o12_right_16") - upper = list(profile.command.upper_bounds) - upper[10] -= 0.01 - profile = replace(profile, command=replace(profile.command, upper_bounds=tuple(upper))) - with pytest.raises(ValueError, match="motion transfer requires equal SDK"): - validate_profile(profile) - - -@pytest.mark.parametrize("field,delta", [ - ("feedback_lower_bounds", -0.01), ("feedback_upper_bounds", 0.01), -]) -def test_recipient_feedback_domain_must_match_donor_even_when_command_domain_matches(field, delta): - profile = load_bundled_hand_profile("o12_right_16") - original = (profile.command.minimum_feedback_values if field == "feedback_lower_bounds" - else profile.command.maximum_feedback_values) - changed = list(original) - changed[10] += delta - command = replace(profile.command, **{field: tuple(changed)}) - assert command.minimum_values[10] == command.minimum_values[11] - assert command.maximum_values[10] == command.maximum_values[11] - with pytest.raises(ValueError, match="motion transfer requires equal SDK"): - validate_profile(replace(profile, command=command)) - - -def test_recipient_cad_mimic_sign_is_checked(tmp_path): - profile = load_bundled_hand_profile("o6_right_8") - tree = ET.parse(PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf") - mimic = tree.getroot().find("joint[@name='rh_index_dip']/mimic") - mimic.set("multiplier", "-1") - source = tmp_path / "reversed_mimic.urdf" - tree.write(source) - with pytest.raises(ValueError, match="equal CAD output signs"): - validate_executable_profile(profile, source) - - -@pytest.mark.parametrize("joint,reason", [ - ("missing", "accepted"), ("rh_pinky_dip", " "), - ("rh_thumb_cmc_pitch", "accepted"), ("rh_index_mcp_pitch", "accepted"), -]) -def test_cad_assumptions_cannot_hide_invalid_or_conflicting_zero_sources(joint, reason): - profile = load_bundled_hand_profile("o6_right_8") - profile = replace(profile, zero=replace(profile.zero, - cad_zero_assumptions={**profile.zero.cad_zero_assumptions, joint: reason})) - with pytest.raises(ValueError, match="CAD zero assumption"): - validate_profile(profile) - - -def test_virtual_model_can_reorder_channels_views_and_tasks(tmp_path): - profile = load_bundled_hand_profile("o6_right_8") - order = (5, 3, 1, 4, 0, 2) - index = {old: new for new, old in enumerate(order)} - reorder = lambda row: tuple(row[i] for i in order) - views = {"front": "inspection_a", "side": "inspection_b", "top": "inspection_c"} - command = replace(profile.command, names=reorder(profile.command.names), - baseline_u8=reorder(profile.command.baseline_u8), - sdk_to_joint_direction=reorder(profile.command.sdk_to_joint_direction), - command_index_by_joint={name: index[channel] for name, channel in profile.command.command_index_by_joint.items()}) - motion = replace(profile.motion, - tasks=tuple(replace(task, view=views[task.view], command_index=index[task.command_index]) - for task in reversed(profile.motion.tasks)), - joint_zero_references={name: replace(reference, command=reorder(reference.command), - approach_commands=tuple(reorder(row) for row in reference.approach_commands)) - for name, reference in profile.motion.joint_zero_references.items()}) - vision = replace(profile.vision, views=tuple(replace(view, name=views[view.name]) - for view in reversed(profile.vision.views)), extrinsic_reference_view=views[profile.vision.extrinsic_reference_view]) - measurement = replace(profile.measurement, measurements={name: replace(spec, view=views[spec.view]) - for name, spec in profile.measurement.measurements.items()}) - virtual = replace(profile, key=ProfileKey("virtual", "right", "mixed_strategy"), - command=command, motion=motion, vision=vision, measurement=measurement) - path = tmp_path / "virtual.yaml" - path.write_text(dump_hand_profile(virtual)) - virtual = load_hand_profile(path) - report = validate_executable_profile(virtual, PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf") - assert not report["independent_measurement_coverage"]["unresolved_motion_joints"] - assert virtual.command.command_index_by_joint["rh_index_mcp_pitch"] == 5 - assert virtual.command.command_index_by_joint["rh_pinky_mcp_pitch"] == 0 diff --git a/src/linkerhand_calibration/test/test_observation_timing.py b/src/linkerhand_calibration/test/test_observation_timing.py deleted file mode 100644 index 25aed78..0000000 --- a/src/linkerhand_calibration/test/test_observation_timing.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Report image loss separately from visibility and synchronization failures.""" - -from copy import deepcopy - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.observation_timing import summarize_observation_timing - - -@pytest.fixture -def capture(): - 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") - tags = next(view.tags for view in profile.vision.views if view.name == "front") - rows = [] - for index in range(5): - stamp = 1_000_000_000 + index * 100_000_000 - rows.append({"kind": "diagnostic_observation_frame", "sample_phase": "sweep", - "task_name": unit.task_key, "cycle": unit.cycle, "direction": unit.direction, - "attempt": 1, "view": "front", "image_stamp_ns": stamp, - "command_unit": profile.command.unit, - "command_vector": list(profile.command.baseline_values), - "feedback_vector": list(profile.command.baseline_values), - "tags": {tag.role: {"tag_role": tag.role, "tag_id": tag.tag_id, - "corners_xy": [[10, 10], [50, 10], [50, 50], [10, 50]], - "candidate_diagnostics": {"observation_stamp_ns": stamp}} - for tag in tags if not tag.fixed_reference}}) - return profile, unit, rows - - -def ip_metrics(capture, rows=None): - profile, unit, original = capture - result = summarize_observation_timing(profile, unit, 1, original if rows is None else rows) - return result[f"joint:rh_thumb_ip:front:{unit.direction}"] - - -def test_current_moving_tags_are_visible_without_fixed_tag_or_selected_pose(capture): - before = deepcopy(capture[2]) - metrics = ip_metrics(capture) - assert metrics == {"raw_frames": 5, "visible_frames": 5, "synchronized_frames": 5, - "missing_command_frames": 0, "missing_feedback_frames": 0, - "maximum_image_gap_seconds": 0.1, "maximum_synchronized_gap_seconds": 0.1} - assert capture[2] == before - assert "passed" not in metrics - - -def test_command_and_feedback_gaps_do_not_disappear_into_image_counts(capture): - rows = capture[2] - rows[1]["command_vector"] = None - rows[2]["feedback_vector"] = None - rows[3]["command_vector"] = [] - rows[3]["feedback_vector"] = [float("nan")] * len(rows[3]["feedback_vector"]) - metrics = ip_metrics(capture) - assert metrics["raw_frames"] == metrics["visible_frames"] == 5 - assert metrics["synchronized_frames"] == 2 - assert metrics["missing_command_frames"] == metrics["missing_feedback_frames"] == 2 - assert metrics["maximum_image_gap_seconds"] == 0.1 - assert metrics["maximum_synchronized_gap_seconds"] == 0.4 - - -@pytest.mark.parametrize("role", ["thumb_pitch", "thumb_ip"]) -@pytest.mark.parametrize("fault", ["missing", "stale", "id", "role", "corners", "not_finite"]) -def test_both_moving_tags_need_current_correctly_bound_corners(capture, role, fault): - rows = capture[2] - for row in rows[1:4]: - item = row["tags"][role] - if fault == "missing": - del row["tags"][role] - elif fault == "stale": - item["candidate_diagnostics"]["observation_stamp_ns"] -= 1 - elif fault == "id": - item["tag_id"] = -1 - elif fault == "role": - item["tag_role"] = "unrelated" - elif fault == "corners": - item["corners_xy"] = None - else: - item["corners_xy"][0][0] = float("inf") - metrics = ip_metrics(capture) - assert metrics["raw_frames"] == 5 - assert metrics["visible_frames"] == metrics["synchronized_frames"] == 2 - assert metrics["missing_command_frames"] == metrics["missing_feedback_frames"] == 0 - assert metrics["maximum_synchronized_gap_seconds"] == 0.4 - - -def test_duplicate_images_are_counted_once_and_not_merged(capture): - profile, unit, rows = capture - original = deepcopy(rows) - assert ip_metrics(capture, rows + deepcopy(rows)) == ip_metrics(capture, original) - rows[2]["command_vector"] = None - duplicates = deepcopy(rows) - duplicates[2]["command_vector"] = list(profile.command.baseline_values) - duplicates[2]["feedback_vector"] = None - metrics = ip_metrics(capture, rows + duplicates) - assert metrics["raw_frames"] == 5 - assert metrics["synchronized_frames"] == 4 - assert metrics["missing_command_frames"] == 1 - assert metrics["maximum_synchronized_gap_seconds"] == 0.2 - - -@pytest.mark.parametrize("field,value", [ - ("kind", "joint_sample"), ("sample_phase", "joint_zero"), - ("task_name", "thumb_yaw_top"), ("cycle", 1), ("direction", "increasing"), - ("attempt", 2), ("view", "side"), ("image_stamp_ns", None), - ("image_stamp_ns", 0), ("image_stamp_ns", True), -]) -def test_other_scopes_and_invalid_image_identities_do_not_supply_frames(capture, field, value): - for row in capture[2]: - row[field] = value - metrics = ip_metrics(capture) - assert metrics["raw_frames"] == metrics["visible_frames"] == metrics["synchronized_frames"] == 0 - assert metrics["maximum_image_gap_seconds"] is None - assert metrics["maximum_synchronized_gap_seconds"] is None - - -@pytest.mark.parametrize("count", [0, 1]) -def test_fewer_than_two_images_cannot_establish_a_gap(capture, count): - metrics = ip_metrics(capture, capture[2][:count]) - assert metrics["raw_frames"] == count - assert metrics["maximum_image_gap_seconds"] is None - assert metrics["maximum_synchronized_gap_seconds"] is None - - -def test_incorrect_native_unit_is_not_a_synchronized_vector(capture): - for row in capture[2]: - row["command_unit"] = "rad" - metrics = ip_metrics(capture) - assert metrics["raw_frames"] == metrics["visible_frames"] == 5 - assert metrics["synchronized_frames"] == 0 - assert metrics["missing_command_frames"] == metrics["missing_feedback_frames"] == 5 - assert metrics["maximum_synchronized_gap_seconds"] is None diff --git a/src/linkerhand_calibration/test/test_observer_policy.py b/src/linkerhand_calibration/test/test_observer_policy.py deleted file mode 100644 index ace3b05..0000000 --- a/src/linkerhand_calibration/test/test_observer_policy.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Select observer strategies by dependencies without reinterpreting old passes.""" -from dataclasses import replace - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import STAGED, TASKWISE_TWO_ROUND, configure_training -from linkerhand_calibration.profiles import load_bundled_hand_profile, load_hand_profile -from linkerhand_calibration.profiles.export import write_hand_profile -from linkerhand_calibration.profiles.observer_policy import ( - configure_observer_policy, rigid_observer_dependencies, RIGID_OBSERVER, LEGACY_OBSERVER, -) -from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract -from linkerhand_calibration.profiles.validator import validate_executable_profile -from o30_capture_fixture import SOURCE - - -@pytest.fixture(params=[STAGED, TASKWISE_TWO_ROUND]) -def staged(request): - return configure_training(load_bundled_hand_profile('o30_right_18'), request.param) - - -def test_new_preparation_covers_every_independent_parent_hinge(staged, tmp_path): - old = configure_preparation(staged, 'occlusion_aware_witness_v1') - new = configure_preparation(staged) - dependencies = rigid_observer_dependencies(new) - assert dependencies == {'thumb_ip': 'thumb_mcp', **{ - f'{finger}_dip': f'{finger}_pip' for finger in ('pinky', 'ring', 'middle', 'index')}} - assert all(s.observer_pose_policy == LEGACY_OBSERVER for s in old.measurement.measurements.values()) - assert {j for j, s in new.measurement.measurements.items() - if s.observer_pose_policy == RIGID_OBSERVER} == dependencies.keys() - assert old.motion == new.motion and old.quality == new.quality - assert preparation_contract(old) != preparation_contract(new) - validate_executable_profile(new, SOURCE) - path = tmp_path/'new.yaml' - write_hand_profile(new, path) - assert load_hand_profile(path) == new - assert configure_observer_policy(new) == new - - -def test_passed_measurements_keep_their_original_strategy(staged): - preserved = set(staged.measurement.measurements) - {'index_dip'} - new = configure_observer_policy(staged, preserved_joints=preserved) - for joint in preserved: - assert new.measurement.measurements[joint] == staged.measurement.measurements[joint] - assert new.measurement.measurements['index_dip'].observer_pose_policy == RIGID_OBSERVER - assert new.motion == staged.motion - with pytest.raises(ValueError, match='unknown_preserved_joint'): - configure_observer_policy(staged, preserved_joints={'nonexistent'}) - - -def test_dependency_compilation_uses_roles_not_finger_names(staged): - # A valid-looking name cannot enable a disconnected observation. - specs = dict(staged.measurement.measurements) - specs['index_dip'] = replace(specs['index_dip'], parent_role='middle_pip') - changed = replace(staged, measurement=replace(staged.measurement, measurements=specs)) - assert 'index_dip' not in rigid_observer_dependencies(changed) - assert configure_observer_policy(changed).measurement.measurements['index_dip'].observer_pose_policy == LEGACY_OBSERVER - - -def test_unsupported_legacy_modes_are_not_silently_changed(): - old = load_bundled_hand_profile('o30_right_18') - assert rigid_observer_dependencies(old) == {} - assert configure_observer_policy(old) == old - - -def test_forced_strategy_without_independent_hinge_is_rejected(staged): - specs = dict(staged.measurement.measurements) - specs['index_mcp_pitch'] = replace(specs['index_mcp_pitch'], observer_pose_policy=RIGID_OBSERVER) - changed = replace(staged, measurement=replace(staged.measurement, measurements=specs)) - with pytest.raises(ValueError, match='rigid_pair_requires_independent_parent_hinge'): - validate_executable_profile(changed, SOURCE) diff --git a/src/linkerhand_calibration/test/test_observer_pose.py b/src/linkerhand_calibration/test/test_observer_pose.py deleted file mode 100644 index 07ad19d..0000000 --- a/src/linkerhand_calibration/test/test_observer_pose.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Independent observer images may constrain a family, never waive ambiguity.""" - -from dataclasses import replace -import math - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points, solve_square_tag_ippe -from linkerhand_calibration.core.geometry.tag_pose.observer_pose import fit_observer_pose -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - - -@pytest.fixture(scope='module') -def observer_images(): - matrix = np.array([[1600., 0, 640.], [0, 1600., 480.], [0, 0, 1.]]) - parents, candidates, corners = [], [], [] - relative = Rotation.identity() - translation = np.array([.045, .02, 0.]) - for theta in np.linspace(-35, 35, 48): - rotation = Rotation.from_euler('xy', [theta, theta*.4], degrees=True) - origin = np.array([.015, -.02, .5]) - parents.append(SquareTagPose(tuple(rotation.as_quat()), tuple(origin), 0.)) - points = rotation.apply(relative.apply(square_object_points(.016))+translation)+origin - projected = points@matrix.T - pixels = projected[:, :2]/projected[:, 2:] - corners.append(pixels) - candidates.append(tuple(solve_square_tag_ippe(pixels, tag_size_m=.016, camera_matrix=matrix))) - return parents, candidates, corners, [matrix]*48, .016, tuple(range(1, 49)) - - -def test_all_legal_candidates_use_fixed_split_and_propagate_parent_uncertainty(observer_images): - inherited = (math.radians(.05), .00005) - result = fit_observer_pose(*observer_images, inherited=inherited) - assert np.allclose(result.translation_xyz_m, [.045, .02, 0.], atol=1e-8) - assert Rotation.from_quat(result.quaternion_xyzw).magnitude() < 1e-6 - assert result.rotation_uncertainty_rad >= inherited[0] - assert result.translation_uncertainty_m >= inherited[1] - assert set(result.training_stamps).isdisjoint(result.validation_stamps) - assert result.training_stamps == tuple(range(1, 49, 2)) - - -def test_rigid_pair_recovers_truth_from_biased_parent_initialization(observer_images): - from linkerhand_calibration.core.geometry.tag_pose.rigid_tag_pair import fit_rigid_tag_pair - parents, candidates, corners, matrices, size, stamps = observer_images - parent_pixels, biased = [], [] - for parent, matrix in zip(parents, matrices): - rotation = Rotation.from_quat(parent.quaternion_xyzw) - points = rotation.apply(square_object_points(size)) + parent.translation_xyz_m - projected = points @ matrix.T - parent_pixels.append(projected[:, :2] / projected[:, 2:]) - biased.append(replace(parent, - quaternion_xyzw=tuple((Rotation.from_euler('x', 2., degrees=True) * rotation).as_quat()), - translation_xyz_m=tuple(np.asarray(parent.translation_xyz_m) + [.005, -.003, .002]))) - result = fit_rigid_tag_pair(biased, candidates, corners, matrices, size, stamps, - parent_corners=parent_pixels, parent_size=size) - assert np.allclose(result.translation_xyz_m, [.045, .02, 0.], atol=1e-6) - assert Rotation.from_quat(result.quaternion_xyzw).magnitude() < 1e-4 - assert 0 < result.rotation_uncertainty_rad < math.radians(1) - assert 0 < result.translation_uncertainty_m < .001 - - -def test_complete_parent_and_child_candidates_recover_known_installation(observer_images): - from linkerhand_calibration.core.geometry.tag_pose.rigid_tag_pair import fit_rigid_tag_pair - parents, candidates, corners, matrices, size, stamps = observer_images - parent_pixels, parent_candidates = [], [] - for parent, matrix in zip(parents, matrices): - points = Rotation.from_quat(parent.quaternion_xyzw).apply(square_object_points(size))+parent.translation_xyz_m - projected = points@matrix.T - pixels = projected[:,:2]/projected[:,2:] - parent_pixels.append(pixels) - parent_candidates.append(tuple(solve_square_tag_ippe(pixels, tag_size_m=size, camera_matrix=matrix))) - result = fit_rigid_tag_pair(*observer_images, parent_corners=parent_pixels, - parent_size=size, parent_candidates=parent_candidates) - assert np.linalg.norm(np.asarray(result.translation_xyz_m)-[.045,.02,0.]) < 1e-6 - assert Rotation.from_quat(result.quaternion_xyzw).magnitude() < 1e-4 - assert set(result.training_stamps).isdisjoint(result.validation_stamps) - - -@pytest.mark.parametrize('inherited', [(math.radians(2.274), .0005), (0., .001)]) -def test_exhausted_parent_budget_is_rejected_before_fitting(observer_images, monkeypatch, inherited): - monkeypatch.setattr('linkerhand_calibration.core.geometry.tag_pose.observer_pose.least_squares', - lambda *a, **kw: pytest.fail('fitting cannot recover spent parent precision')) - with pytest.raises(ValueError, match='parent_precision_budget_exhausted'): - fit_observer_pose(*observer_images, inherited=inherited) - - -@pytest.mark.parametrize('fault', ['missing', 'moving', 'installation', 'unobservable', 'uncertain_parent']) -def test_invalid_observer_evidence_cannot_authorize_a_pose(observer_images, fault): - parents, candidates, corners, matrices, size, stamps = observer_images - inherited = (0., 0.) - if fault == 'missing': - parents, candidates, corners, matrices, stamps = [v[:10] for v in (parents, candidates, corners, matrices, stamps)] - elif fault in {'moving', 'installation'}: - corners = [np.array(pixels, copy=True) for pixels in corners] - for i in (range(1, len(corners), 2) if fault == 'moving' else range(len(corners)//2, len(corners))): - corners[i] += [10., -10.] - elif fault == 'unobservable': - parents = [parents[0]]*len(parents) - else: - inherited = (math.radians(2), .01) - with pytest.raises(ValueError, match='observer_'): - fit_observer_pose(parents, candidates, corners, matrices, size, stamps, inherited=inherited) - - -@pytest.mark.integration -def test_measured_observer_pose_resolves_only_a_separated_hinge_family(observer_images): - from linkerhand_calibration.core.geometry.tag_pose.pose_bridge import SharedTagPoseBridge - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion - from test_motion_image_diagnostics import frames, RELATIONS - motion = frames(alternatives=True) - unresolved = resolve_image_motion(motion, RELATIONS) - assert not unresolved.resolved and unresolved.reason == 'image_motion_families_not_distinguishable' - measured = fit_observer_pose(*observer_images) - stationary = tuple(replace(motion[0], evidence=replace(motion[0].evidence, - stamp_ns=10_000_000_000+i*30_000_000)) for i in range(10)) - bridge = SharedTagPoseBridge(RELATIONS[0], measured.quaternion_xyzw, measured.translation_xyz_m, - stationary, rotation_uncertainty_rad=measured.rotation_uncertainty_rad, - translation_uncertainty_m=measured.translation_uncertainty_m) - result = resolve_image_motion(motion, RELATIONS, pose_bridges=(bridge,)) - assert result.resolved, result - assert all(c.status == 'consistent' for h in result.hypotheses if h.branches == result.model.branches - for c in h.pose_bridge_checks) - - -def test_observer_roles_do_not_change_the_current_task_or_require_visibility(): - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime.observer_reference import observer_roles - profile = load_bundled_hand_profile('o30_right_18') - spec = profile.motion.joint_zero_references['middle_pip'] - motion = MotionCommand('zero_approach', spec.command, 200., task_key=spec.task_key, reference_joints=('middle_pip',)) - assert 'middle_dip' in observer_roles(profile, motion) - assert observer_roles(profile, replace(motion, phase='sweep')) == set() - assert motion.reference_joints == ('middle_pip',) diff --git a/src/linkerhand_calibration/test/test_observer_preparation.py b/src/linkerhand_calibration/test/test_observer_preparation.py deleted file mode 100644 index 16d950e..0000000 --- a/src/linkerhand_calibration/test/test_observer_preparation.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Bounded observation acquires new information without changing accepted joints.""" -from copy import deepcopy -from dataclasses import asdict, replace -from types import SimpleNamespace as NS - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.domain.capture_plan import STAGED, configure_training -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points, solve_square_tag_ippe -from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles import load_bundled_hand_profile, load_hand_profile -from linkerhand_calibration.profiles.export import write_hand_profile -from linkerhand_calibration.profiles.observer_preparation import configure_observer_preparations, observer_actions -from linkerhand_calibration.profiles.validator import validate_executable_profile -from linkerhand_calibration.runtime.observer_preparation import ObserverPreparation, ObserverProbeEvidence, normal_coverage, POLICY -from linkerhand_calibration.runtime.observer_preparation_evidence import validate_probe_evidence, build_probe_bridge -from linkerhand_calibration.runtime.observer_reference import ObserverBridgeInput -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from linkerhand_calibration.runtime.joint_resume import _index_motion_provenance -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def active_profile(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - return configure_observer_preparations(profile, UrdfKinematicModel(SOURCE), - preserved_joints=set(profile.measurement.measurements)-{'index_dip'}) - - -def test_compile_bounds_preserves_all_existing_measurements(active_profile, tmp_path): - original = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - assert set(active_profile.motion.observer_preparations) == {'index_dip'} - plan = observer_actions(active_profile, 'index_dip')[0] - assert plan.joint == 'index_mcp_roll' - assert plan.command == original.motion.joint_zero_references['index_dip'].command - assert abs(plan.approach_commands[1][2]-plan.command[2]) == 63 - for name in set(original.measurement.measurements)-{'index_dip'}: - assert original.measurement.measurements[name] == active_profile.measurement.measurements[name] - assert original.motion.tasks == active_profile.motion.tasks - assert original.quality == active_profile.quality - validate_executable_profile(active_profile, SOURCE) - path = tmp_path/'active.yaml';write_hand_profile(active_profile,path) - assert load_hand_profile(path) == active_profile - - -@pytest.fixture(scope='module') -def synthetic_probe(active_profile): - profile = active_profile - plan = observer_actions(profile, 'index_dip')[0] - matrix = np.array([[3700.,0.,1024.],[0.,3700.,768.],[0.,0.,1.]]) - spec = profile.measurement.measurements['index_dip'] - tags = {t.role:t for v in profile.vision.views if v.name==spec.view for t in v.tags} - relative = Rotation.from_euler('xyz',[.02,.04,-.03]) - translation = np.array([.001,-.024,.008]) - decision = dict(kind='observer_preparation_decision',policy=POLICY,joint='index_dip', - task_name='index_dip_side',session_epoch=1,stamp_ns=1,action='excite',donor=plan.joint, - path=[list(p) for p in (*plan.approach_commands,plan.command)]) - identity = source_frame_sha256(decision) - rows,segments=[],[] - stamp=1_000_000_000 - def image(value): - rotation=Rotation.from_euler('xyz',[.2+(255-value)/63*.6,.12,.08]) - origin=np.array([.01,-.02,.89]) - output={} - for role,r,t in [(spec.parent_role,rotation,origin), - (spec.child_role,rotation*relative,origin+rotation.apply(translation))]: - p=tags[role] - camera=r.apply(square_object_points(p.size_m))+t - projected=camera@matrix.T - pixels=projected[:,:2]/projected[:,2:] - poses=solve_square_tag_ippe(pixels,tag_size_m=p.size_m,camera_matrix=matrix) - output[role]=dict(tag_id=p.tag_id,tag_size_m=p.size_m,corners_xy=pixels.tolist(), - selected_pose=asdict(SquareTagPose(tuple(r.as_quat()),tuple(t),0.)), - reprojection_valid_candidates=[asdict(p) for p in poses], - candidate_diagnostics=dict(observation_stamp_ns=stamp,branch_frozen=role==spec.parent_role)) - return output - for index,target in enumerate((*plan.approach_commands,plan.command)): - start=plan.command[2] if index==0 else plan.approach_commands[index-1][2] - values=[target[2]]*12 if index==0 else [*np.round(np.linspace(start,target[2],49)),*[target[2]]*12] - for value in values: - stamp+=33_333_333 - command=list(plan.command);command[2]=float(value) - rows.append(dict(kind='image_observation_frame',sample_phase='observer_probe',view='side', - image_stamp_ns=stamp,session_epoch=1,motion_version=index+1,reference_path_index=index, - task_name='index_dip_side',observer_joint='index_dip',observer_preparation_id=identity, - command_vector=command,feedback_vector=command,camera_matrix=matrix.tolist(),tags=image(value))) - segments.append(dict(kind='observer_preparation_segment_complete',observer_preparation_id=identity, - path_index=index,session_epoch=1,motion_version=index+1,stamp_ns=stamp+1_000_000)) - stamp+=2_000_000 - endpoints=[] - for i in range(10): - stamp+=33_333_333 - endpoints.append(dict(kind='motion_branch_observation',view='side',image_stamp_ns=stamp, - task_name='index_dip_side',session_epoch=1,motion_version=4,zero_joints=['index_dip'], - sample_phase='zero_approach',command_vector=list(plan.command),feedback_vector=list(plan.command), - camera_matrix=matrix.tolist(),tags=image(plan.command[2]))) - geometry=NS(relation=NS(child_role=spec.parent_role)) - model=NS(camera_matrix=tuple(map(tuple,matrix)),geometry=(geometry,)) - first_motion=next(r for r in rows if r['reference_path_index']==1) - started=dict(kind='observer_preparation_excursion_started',observer_preparation_id=identity, - session_epoch=1,motion_version=2,stamp_ns=first_motion['image_stamp_ns']-1_000_000) - source=ObserverBridgeInput('index_dip',model,dict(image_model_sha256='a'*64,evidence_ids={spec.parent_role:'parent'}), - (),tuple(endpoints),(16,),ObserverProbeEvidence(decision,tuple(rows),tuple(segments),started)) - return source,relative,translation - - -def test_tilt_diversity_not_image_plane_rotation(active_profile, synthetic_probe): - source,_,_=synthetic_probe - rows=source.preparation.rows - assert not normal_coverage(rows,'index_pip')['needs_excitation'] - flat=[] - template=rows[0] - matrix=np.asarray(template['camera_matrix']) - points=square_object_points(template['tags']['index_pip']['tag_size_m']) - for i,angle in enumerate(np.linspace(0,100,40)): - rotation=Rotation.from_euler('z',angle,degrees=True)*Rotation.from_euler('x',.05) - xyz=rotation.apply(points)+[.01,.01,.89] - projected=xyz@matrix.T - row=deepcopy(template);row['image_stamp_ns']=i+1 - row['tags']['index_pip']['corners_xy']=(projected[:,:2]/projected[:,2:]).tolist();flat.append(row) - coverage=normal_coverage(flat,'index_pip') - assert coverage['needs_excitation'] and coverage['normal_span_deg']<10 - - -def test_independent_probe_recovers_true_installation(active_profile, synthetic_probe): - source,rotation,translation=synthetic_probe - bridge,record=build_probe_bridge(active_profile,source) - assert np.linalg.norm(np.asarray(bridge.translation_xyz_m)-translation)<1e-6 - assert (Rotation.from_quat(bridge.quaternion_xyzw).inv()*rotation).magnitude()<1e-5 - assert bridge.rotation_uncertainty_rad=24 - assert controller.cancel_unstarted('index_dip',epoch=2,stamp_ns=source.segments[-1]['stamp_ns']+1) is None - motion=MotionCommand('observer_preflight',(),200,task_key='index_dip_side',reference_joints=('index_dip',)) - extension=controller.decide(motion,epoch=2,stamp_ns=source.segments[-1]['stamp_ns']+1) - assert extension['action_index']==1 and extension['action_limit']==2 - assert extension['previous_preparation_id']==source_frame_sha256(source.decision) - assert extension['path'][1][2]==0 - restored=ObserverPreparation(NS(profile=active_profile)); restored.restore([*records,extension]) - assert len(restored.history['index_dip'])==1 - assert restored.decisions['index_dip']==extension - reused=deepcopy(extension); reused['stamp_ns']+=1 - with pytest.raises(ValueError,match='budget_reused'): - restored.observe(reused) - skipped=ObserverPreparation(NS(profile=active_profile)) - with pytest.raises(ValueError,match='previous_action_missing'): - skipped.observe(extension) - with pytest.raises(ValueError,match='incomplete_checkpoint'): - controller.decide(motion,epoch=2,stamp_ns=source.segments[-1]['stamp_ns']+2) - reference=read_joint_zero_references(active_profile,[data['reference']])['index_mcp_pitch'] - channels=compile_tag_feedback_channels(active_profile,UrdfKinematicModel(SOURCE)) - task=next(t for t in active_profile.motion.tasks if t.key=='index_dip_side') - held=channels[active_profile.measurement.measurements['index_mcp_pitch'].child_role] - with pytest.raises(ValueError,match='held_feedback_changed'): - check_parent_pose(active_profile,task,reference,data['current'],held) - check_parent_pose(active_profile,task,reference,data['current'],held, - verification=REVISIT_POLICY,feedback_limits=revisit_feedback_limits(active_profile)) - assert reference.as_record()==data['reference'] or json.loads(json.dumps(reference.as_record()))==data['reference'] - - -def test_single_trajectory_action_declares_budget_and_cannot_escalate(active_profile, synthetic_probe): - from linkerhand_calibration.core.domain.profile import ObserverPreparationPlan - from linkerhand_calibration.profiles.observer_policy import TRAJECTORY_OBSERVER - from linkerhand_calibration.runtime.observer_preparation import TRAJECTORY_POLICY - source, _, _ = synthetic_probe - joint = 'index_dip' - specs = dict(active_profile.measurement.measurements) - specs[joint] = replace(specs[joint], observer_pose_policy=TRAJECTORY_OBSERVER) - profile = replace(active_profile, measurement=replace(active_profile.measurement, measurements=specs), - motion=replace(active_profile.motion, observer_preparations={joint: - ObserverPreparationPlan((observer_actions(active_profile, joint)[0],))})) - controller = ObserverPreparation(NS(profile=profile)) - motion = MotionCommand('observer_preflight', (), 200, task_key='index_dip_side', reference_joints=(joint,)) - # No sources requires excitation; dependency registry is empty. - controller.owner.parent_references = NS(models={}) - controller.owner.observer_records = {} - decision = controller.decide(motion, epoch=1, stamp_ns=1) - assert decision['policy'] == TRAJECTORY_POLICY - assert decision['action_limit'] == 1 and decision['action_index'] == 0 - first = source.preparation - identity = source_frame_sha256(decision) - rows = tuple(dict(r, observer_preparation_id=identity) for r in first.rows) - segments = tuple(dict(s, observer_preparation_id=identity) for s in first.segments) - started = dict(first.started, observer_preparation_id=identity) - evidence = ObserverProbeEvidence(decision, rows, segments, started) - validate_probe_evidence(profile, joint, evidence) - restored = ObserverPreparation(NS(profile=profile)) - restored.restore([decision, started, *rows, *segments]) - assert restored.decide(motion, epoch=2, stamp_ns=segments[-1]['stamp_ns']+1, - reason='rigid_pair_candidates_unresolved') is None - with pytest.raises(ValueError, match='action_budget_changed'): - validate_probe_evidence(profile, joint, replace(evidence, - decision=dict(decision, action_limit=2))) - - -def test_two_completed_actions_exhaust_budget_across_restart(active_profile, synthetic_probe): - source,_,_=synthetic_probe - first=source.preparation - records=[first.decision,first.started,*first.rows,*first.segments] - controller=ObserverPreparation(NS(profile=active_profile));controller.restore(records) - motion=MotionCommand('observer_preflight',(),200,task_key='index_dip_side',reference_joints=('index_dip',)) - shift=first.segments[-1]['stamp_ns']+1_000_000_000 - decision=controller.decide(motion,epoch=1,stamp_ns=shift,reason='rigid_pair_candidates_unresolved') - identity=source_frame_sha256(decision) - rows=deepcopy(first.rows) - for row in rows: - row.update(observer_preparation_id=identity,image_stamp_ns=row['image_stamp_ns']+shift, - motion_version=row['motion_version']+3) - for field in ('command_vector','feedback_vector'): - row[field]=list(row[field]) - row[field][2]=255-(255-row[field][2])*255/63 - for item in row['tags'].values(): - item['candidate_diagnostics']['observation_stamp_ns']=row['image_stamp_ns'] - segments=tuple(dict(s,observer_preparation_id=identity,stamp_ns=s['stamp_ns']+shift, - motion_version=s['motion_version']+3) for s in first.segments) - started=dict(first.started,observer_preparation_id=identity,stamp_ns=first.started['stamp_ns']+shift, - motion_version=first.started['motion_version']+3) - second=ObserverProbeEvidence(decision,rows,segments,started) - validate_probe_evidence(active_profile,'index_dip',second) - restored=ObserverPreparation(NS(profile=active_profile)) - restored.restore([*records,decision,started,*rows,*segments]) - assert restored.source('index_dip')==second - assert restored.decide(motion,epoch=2,stamp_ns=segments[-1]['stamp_ns']+1, - reason='rigid_pair_candidates_unresolved') is None - assert len(restored.history['index_dip'])==1 diff --git a/src/linkerhand_calibration/test/test_observer_reference.py b/src/linkerhand_calibration/test/test_observer_reference.py deleted file mode 100644 index ff3f04e..0000000 --- a/src/linkerhand_calibration/test/test_observer_reference.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Raw observer pixels traverse the runtime bridge admission boundary.""" - -from copy import deepcopy -from dataclasses import asdict, replace -from types import SimpleNamespace as NS - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points, solve_square_tag_ippe -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion -from linkerhand_calibration.runtime.observer_reference import ObserverBridgeInput, build_observer_bridge -from linkerhand_calibration.runtime.branch_initialization import BranchInitializationRequest, initialization_artifacts, solve_initialization -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from test_motion_image_diagnostics import frames, RELATIONS, MATRIX - - -@pytest.fixture(scope='module') -def observer_source(): - source = frames() - # A tilted upstream axis provides genuinely different camera directions; - # rolling a frontal plane in place must remain ambiguous. - turn, origin = Rotation.from_euler('y', 25, degrees=True), np.array([0., 0., .8]) - transformed = [] - for frame in source: - roles, observations = [], [] - for role, observation in zip(frame.evidence.roles, frame.observations): - pose = role.candidates[0] - rotation = turn*Rotation.from_quat(pose.quaternion_xyzw) - if role.role == 'moving': - rotation = rotation*Rotation.from_euler('x', 20, degrees=True) - translation = origin+turn.apply(np.asarray(pose.translation_xyz_m)-origin) - pose = replace(pose, quaternion_xyzw=tuple(rotation.as_quat()), translation_xyz_m=tuple(translation)) - projected = (rotation.apply(square_object_points(.016))+translation)@np.asarray(MATRIX).T - roles.append(replace(role, candidates=(pose,))) - observations.append(replace(observation, corners_xy=tuple(map(tuple, projected[:, :2]/projected[:, 2:])))) - transformed.append(replace(frame, evidence=replace(frame.evidence, roles=tuple(roles)), observations=tuple(observations))) - source = tuple(transformed) - profile = NS(command=NS(unit='u8', command_index_by_joint={'hinge': 0, 'distal': 1}), - acquisition=NS(fixed_reference_minimum_frames=10), - motion=NS(joint_zero_references={'distal': NS(command=(255., 0.))}), - measurement=NS(measurements={'distal': NS(view='view', parent_role='moving', child_role='terminal', - observer_pose_policy='frozen_parent_v1')}), - vision=NS(views=(NS(name='view', tags=(NS(role='terminal', size_m=.016),)),))) - rows = [] - for i, frame in enumerate(source): - stamp = frame.evidence.stamp_ns - tags = {} - for role, observation in zip(frame.evidence.roles, frame.observations): - tags[role.role] = dict(tag_role=role.role, tag_size_m=.016, corners_xy=observation.corners_xy, - selected_pose=asdict(role.candidates[0]), reprojection_valid_candidates=[asdict(p) for p in role.candidates], - candidate_diagnostics=dict(observation_stamp_ns=stamp, branch_frozen=role.frozen)) - parent = frame.evidence.roles[1].candidates[0] - rotation = Rotation.from_quat(parent.quaternion_xyzw) - points = rotation.apply(Rotation.from_euler('x', 20, degrees=True).apply(square_object_points(.016)) - + [.03, .01, -.005]) + parent.translation_xyz_m - projected = points@np.asarray(MATRIX).T - pixels = projected[:, :2]/projected[:, 2:] - candidates = solve_square_tag_ippe(pixels, tag_size_m=.016, camera_matrix=MATRIX) - tags['terminal'] = dict(tag_role='terminal', tag_size_m=.016, corners_xy=pixels.tolist(), - selected_pose=None, reprojection_valid_candidates=[asdict(p) for p in candidates], - candidate_diagnostics=dict(observation_stamp_ns=stamp, branch_frozen=False)) - rows.append(dict(kind='motion_branch_observation', image_stamp_ns=stamp, view='view', task_name='upstream', - zero_joints=['hinge'], session_epoch=1, motion_version=1, camera_matrix=MATRIX, tags=tags, - input_is_rectified=True, sample_phase='zero_approach', - command_vector=[i*255/(len(source)-1), 0.], feedback_vector=[i*255/(len(source)-1), 0.])) - request = BranchInitializationRequest('upstream', 'view', ('hinge',), 1, 1, 1, - tuple(f.evidence for f in source), RELATIONS, (), ('moving',), - tuple((str(r['image_stamp_ns']), source_frame_sha256(r)) for r in rows), - image_frames=source, image_geometry=True) - result = resolve_image_motion(source, RELATIONS) - assert result.resolved - _, report = initialization_artifacts(request, result) - endpoints = [] - for i in range(10): - row = deepcopy(rows[-1]) - row.update(task_name='downstream', zero_joints=['distal'], motion_version=2, - image_stamp_ns=row['image_stamp_ns']+(i+1)*30_000_000) - for item in row['tags'].values(): - item['candidate_diagnostics']['observation_stamp_ns'] = row['image_stamp_ns'] - row['tags']['moving']['candidate_diagnostics']['branch_frozen'] = True - endpoints.append(row) - return profile, ObserverBridgeInput('distal', result.model, report, tuple(rows), tuple(endpoints), (1,)), request - - -def test_raw_bridge_keeps_original_corners_candidates_and_fixed_image_split(observer_source): - profile, source, _ = observer_source - original = source_frame_sha256(source.rows[0]) - bridge, record = build_observer_bridge(profile, source) - assert np.allclose(bridge.translation_xyz_m, [.03, .01, -.005], atol=1e-6) - assert (Rotation.from_quat(bridge.quaternion_xyzw).inv()*Rotation.from_euler('x', 20, degrees=True)).magnitude() < 1e-5 - assert record['source_frame_hashes'][str(source.rows[0]['image_stamp_ns'])] == original - assert source_frame_sha256(source.rows[0]) == original - assert set(record['pose']['training_stamps']).isdisjoint(record['pose']['validation_stamps']) - - -@pytest.mark.parametrize('fault', ['feedback', 'command', 'size', 'missing']) -def test_bad_observer_evidence_is_not_a_new_reference(observer_source, fault): - profile, source, request = observer_source - rows = deepcopy(source.rows) - if fault == 'feedback': - rows[-1]['feedback_vector'][1] = 10 - elif fault == 'command': - rows[-1]['command_vector'][1] = 10 - elif fault == 'size': - rows[-1]['tags']['terminal']['tag_size_m'] *= 2 - else: - del rows[-1]['tags']['terminal'] - invalid = replace(source, rows=tuple(rows)) - with pytest.raises((ValueError, KeyError)): - build_observer_bridge(profile, invalid) - if fault == 'missing': - # Missing optional pixels do not change this upstream hinge's evidence. - result = solve_initialization(replace(request, observer_sources=(invalid,), observer_profile=profile)) - assert result.resolved and result.observer_rejections and not result.observer_pose_bridges - - -@pytest.fixture(scope='module') -def observer_audit(observer_source): - from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageMotionFrame, ImageRoleObservation - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionEvidenceFrame, MotionRelation, RoleCandidates - from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - profile, source, _ = observer_source - parent = source.rows[-1]['tags']['moving']['selected_pose'] - parent_rotation = Rotation.from_quat(parent['quaternion_xyzw']) - parent_point = np.asarray(parent['translation_xyz_m']) - relation = MotionRelation('distal', 'moving', 'terminal') - rows, images = [], [] - for index, theta in enumerate(np.linspace(np.deg2rad(35), 0., 49)): - stamp = 5_000_000_000+index*50_000_000+(500_000_000 if index == 48 else 0) - arc = Rotation.from_rotvec([0., theta, 0.]) - poses = (('moving', parent_rotation, parent_point), - ('terminal', parent_rotation*arc*Rotation.from_euler('x', 20, degrees=True), - parent_point+parent_rotation.apply(arc.apply([.03, .01, -.005])))) - roles, observations, tags = [], [], {} - for role, rotation, point in poses: - projected = (rotation.apply(square_object_points(.016))+point)@np.asarray(MATRIX).T - corners = tuple(map(tuple, projected[:, :2]/projected[:, 2:])) - pose = SquareTagPose(tuple(rotation.as_quat()), tuple(point), .1) - roles.append(RoleCandidates(role, (pose,), role == 'moving')) - observations.append(ImageRoleObservation(role, .016, corners)) - tags[role] = dict(tag_role=role, tag_size_m=.016, corners_xy=corners, selected_pose=asdict(pose), - reprojection_valid_candidates=[asdict(pose)], - candidate_diagnostics=dict(observation_stamp_ns=stamp, branch_frozen=role == 'moving')) - images.append(ImageMotionFrame(MotionEvidenceFrame(stamp, tuple(roles)), MATRIX, tuple(observations))) - rows.append(dict(kind='motion_branch_observation', image_stamp_ns=stamp, view='view', task_name='downstream', - zero_joints=['distal'], session_epoch=1, motion_version=2, camera_matrix=MATRIX, tags=tags, - input_is_rectified=True, sample_phase='zero_approach', command_vector=[255., float(theta)], - feedback_vector=[255., float(theta)])) - endpoints = [] - for index in range(10): - row = deepcopy(rows[-1]) - row['image_stamp_ns'] = rows[-2]['image_stamp_ns']+(index+1)*30_000_000 - for tag in row['tags'].values(): - tag['candidate_diagnostics']['observation_stamp_ns'] = row['image_stamp_ns'] - endpoints.append(row) - source = replace(source, endpoint_rows=tuple(endpoints)) - request = BranchInitializationRequest('downstream', 'view', ('distal',), 1, 2, 1, - tuple(f.evidence for f in images), (relation,), tuple(source.parent_report['evidence_ids'].items()), - ('moving', 'terminal'), tuple((str(r['image_stamp_ns']), source_frame_sha256(r)) for r in rows), - image_frames=tuple(images), image_geometry=True, observer_sources=(source,), observer_profile=profile) - result = solve_initialization(request) - assert result.resolved and result.observer_pose_bridges, result.reason - _, report = initialization_artifacts(request, result) - evidence = {report['evidence_ids'][role]: payload for report in (source.parent_report, report) - for role, payload in report['evidence_payloads'].items()} - records = (*source.rows, source.parent_report, *rows[:-1], *endpoints, rows[-1], report) - return profile, records, evidence - - -def test_observer_pixels_and_frozen_model_survive_journal_readback(observer_audit): - from linkerhand_calibration.runtime.observer_reference import validate_observer_references - from linkerhand_calibration.runtime.motion_provenance import _initializations - profile, records, evidence = observer_audit - assert _initializations(records) == evidence - validate_observer_references(profile, records, evidence) - - -def test_resume_retains_bound_observer_images_without_inventing_missing_pixels(observer_source): - from linkerhand_calibration.runtime.observer_reference import restore_observer_records, observer_inputs - profile, source, _ = observer_source - profile = NS(**vars(profile), vision_motion=False) - owner = NS(profile=profile, zero_joints=('distal',), observer_records={}, - tag_feedback_channels={'moving': {0}, 'terminal': {0, 1}}, - parent_references=NS(models={(2, 'view', 'hinge'): (NS(image_model=source.parent_model), source.parent_report)})) - from linkerhand_calibration.runtime.observer_preparation import ObserverPreparation - owner.observer_preparation = ObserverPreparation(owner) - assert not observer_inputs(owner, 'view', source.endpoint_rows, set(), 2) - rows = deepcopy(source.rows) - del rows[0]['tags']['terminal'] - restore_observer_records(owner, (*rows, *source.endpoint_rows)) - restored, = observer_inputs(owner, 'view', source.endpoint_rows, set(), 2) - assert restored.rows == rows[1:] - assert len(owner.observer_records[(1, 'upstream', 'view')]) == len(rows) - assert source_frame_sha256(restored.rows[0]) == source_frame_sha256(rows[1]) - - -@pytest.mark.parametrize('fault,reason', [('pixels', 'source_pixels_changed'), - ('sampling', 'source_sampling_changed'), ('installation', 'parent_installation_changed')]) -def test_observer_audit_rejects_changed_sources_without_refitting_whole_hand(observer_audit, fault, reason): - from linkerhand_calibration.runtime.observer_reference import validate_observer_references - profile, records, evidence = observer_audit - records, evidence = deepcopy(records), deepcopy(evidence) - payload = next(p for p in evidence.values() if p.get('observer_pose_bridges')) - bridge = payload['observer_pose_bridges'][0] - if fault == 'pixels': - row = next(r for r in records if str(r.get('image_stamp_ns')) in bridge['source_frame_hashes']) - row['tags']['terminal']['corners_xy'][0][0] += .1 - elif fault == 'sampling': - bridge['source_frame_hashes'].pop(next(iter(bridge['source_frame_hashes']))) - else: - bridge['parent_model_sha256'] = '0'*64 - with pytest.raises(ValueError, match=reason): - validate_observer_references(profile, records, evidence) diff --git a/src/linkerhand_calibration/test/test_offline_motion_initialization.py b/src/linkerhand_calibration/test/test_offline_motion_initialization.py new file mode 100644 index 0000000..fa5201a --- /dev/null +++ b/src/linkerhand_calibration/test/test_offline_motion_initialization.py @@ -0,0 +1,89 @@ +"""Recorded branch swaps and endpoint history must not distort offline seeds.""" +import gzip +import json +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from scipy.spatial.transform import Rotation + +from linkerhand_calibration.profiles import load_bundled_hand_profile +from linkerhand_calibration.core.geometry.tag_pose.urdf_command_images import UrdfCommandImages +from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import ( + RoleCandidates, connect_candidate_families, +) +from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose +from linkerhand_calibration.core.fitting.raw_joint_draft import fit_draft +from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint + + +@pytest.fixture +def recorded(): + with gzip.open(Path(__file__).parent/'fixtures/o30_recorded_motion_initialization.json.gz', 'rt') as stream: + rows = json.load(stream)['rows'] + bundle = object.__new__(UrdfCommandImages) + bundle.profile = load_bundled_hand_profile('o30_right_18') + bundle.rows = rows + bundle.commands = np.asarray([row['command_vector'] for row in rows]) + bundle.channels = {name: channel_for_joint(bundle.profile, name) + for name in bundle.profile.measurement.measurements} + bundle.tags = {tag.role: (view.name, tag) for view in bundle.profile.vision.views for tag in view.tags} + bundle.extrinsics = SimpleNamespace(transform=lambda view: np.eye(4)) + bundle._pnps = {} + return bundle + + +def test_recorded_yaw_candidate_rank_swap_does_not_become_motion(recorded): + indices = recorded.motion_indices('thumb_cmc_yaw', 0, 0) + indices = [i for i in indices if recorded.commands[i, 1] in (32, 64, 96, 128)] + naive = np.asarray([recorded._pose(i, 'thumb_cmc_yaw', 0) for i in indices]) + def steps(poses): + rotations = Rotation.from_matrix(poses[:, :3, :3]) + return np.degrees((rotations[1:]*rotations[:-1].inv()).magnitude()) + assert max(steps(naive)) > 40 + families = [] + for branch in (0, 1): + selected, poses = recorded.motion_poses(indices, 'thumb_cmc_yaw', branch) + assert selected == indices + assert max(steps(poses)) < 22 + families.append(poses) + assert not np.allclose(*families) + + +def test_start_belongs_to_scan_without_overwriting_hysteresis(recorded): + before = recorded._directions(recorded.rows).copy() + indices = recorded.motion_indices('thumb_cmc_yaw', 0, 0) + assert recorded.commands[indices[0], 1] == 0 + assert before[indices[0], 1] == 1 # Incoming return move was decreasing. + assert recorded.commands[indices[-1], 1] == 255 + assert np.array_equal(before, recorded._directions(recorded.rows)) + for finger in ('index', 'ring', 'pinky'): + indices = recorded.motion_indices(finger+'_mcp_roll', 0, 0) + assert {recorded.rows[i]['segment_key'] for i in indices} == {'linked_increasing'} + indices = recorded.motion_indices('middle_mcp_roll', 0, 0) + assert {recorded.rows[i]['segment_key'] for i in indices} == {'linked_increasing', 'middle_lower_increasing'} + assert min(recorded.commands[indices, 3]) == 0 + assert max(recorded.commands[indices, 3]) == 255 + + +def test_missing_candidate_is_not_copied_and_strict_gate_remains(): + def pose(angle): + return SquareTagPose(tuple(Rotation.from_euler('x', angle, degrees=True).as_quat()), (0., 0., 1.), .1) + first, second, moved = pose(-15), pose(15), pose(16) + families, reason = connect_candidate_families([ + RoleCandidates('tag', (first, second)), RoleCandidates('tag', (moved,))]) + assert not reason + assert families[0] == [first, None] and families[1] == [second, moved] + families, reason = connect_candidate_families([ + RoleCandidates('tag', (first,)), RoleCandidates('tag', (pose(170),))]) + assert families is None and reason == 'motion_candidate_family_discontinuous' + + +def test_underdetermined_zero_is_rejected_even_when_thin_svd_has_no_null_rows(): + bundle = SimpleNamespace(tags={'tag': object()}, scale=np.ones(2), zero_columns={'joint': 0}, + fit=lambda _: SimpleNamespace(success=True, x=np.zeros(2), fun=np.ones(8), + nfev=1, initialization_evaluations=0), output_mappings=lambda _: {}, + zero_offsets=lambda x: {'joint': x[0]}, jacobian=lambda _: np.array([[1., 1.]])) + with pytest.raises(ValueError, match='draft_estimated_zero_unobservable:joint'): + fit_draft(bundle) diff --git a/src/linkerhand_calibration/test/test_offline_replay.py b/src/linkerhand_calibration/test/test_offline_replay.py deleted file mode 100644 index 6bff284..0000000 --- a/src/linkerhand_calibration/test/test_offline_replay.py +++ /dev/null @@ -1,86 +0,0 @@ -import pytest - -from linkerhand_calibration.compat.legacy import ( - uses_coupled_full_hand_zero_solver, -) -from linkerhand_calibration.offline_replay import ( - _latest_attempt_records, - _latest_palm_axis_records, - _output_suffix, -) - - -def _sample(joint: str, cycle: int, direction: str, attempt: int) -> dict: - return { - "kind": "sample", - "joint": joint, - "cycle": cycle, - "direction": direction, - "attempt": attempt, - } - - -def test_latest_attempt_is_selected_per_joint_cycle_and_direction() -> None: - rows = [ - {"kind": "session_start"}, - _sample("pinky_pip", 0, "decreasing", 1), - _sample("pinky_pip", 0, "decreasing", 3), - _sample("pinky_pip", 0, "increasing", 1), - _sample("pinky_pip", 1, "decreasing", 2), - _sample("thumb_cmc_yaw", 0, "decreasing", 1), - ] - - selected = _latest_attempt_records(rows) - - assert [ - record["attempt"] for record in selected["pinky_pip"] - ] == [3, 1, 2] - assert [ - record["attempt"] for record in selected["thumb_cmc_yaw"] - ] == [1] - - -def test_latest_palm_axis_attempt_is_selected_per_cycle_and_direction() -> None: - def sample(cycle: int, direction: str, attempt: int) -> dict: - return { - "kind": "palm_axis_sample", - "source_joint": "pinky_mcp_pitch_front_axis", - "cycle": cycle, - "direction": direction, - "attempt": attempt, - } - - selected = _latest_palm_axis_records( - [ - sample(0, "decreasing", 1), - sample(0, "decreasing", 2), - sample(0, "increasing", 1), - ] - ) - - assert [ - record["attempt"] - for record in selected["pinky_mcp_pitch_front_axis"] - ] == [2, 1] - - -def test_output_suffix_is_safe_and_explicit() -> None: - assert _output_suffix(None) == "" - assert _output_suffix("MEASURED_ZERO_V2") == "_MEASURED_ZERO_V2" - for invalid in ("", "../escape", "/absolute", "contains space", "x" * 65): - with pytest.raises(ValueError, match="output tag"): - _output_suffix(invalid) - - -def test_v1_session_solver_selection_is_confined_to_compatibility() -> None: - legacy = { - "sample_schema_version": 1, - "capabilities": ["palm_axis_side_channel_v2"], - } - current = { - "sample_schema_version": 1, - "capabilities": ["palm_axis_relative_motion_v3"], - } - - assert uses_coupled_full_hand_zero_solver(legacy) - assert not uses_coupled_full_hand_zero_solver(current) diff --git a/src/linkerhand_calibration/test/test_optical_verification.py b/src/linkerhand_calibration/test/test_optical_verification.py deleted file mode 100644 index 081332d..0000000 --- a/src/linkerhand_calibration/test/test_optical_verification.py +++ /dev/null @@ -1,69 +0,0 @@ -import copy -from dataclasses import replace - -import numpy as np -import pytest - -from optical_fixture import optical_dataset -from linkerhand_calibration.core.geometry.extrinsics import CameraExtrinsics, CameraCalibrationIdentity -from linkerhand_calibration.runtime.optical_verification import verify_optical_observations - - -@pytest.fixture -def optical(): - cameras = {v: CameraCalibrationIdentity(v, 1600, 1200, 'b'*64) for v in ('a','b','c')} - transforms = {v: np.eye(4) for v in cameras} - transforms['b'][0,3] = .03 - transforms['c'][1,3] = .03 - extrinsics = CameraExtrinsics(cameras, 'a', transforms, {}) - matrices = {v: np.array([[1400.,0,800.],[0,1400.,600.],[0,0,1.]]) for v in cameras} - return extrinsics, matrices, optical_dataset(extrinsics, matrices, 'a'*64) - - -def test_measured_optical_check_freezes_camera_parameters(optical): - extrinsics, matrices, dataset = optical - report = verify_optical_observations(dataset, extrinsics, matrices, extrinsics_sha256='a'*64) - assert report['passed'] and report['reprojection_p95_px'] < 1e-5 - assert report['intrinsics_updated'] is report['extrinsics_updated'] is False - - -@pytest.mark.parametrize('error', ['intrinsics', 'extrinsics', 'coverage', 'serial', 'synchronization']) -def test_configuration_identity_cannot_replace_optical_observations(optical, error): - extrinsics, matrices, dataset = optical - if error == 'intrinsics': - matrices['b'][0,0] *= 1.1 - for capture in dataset['captures']: - capture['views']['b']['camera_matrix'] = matrices['b'].tolist() - elif error == 'extrinsics': - extrinsics.reference_from_view['b'][0,3] += .03 - elif error == 'coverage': - dataset['captures'] = dataset['captures'][:2] - elif error == 'serial': - dataset['captures'][0]['views']['b']['serial_number'] = 'wrong' - else: - dataset['captures'][0]['views']['b']['image_stamp_ns'] += 60_000_000 - with pytest.raises(ValueError, match='optical_'): - verify_optical_observations(dataset, extrinsics, matrices, extrinsics_sha256='a'*64) - - -def test_explicit_camera_reuse_checks_prior_quality_without_claiming_new_measurement(tmp_path): - import hashlib - from pathlib import Path - import yaml - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.optical_verification import accept_existing_camera_calibration - profile = load_bundled_hand_profile('o30_right_18') - source = Path(__file__).resolve().parents[3]/'config/o30_three_camera_extrinsics_20260922_101452.yaml' - path = tmp_path/'cameras.yaml' - path.write_bytes(source.read_bytes()) - digest = hashlib.sha256(path.read_bytes()).hexdigest() - report = accept_existing_camera_calibration(profile, path, digest) - assert report['accepted'] is True and report['passed'] is False - assert report['current_optical_verification'] == 'not_performed' - with pytest.raises(ValueError, match='existing_camera_calibration_changed'): - accept_existing_camera_calibration(profile, path, '0'*64) - payload = yaml.safe_load(path.read_text()) - payload['quality']['reprojection_rms_px'] = 100. - path.write_text(yaml.safe_dump(payload)) - with pytest.raises(ValueError): - accept_existing_camera_calibration(profile, path, hashlib.sha256(path.read_bytes()).hexdigest()) diff --git a/src/linkerhand_calibration/test/test_package_rename.py b/src/linkerhand_calibration/test/test_package_rename.py deleted file mode 100644 index 658cded..0000000 --- a/src/linkerhand_calibration/test/test_package_rename.py +++ /dev/null @@ -1,56 +0,0 @@ -import importlib -from pathlib import Path -import re -import sys - -import pytest - -from linkerhand_calibration.compat import resolve_renamed_package_path - - -def test_legacy_python_package_forwards_only_public_config_api() -> None: - with pytest.warns(DeprecationWarning, match="linkerhand_calibration"): - if "g20_thumb_apriltag_calibration" in sys.modules: - legacy = importlib.reload( - sys.modules["g20_thumb_apriltag_calibration"] - ) - else: - legacy = importlib.import_module( - "g20_thumb_apriltag_calibration" - ) - - current = importlib.import_module("linkerhand_calibration.product") - assert legacy.ProductConfig is current.ProductConfig - assert legacy.load_product_config is current.load_product_config - - -def test_new_and_legacy_executable_names_share_one_implementation() -> None: - setup_text = (Path(__file__).resolve().parents[1] / "setup.py").read_text( - encoding="utf-8" - ) - for executable in ("calibrate_hand", "calibrate_g20_right"): - assert re.search( - rf'"{executable} = "\s*' - r'"linkerhand_calibration\.runtime\.runner:main"', - setup_text, - ) - assert ( - "linkerhand_calibration.runtime.ros.entrypoint:main" - in setup_text - ) - - -def test_package_resource_uri_resolves_to_the_workspace_copy() -> None: - repository = Path(__file__).resolve().parents[3] - expected = ( - repository - / "src/linkerhand_calibration/urdf/g20_right/linkerhand_g20_right.urdf" - ) - - resolved = resolve_renamed_package_path( - "package://linkerhand_calibration/urdf/g20_right/" - "linkerhand_g20_right.urdf", - repository, - ) - - assert resolved == expected diff --git a/src/linkerhand_calibration/test/test_parallel_image_replay.py b/src/linkerhand_calibration/test/test_parallel_image_replay.py deleted file mode 100644 index e5fe57b..0000000 --- a/src/linkerhand_calibration/test/test_parallel_image_replay.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Parallel preparation must run the same image and sample integrity checks.""" - -from copy import deepcopy - -import pytest - -from linkerhand_calibration.runtime import image_replay -from test_image_motion_provenance import recorded_image - - -def test_spawned_replay_accepts_the_same_images_and_preserves_records(): - frame, row = recorded_image() - original = deepcopy((frame, row)) - groups = [(frame, [row])] * 256 - image_replay.validate_image_groups(groups) - image_replay.validate_image_groups(groups, workers=2) - assert (frame, row) == original - - -@pytest.mark.parametrize("target", ["frame", "sample"]) -def test_spawned_replay_propagates_corruption_after_valid_batches(target): - frame, row = recorded_image() - altered_frame, altered_row = deepcopy((frame, row)) - if target == "frame": - altered_frame["roles"]["moving"]["frozen_image_model"]["geometry"][0]["pivot_parent_xyz_m"] = (.2, .2, .2) - else: - altered_row["relative_translation_xyz_m"][0] += .01 - groups = [(frame, [row])] * 255 + [(altered_frame, [altered_row])] - with pytest.raises(ValueError) as serial: - image_replay.validate_image_groups(groups) - with pytest.raises(ValueError) as parallel: - image_replay.validate_image_groups(groups, workers=2) - assert str(parallel.value) == str(serial.value) - - -@pytest.mark.parametrize("small,daemon", [(True, False), (False, True)]) -def test_small_inputs_and_daemon_finalizers_do_not_spawn(monkeypatch, small, daemon): - from types import SimpleNamespace - - monkeypatch.setattr(image_replay.multiprocessing, "current_process", lambda: SimpleNamespace(daemon=daemon)) - monkeypatch.setattr(image_replay, "ProcessPoolExecutor", lambda **kwargs: pytest.fail("unexpected process pool")) - frame, row = recorded_image() - image_replay.validate_image_groups([(frame, [row])] * (1 if small else 256), workers=4) - - -@pytest.mark.parametrize("cpus,expected", [(None, 1), (1, 1), (2, 1), (4, 2), (24, 4)]) -def test_checkpoint_workers_leave_host_capacity(monkeypatch, cpus, expected): - monkeypatch.setattr(image_replay.os, "cpu_count", lambda: cpus) - assert image_replay.checkpoint_replay_workers() == expected diff --git a/src/linkerhand_calibration/test/test_parent_reference_provenance.py b/src/linkerhand_calibration/test/test_parent_reference_provenance.py deleted file mode 100644 index d17131d..0000000 --- a/src/linkerhand_calibration/test/test_parent_reference_provenance.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Read back stationary parent checks from actual projected corner evidence.""" - -from copy import deepcopy -from dataclasses import asdict, replace -import json - -import pytest - -from linkerhand_calibration.core.domain.reference import build_joint_zero_reference -from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import image_motion_model_from_dict -from linkerhand_calibration.core.geometry.tag_pose.cad_image_model import TransferredImageMotionModel -from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry -from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageHingeGeometry -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation -from linkerhand_calibration.core.geometry.tag_pose.source_hinge import SourceHinge -from linkerhand_calibration.runtime.parent_reference import ParentReferenceRegistry -from linkerhand_calibration.runtime.parent_reference_evidence import ( - validate_parent_references, validate_source_hinges, same_capture_epoch, -) -from linkerhand_calibration.runtime.branch_initialization import MotionBranchModel -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from test_image_motion_provenance import recorded_image -from test_parent_reference_reuse import setup - - -def pixels(profile, stamp, task_key, command): - # Known 3D truth is projected by the shared independent image fixture. - # Rename only the declared roles/joint; geometry and measured pixels stay. - frame, row = recorded_image(stamp=stamp) - mapping = {"root": "front_base", "moving": "thumb_mcp", "hinge": "thumb_cmc_roll", "task": task_key} - def renamed(value): - if isinstance(value, str): return mapping.get(value, value) - if isinstance(value, dict): return {mapping.get(k, k): renamed(v) for k, v in value.items()} - if isinstance(value, (tuple, list)): return [renamed(v) for v in value] - return value - frame, row = renamed(frame), renamed(row) - image = frame["roles"]["thumb_mcp"]["frozen_image_model"] - digest = image_model_sha256(image) - for item in (frame["roles"]["thumb_mcp"], row["pnp_observation_evidence"]["child"]): - item["image_model_sha256"] = digest - item["candidate_diagnostics"].update(motion_evidence_verified=True, motion_evidence_id="a"*64) - row.update(kind="joint_zero_sample", sample_phase="joint_zero", command_vector_u8=list(command), - state_u8=list(command), command_direction_by_index=["decreasing"]*20) - return frame, row, image - - -def journal(setup): - profile, task, channels = setup - name = task.parent_reference.pose_joint - spec = profile.motion.joint_zero_references[name] - target = profile.motion.joint_zero_references["thumb_ip"].command - records, before, after = [], [], [] - for stamp in range(4000000000, 4000000010): - frame, row, image = pixels(profile, stamp, spec.task_key, spec.command) - records.extend((frame, row)); before.append(row) - reference = build_joint_zero_reference(profile, name, before, session_epoch=1, motion_version=3) - records.append(reference.as_record()) - for stamp in range(5000000000, 5000000010): - frame, row, _ = pixels(profile, stamp, task.key, target) - records.extend((frame, row)); after.append(row) - model = image_motion_model_from_dict(image) - pose = MotionBranchModel(spec.task_key, "front", (), (("thumb_mcp", "a"*64),), model) - report = dict(session_epoch=1, image_model_sha256=image_model_sha256(image)) - owner = ParentReferenceRegistry(profile, channels) - owner.remember(pose, report) - # A separate accepted geometry source is required even for activation. - owner.models[(1, "front", "thumb_mcp")] = (pose, report) - check = owner.confirm(task, 1, 10, reference, after) - records.append(check) - parent = ImageHingeGeometry(MotionRelation("thumb_mcp", "front_base", "thumb_mcp"), - (0., 0., 1.), (0., 0., 0., 1.), (0., 0., 0.), (.01, 0., 0.)) - source = SourceHinge(parent, "b"*64, "c"*64, .001, .0001) - child = ImageHingeGeometry(MotionRelation("thumb_ip", "thumb_mcp", "thumb_ip"), - (0., 0., 1.), (0., 0., 0., 1.), (.03, 0., 0.), (.015, .002, 0.)) - transferred = TransferredImageMotionModel((child,), model.camera_matrix, - (("thumb_mcp", .016), ("thumb_ip", .016)), (6000000000, 6000000001), - (6000000000,), (6000000001,), (("thumb_mcp", 0), ("thumb_ip", 0)), - constraints=(ParallelAxisGeometry("thumb_mcp", "thumb_ip", 1, .04),), - source_urdf_sha256="d"*64, source_hinges=(source,)) - evidence = {"a"*64: dict(frozen_image_model=image, image_model_sha256=report["image_model_sha256"], - view="front", tag_role="thumb_mcp", session_epoch=1, source_image_stamps=[1, 2]), - "e"*64: dict(frozen_image_model=asdict(transferred), image_model_sha256=image_model_sha256(asdict(transferred)), - view="front", tag_role="thumb_ip", session_epoch=1, motion_version=12, - task_name=task.key, parent_reference_sha256=source_frame_sha256(check), - source_image_stamps=list(transferred.source_stamps_ns))} - return profile, records, evidence - - -def test_parent_check_survives_file_roundtrip_with_original_zero_and_current_pixels(setup): - profile, records, evidence = journal(setup) - records, evidence = json.loads(json.dumps((records, evidence))) - validate_parent_references(profile, records, evidence) - - -@pytest.mark.parametrize("change", ["missing_check", "held_channels", "sample", "missing_pixels", "model", "old_zero", "feedback_policy"]) -def test_parent_check_cannot_be_relabelled_or_lose_original_pixels(setup, change): - profile, records, evidence = journal(setup) - records, evidence = deepcopy((records, evidence)) - check = next(row for row in records if row["kind"] == "parent_reference_verified") - if change == "missing_check": records.remove(check) - elif change == "held_channels": check["held_channels"] = [] - elif change == "sample": - row = next(row for row in records if row["kind"] == "joint_zero_sample" and row["image_stamp_ns"] >= 5000000000) - row["state_u8"][1] = 80 - elif change == "missing_pixels": - records[:] = [r for r in records if not (r["kind"] == "pnp_candidate_frame" and r["image_stamp_ns"] >= 5000000000)] - elif change == "model": check["pose_evidence_id"] = "f"*64 - elif change == "feedback_policy": check['feedback_limits']['between_visit_difference'] = 11 - else: - records[:] = [r for r in records if not (r["kind"] == "pnp_candidate_frame" and r["image_stamp_ns"] < 5000000000)] - # Rehash the changed check: validation must inspect meaning and pixels, - # rather than rely on a metadata hash mismatch alone. - evidence["e"*64]["parent_reference_sha256"] = source_frame_sha256(check) - with pytest.raises(ValueError): validate_parent_references(profile, records, evidence) - - -def test_callback_epoch_changes_need_ordered_internal_recovery_evidence(): - recovery = dict(kind="joint_zero_recovery", action="retry_zero_images", - previous_session_epoch=1, session_epoch=2, stamp_ns=50) - assert same_capture_epoch([recovery], 1, 2, 10, 100) - assert not same_capture_epoch([], 1, 2, 10, 100) - assert not same_capture_epoch([recovery], 1, 3, 10, 100) - assert not same_capture_epoch([recovery], 1, 2, 60, 100) - assert not same_capture_epoch([{**recovery, "action": "pause"}], 1, 2, 10, 100) - - -@pytest.mark.parametrize("use", ["pose", "hinge"]) -@pytest.mark.parametrize("change", [None, "missing", "identity", "model", "epoch", "task", "late", "undeclared"]) -def test_restored_parent_sources_require_exact_journalled_model_identity(setup, use, change): - profile, records, evidence = journal(setup) - if use == "pose": - source_id = "a"*64 - original = evidence[source_id] - validate = lambda: validate_parent_references(profile, records, evidence) - else: - payload = evidence["e"*64] - transferred = image_motion_model_from_dict(payload["frozen_image_model"]) - source = transferred.source_hinges[0] - original_model = replace(image_motion_model_from_dict(evidence["a"*64]["frozen_image_model"]), - geometry=(source.geometry,)) - digest = image_model_sha256(asdict(original_model)) - transferred = replace(transferred, source_hinges=(replace(source, model_sha256=digest),)) - payload.update(frozen_image_model=asdict(transferred), - image_model_sha256=image_model_sha256(asdict(transferred))) - source_id = source.evidence_id - original = evidence[source_id] = dict(frozen_image_model=asdict(original_model), - image_model_sha256=digest, view="front", tag_role="thumb_mcp", - source_image_stamps=[1, 2], geometry_uncertainty=[("thumb_mcp", .001, .0001)]) - validate = lambda: validate_source_hinges(evidence, records) - original.update(session_epoch=2, task_name="passed_source_task") - restored = dict(kind="passed_pose_models_restored", resume_mode="passed", - unchanged_installation_declared=True, session_epoch=1, stamp_ns=4500000000, - models=[dict(source_epoch=2, task_name=original["task_name"], - image_model_sha256=original["image_model_sha256"], - evidence_ids={original["tag_role"]: source_id})]) - item = restored["models"][0] - if change == "identity": item["evidence_ids"][original["tag_role"]] = "f"*64 - elif change == "model": item["image_model_sha256"] = "f"*64 - elif change == "epoch": item["source_epoch"] = 3 - elif change == "task": item["task_name"] = "different_task" - elif change == "late": restored["stamp_ns"] = 7000000000 - elif change == "undeclared": restored["unchanged_installation_declared"] = False - if change != "missing": records.append(restored) - if change is None: - validate() - else: - with pytest.raises(ValueError, match="(?:pose_source|source_hinge)_unbound"): - validate() diff --git a/src/linkerhand_calibration/test/test_parent_reference_reuse.py b/src/linkerhand_calibration/test/test_parent_reference_reuse.py deleted file mode 100644 index 35c261d..0000000 --- a/src/linkerhand_calibration/test/test_parent_reference_reuse.py +++ /dev/null @@ -1,262 +0,0 @@ -"""A held parent is checked in place; independent zeros and sources stay frozen.""" - -from copy import deepcopy -from dataclasses import asdict, replace -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.validator import validate_executable_profile -from linkerhand_calibration.profiles.observations import compile_tag_feedback_channels -from linkerhand_calibration.core.domain.reference import JointZeroPose, JointZeroSample, JointZeroReference -from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionModel -from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageHingeGeometry -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, MotionBranchModel -from linkerhand_calibration.runtime.parent_reference import ParentReferenceRegistry, check_parent_pose -from linkerhand_calibration.runtime.capture import ObservationCapture -from linkerhand_calibration.runtime.reference_lock import ReferenceLock -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.zero_recovery import ZeroRecovery -from o30_capture_fixture import SOURCE - - -@pytest.fixture -def setup(): - profile = load_bundled_hand_profile("o30_right_18", scope="without_finger_roll") - task = next(t for t in profile.motion.tasks if t.key == "thumb_ip_front") - channels = compile_tag_feedback_channels(profile, UrdfKinematicModel(SOURCE)) - return profile, task, channels - - -def reference_and_rows(profile, task): - spec = profile.motion.joint_zero_references[task.parent_reference.pose_joint] - pose = JointZeroPose((0., 0., 0., 1.), (.02, .03, .7)) - command = spec.command - feedback = list(command) - feedback[0] = 10. # A repeatable command/feedback offset is not a pose fault. - samples = tuple(JointZeroSample(f"front:{100+i}", "front", 100+i, (0., 0., 0., 1.), - (0., 0., 0.), command, tuple(feedback), ("decreasing",)*20, pose, pose) for i in range(10)) - reference = JointZeroReference(task.parent_reference.pose_joint, spec.task_key, 0., 0, 1, 2, samples) - target = profile.motion.joint_zero_references["thumb_ip"].command - feedback[2] = target[2] - rows = [dict(kind="joint_zero_sample", joint=reference.joint, task_name=task.key, - view="front", image_stamp_ns=1000+i, relative_quaternion_xyzw=(0., 0., 0., 1.), - relative_translation_xyz_m=(0., 0., 0.), command_vector_u8=target, state_u8=tuple(feedback), - command_direction_by_index=("decreasing",)*20, parent_pose_common=asdict(pose), child_pose_common=asdict(pose)) - for i in range(10)] - return reference, rows - - -@pytest.mark.parametrize("change", ["none", "yaw", "roll", "tag", "feedback", "unstable", "short", "duplicate", "stale"]) -def test_stationary_check_uses_ancestry_current_images_and_feedback_stability(setup, change): - profile, task, channels = setup - reference, rows = reference_and_rows(profile, task) - before = deepcopy(reference) - if change in {"yaw", "roll"}: - values = list(rows[-1]["command_vector_u8"]); values[1 if change == "yaw" else 0] += 1 - rows[-1]["command_vector_u8"] = tuple(values) - elif change == "tag": - rows[-1]["child_pose_common"]["quaternion_xyzw"] = tuple(Rotation.from_euler("x", 10, degrees=True).as_quat()) - elif change in {"feedback", "unstable"}: - for row in (rows if change == "feedback" else rows[-1:]): - values = list(row["state_u8"]); values[6] += 10 if change == "feedback" else 3 - row["state_u8"] = tuple(values) - elif change == "short": rows.pop() - elif change == "duplicate": rows[-1] = deepcopy(rows[0]) - elif change == "stale": rows[0]["image_stamp_ns"] = 1 - if change == "none": - check_parent_pose(profile, task, reference, rows, channels["thumb_mcp"]) - else: - with pytest.raises(ValueError, match="parent_reference"): - check_parent_pose(profile, task, reference, rows, channels["thumb_mcp"]) - assert reference == before - - -def model(joint, identity): - image = ImageMotionModel((ImageHingeGeometry(MotionRelation(joint, "front_base", "thumb_mcp"), - (0., 0., 1.), (0., 0., 0., 1.), (.01, .02, 0.), (.02, 0., 0.)),), - ((2000., 0., 800.), (0., 2000., 600.), (0., 0., 1.)), - (("front_base", .0165), ("thumb_mcp", .0165)), (1, 2), (1,), (2,), (("front_base", 0), ("thumb_mcp", 0))) - branch = MotionBranchModel(joint+"_front", "front", (), (("thumb_mcp", identity),), image) - report = dict(session_epoch=1, image_model_sha256=image_model_sha256(asdict(image)), - geometry_uncertainty=[(joint, .001, .0001)]) - return branch, report - - -def test_registry_needs_both_sources_and_fresh_check_and_preserves_zeros(setup): - profile, task, channels = setup - owner = ParentReferenceRegistry(profile, channels) - reference, rows = reference_and_rows(profile, task) - roll, roll_report = model("thumb_cmc_roll", "a"*64) - mcp, mcp_report = model("thumb_mcp", "b"*64) - owner.remember(roll, roll_report) - with pytest.raises(ValueError, match="source_missing"): owner.sources(task, 1) - owner.remember(mcp, mcp_report) - with pytest.raises(ValueError, match="source_missing"): owner.sources(task, 2) - with pytest.raises(ValueError, match="unverified"): owner.geometry(task, 1) - - refresh = owner.authorize_refreshed_pose(task, 1, 9, reference) - assert refresh["passed_zero_preserved"] is True - assert refresh["image_model_sha256"] == roll_report["image_model_sha256"] - assert owner.pose_source(task, 1) == (roll, roll_report) - - for row in rows: - row["pnp_observation_evidence"] = {"child": dict(tag_role="thumb_mcp", - image_model_sha256=roll_report["image_model_sha256"], - candidate_diagnostics={"motion_evidence_id": "a"*64})} - check = owner.confirm(task, 1, 10, reference, rows) - hinges, identity = owner.geometry(task, 1) - assert hinges[0].geometry == mcp.image_model.geometry[0] - assert hinges[0].evidence_id == "b"*64 and check["pose_evidence_id"] == "a"*64 - assert check["source_reference"] == reference.as_record() and len(identity) == 64 - owner.advance_recovery_epoch(1, 2) - assert owner.geometry(task, 2) == (hinges, identity) - assert owner.sources(task, 2)[0][1]["session_epoch"] == 1 - owner.clear() - with pytest.raises(ValueError, match="unverified"): owner.geometry(task, 1) - - -def test_parent_repeatability_uses_declared_revisit_policy_and_preserves_legacy(setup): - from linkerhand_calibration.runtime.revisit import REVISIT_POLICY, revisit_feedback_limits - profile, task, channels = setup - reference, rows = reference_and_rows(profile, task) - limits = revisit_feedback_limits(profile) - for row in rows: - values = list(row['state_u8']); values[6] += 8 - row['state_u8'] = tuple(values) - with pytest.raises(ValueError, match='held_feedback_changed'): - check_parent_pose(profile, task, reference, rows, channels['thumb_mcp']) - check_parent_pose(profile, task, reference, rows, channels['thumb_mcp'], - verification=REVISIT_POLICY, feedback_limits=limits) - for fault in ('unstable', 'outside', 'policy', 'image'): - changed = deepcopy(rows); declared = dict(limits) - if fault == 'unstable': - values=list(changed[-1]['state_u8']); values[6] -= 3 - changed[-1]['state_u8']=tuple(values) - elif fault == 'outside': - for row in changed: - values=list(row['state_u8']);values[6]+=3;row['state_u8']=tuple(values) - elif fault == 'policy': declared['between_visit_difference'] = 11 - else: changed[-1]['child_pose_common']['translation_xyz_m'] = (.02,.03,.72) - with pytest.raises(ValueError, match='parent_reference'): - check_parent_pose(profile, task, reference, changed, channels['thumb_mcp'], - verification=REVISIT_POLICY, feedback_limits=declared) - - -def test_activation_removes_incompatible_parent_and_keeps_reused_model_through_ip_approach(setup): - profile, task, _ = setup - capture = ObservationCapture(profile, reference_lock=ReferenceLock({"front": 0, "side": 3, "top": 16}), - extrinsics=SimpleNamespace(transform=lambda _: np.eye(4))) - roll, _ = model("thumb_cmc_roll", "a"*64) - mcp, _ = model("thumb_mcp", "b"*64) - capture.install_motion_model(mcp) - capture.activate_reference_model(roll) - assert list(capture._motion_models.values()) == [roll] - capture.begin_motion(MotionCommand("zero_approach", profile.command.baseline_values, 200, - task_key=task.key, reference_joints=("thumb_ip",))) - assert list(capture._motion_models.values()) == [roll] - assert roll.image_model.geometry[0].relation.joint == "thumb_cmc_roll" - - -def test_stationary_reference_cannot_trigger_a_new_solver_or_extra_motion_retry(setup): - profile, task, _ = setup - motion = MotionCommand("joint_zero", profile.command.baseline_values, 200, - task_key=task.key, zero_joints=("thumb_cmc_roll",), reference_only=True, reference_reuse=True) - owner = BranchInitialization(profile, source_urdf=SOURCE) - owner.begin(motion, session_epoch=1, motion_version=10) - assert owner.request("front", motion, session_epoch=1, motion_version=10) is None - recovery = ZeroRecovery() - assert recovery.take(motion, geometry_error="", reports={}, referenced_joints=()) is None - assert recovery.take_parent_model_refresh(motion, stale_image_anchor=False) is None - ordinary = replace(motion, reference_only=False, reference_reuse=False, zero_joints=("thumb_ip",)) - assert recovery.take(ordinary, geometry_error="ambiguous", reports={"front": { - "status": "unresolved", "reason": "image_motion_families_not_distinguishable"}}, referenced_joints=()) is None - - -def test_stale_cross_session_anchor_gets_one_model_refresh_without_replacing_zero(setup): - profile, task, _ = setup - parent = task.parent_reference.pose_joint - motion = MotionCommand("joint_zero", - profile.motion.joint_zero_references[task.joints[0]].command, 200, - task_key=task.key, cycle=0, zero_joints=(parent,), - reference_only=True, reference_reuse=True) - recovery = ZeroRecovery() - assert recovery.take_parent_model_refresh(motion, stale_image_anchor=True) == "refresh_parent_model" - assert recovery.take_parent_model_refresh(motion, stale_image_anchor=True) is None - - execution = SessionExecution(profile) - execution.session.phase = Phase.PREPARE - frozen = object() - execution.zero_references[parent] = frozen - execution._effects = [motion] - execution.refresh_parent_model(motion.target) - assert execution.zero_references[parent] is frozen - assert execution._effects[-1] is motion - refresh = execution._effects[:-1] - assert refresh and refresh[-1].phase == "joint_zero" - assert refresh[-1].zero_joints == (parent,) - assert all(item.parent_model_refresh_for == task.key for item in refresh) - assert all(item.reference_only and not item.reference_reuse for item in refresh) - moving = [item for item in refresh if item.phase == "zero_approach"] - from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint - channel = channel_for_joint(profile, parent) - assert max(item.target[channel] for item in moving) != min(item.target[channel] for item in moving) - - -def test_coordinator_waits_for_current_parent_images_and_keeps_both_joint_zeros(setup, tmp_path, monkeypatch): - from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator - from linkerhand_calibration.runtime import coordinator as module - - profile, task, channels = setup - reference, rows = reference_and_rows(profile, task) - owner = ParentReferenceRegistry(profile, channels) - roll, report = model("thumb_cmc_roll", "a"*64) - owner.remember(roll, report) - owner.remember(*model("thumb_mcp", "b"*64)) - for row in rows: - row["pnp_observation_evidence"] = {"child": dict(tag_role="thumb_mcp", - image_model_sha256=report["image_model_sha256"], - candidate_diagnostics={"motion_evidence_id": "a"*64})} - frozen, records = [], [] - zeros = {reference.joint: reference, "thumb_mcp": object()} - host = SimpleNamespace(profile=profile, - branch_initialization=SimpleNamespace(ready=lambda _: pytest.fail("stationary reference cannot wait for a new solve"), - parent_references=owner), execution=SimpleNamespace(zero_references=zeros.copy()), - _steady_rows=rows[:-1], _observation_epoch=1, _segment_number=10, - _freeze_reference_branches=lambda value: frozen.append(value) or True, - raw_path=tmp_path/"reference.jsonl", capture_index=records) - revisions = {("front", "thumb_mcp"): 2, ("front", "front_base"): 1} - monkeypatch.setattr(module, "reference_branch_revisions", lambda *args, **kwargs: revisions) - motion = MotionCommand("joint_zero", profile.command.baseline_values, 200, - task_key=task.key, zero_joints=(reference.joint,), reference_only=True, reference_reuse=True) - assert not CalibrationCoordinator._confirm_model_reference(host, motion) - assert not records and not frozen - host._steady_rows = rows - assert CalibrationCoordinator._confirm_model_reference(host, motion) - assert frozen == [revisions] and records[0]["kind"] == "parent_reference_verified" - assert host.execution.zero_references == zeros - assert owner.geometry(task, 1)[0][0].geometry.relation.joint == "thumb_mcp" - - -@pytest.mark.parametrize("change", ["future_source", "wrong_tag", "wrong_pose", "nonadjacent_geometry"]) -def test_invalid_reference_contract_rejected_before_hardware(setup, change): - profile, task, _ = setup - if change == "future_source": task = replace(task, parent_reference=replace(task.parent_reference, pose_joint="thumb_cmc_yaw")) - elif change == "wrong_tag": task = replace(task, parent_reference=replace(task.parent_reference, pose_joint="index_mcp_roll")) - elif change == "nonadjacent_geometry": task = replace(task, parent_reference=replace(task.parent_reference, geometry_joint="thumb_cmc_roll")) - else: - references = dict(profile.motion.joint_zero_references) - pose = list(references["thumb_ip"].command); pose[1] = 80 - references["thumb_ip"] = replace(references["thumb_ip"], command=tuple(pose)) - profile = replace(profile, motion=replace(profile.motion, joint_zero_references=references)) - profile = replace(profile, motion=replace(profile.motion, - tasks=tuple(task if t.key == task.key else t for t in profile.motion.tasks))) - with pytest.raises(ValueError): validate_executable_profile(profile, SOURCE) diff --git a/src/linkerhand_calibration/test/test_partial_o30_calibration.py b/src/linkerhand_calibration/test/test_partial_o30_calibration.py deleted file mode 100644 index 320f750..0000000 --- a/src/linkerhand_calibration/test/test_partial_o30_calibration.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Partial release retains CAD byte values and cannot invent excluded curves.""" - -from copy import deepcopy -from dataclasses import replace -import hashlib -import json -import xml.etree.ElementTree as ET - -import pytest - -from o30_capture_fixture import synthetic_capture, SOURCE -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.urdf.partial_scope import profile_scope, retained_from_payload, require_retained_pose -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -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 - - -def test_partial_tasks_keep_sdk_and_avoidance_contract(tmp_path): - full = load_bundled_hand_profile('o30_right_18', scope='full') - partial = load_bundled_hand_profile('o30_right_18', scope='without_finger_roll') - assert partial.command == full.command - assert len(partial.motion.tasks) == 16 - assert [task.key for task in partial.motion.tasks] == [ - 'thumb_cmc_roll_front', 'thumb_mcp_front', 'thumb_ip_front', 'thumb_cmc_yaw_top', - *(f'{finger}_{joint}_side' for finger in ('pinky', 'ring', 'middle', 'index') - for joint in ('mcp_pitch', 'pip', 'dip')), - ] - from linkerhand_calibration.runtime.trajectory import build_calibration_motion_command - for task in partial.motion.tasks: - reference = partial.motion.joint_zero_references[task.joints[0]] - assert reference.command == tuple(build_calibration_motion_command(task, 0, profile=partial)) - assert reference.command[3:6] == (196, 127, 52) - assert reference.command[2] == (0 if task.key == 'thumb_cmc_roll_front' else 255) - for approach in reference.approach_commands: - assert all(value == reference.command[index] for index, value in enumerate(approach) - if index != task.command_index) - yaw = partial.motion.joint_zero_references['thumb_cmc_yaw'] - assert yaw.command == partial.command.baseline_values - assert yaw.approach_commands == ((0, 255, 255, 196, 127, 52) + (0,) * 14,) - assert len(partial.zero.direct_zero_joints) == 11 - assert tuple(t for t in full.motion.tasks if t.key != 'fingers_mcp_roll_front') == partial.motion.tasks - assert not partial.retained_joints & partial.urdf_authorized_fields.keys() - assert partial.artifacts.publication_pointer != full.artifacts.publication_pointer - for name in partial.retained_joints: - assert name not in partial.motion.joint_zero_references - # Scope compilation is idempotent across profile export/readback. - from linkerhand_calibration.profiles.scope import compile_capture_scope - assert compile_capture_scope(partial) == partial - from linkerhand_calibration.profiles import dump_hand_profile, load_hand_profile - path = tmp_path/'partial.yaml'; path.write_text(dump_hand_profile(partial)) - assert load_hand_profile(path) == partial - from linkerhand_calibration.runtime.engine import CalibrationEngine, ACQUISITION_POLICY_VERSION - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - engine = CalibrationEngine(partial) - header = dict(profile_id=partial.key.profile_id, - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - capture_schedule_version=engine.capture_schedule_version, - pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, - calibration_scope=profile_scope(partial)) - assert engine.resume_compatible(header) - assert not engine.resume_compatible({**header, - 'capture_schedule_version': 'unified_schedule_v7_single_pass_endpoint_images'}) - assert not CalibrationEngine(full).resume_compatible(header) - header.pop('calibration_scope') - assert not engine.resume_compatible(header) - - -@pytest.mark.parametrize('camera_aligned_reference', [False, True]) -def test_partial_o30_finalization_and_independent_readback(tmp_path, monkeypatch, camera_aligned_reference): - from linkerhand_calibration.runtime.artifacts import finalization - from test_final_image_holdout import independent_pixels - original = finalization.FrozenTagArtifactValidator - def independent_validator(*args, **kwargs): - validator = original(*args, **kwargs) - return replace(validator, require_image_holdout=True, - command_image_observations=independent_pixels(validator.command_observations)) - monkeypatch.setattr(finalization, 'FrozenTagArtifactValidator', independent_validator) - profile, source, records, truth = synthetic_capture(scope='without_finger_roll', - camera_aligned_reference=camera_aligned_reference) - digest = hashlib.sha256(source.read_bytes()).hexdigest() - hashes = {key: 'a'*64 for key in profile.artifacts.protected_input_fields} - hashes['source_urdf_sha256'] = digest - _, fit, artifact = finalize_profile_session(profile=profile, source_urdf=source, - records=records, protected_inputs=hashes, serial_number='SYNTHETIC_PARTIAL_ONLY', - session_dir=tmp_path/'session', standard_loader=lambda _: None) - payload = json.loads(artifact.staged_release.calibration_json.read_text()) - assert len(payload['joints']) == 16 - assert payload['calibration_scope'] == profile_scope(profile) - assert not profile.retained_joints & (fit.zero_offsets_rad.keys() | fit.command_mappings.keys()) - for name in profile.zero.direct_zero_joints: - assert fit.zero_offsets_rad[name] == pytest.approx(truth[name], abs=.004) - before = {j.get('name'): ET.tostring(j) for j in ET.parse(source).findall('joint')} - after = {j.get('name'): ET.tostring(j) for j in ET.parse(artifact.path).findall('joint')} - for name in profile.retained_joints: - assert before[name] == after[name] - mapper = load_unified_mapper(artifact.staged_release.calibration_json) - assert set(mapper.urdf_joint_names) == payload['joints'].keys() - assert len(mapper.map_positions(profile.command.baseline_values)) == 16 - changed = list(profile.command.baseline_values); changed[4] += 1 - with pytest.raises(ValueError, match='uncalibrated_ancestor'): - mapper.map_positions(changed) - rebuilt = rebuild_urdf_from_json(artifact.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() == artifact.path.read_bytes() - assert hashlib.sha256(source.read_bytes()).hexdigest() == digest - corrupt = deepcopy(payload) - corrupt['urdf_correction']['joint_patches']['ring_mcp_roll'] = {'origin_rpy': '0 0 0'} - with pytest.raises(ValueError, match='retained_joint_has_urdf_correction'): - retained_from_payload(corrupt) - del corrupt['calibration_scope'] - with pytest.raises(ValueError, match='joints differ'): - retained_from_payload(corrupt, UrdfKinematicModel(source)) - - -def test_retained_commands_are_checked_only_on_observed_chain(): - profile = load_bundled_hand_profile('o30_right_18', scope='without_finger_roll') - model = UrdfKinematicModel(SOURCE) - retained = profile_scope(profile)['retained_joints'] - commands = list(profile.command.baseline_values); commands[2] = 0 - # Thumb roll requires index avoidance; it does not move the thumb's ancestors. - require_retained_pose(retained, commands, model=model, link='thumb_proximal') - with pytest.raises(ValueError, match='index_mcp_roll'): - require_retained_pose(retained, commands, model=model, link='index_distal') - - -def test_auxiliary_pixels_outside_partial_scope_are_explicit_and_never_primary(): - from types import SimpleNamespace - from linkerhand_calibration.runtime.artifacts.image_evidence import ImageEvidence - from linkerhand_calibration.runtime.image_capture import IMAGE_OBSERVATION_POLICY - from linkerhand_calibration.core.domain.motion_path import record_scan_identity - profile = load_bundled_hand_profile('o30_right_18', scope='without_finger_roll') - evidence = object.__new__(ImageEvidence) - evidence.profile, evidence.source_model = profile, UrdfKinematicModel(SOURCE) - evidence.tasks = {t.key: t for t in profile.motion.tasks} - evidence.tags = {tag.role: (v.name, tag) for v in profile.vision.views for tag in v.tags} - evidence.out_of_scope_images = [] - task = next(t for t in profile.motion.tasks if 'index_mcp_pitch' in t.joints) - row = dict(kind='image_observation_frame', policy=IMAGE_OBSERVATION_POLICY, - view='side', task_name=task.key, cycle=3, direction='increasing', attempt=1, - sample_phase='steady', command_unit='u8', command_vector=list(profile.command.baseline_values), - feedback_vector=list(profile.command.baseline_values), state_image_sync_error_ns=0, - command_direction_by_index=['increasing']*20, tags={'index_dip': {}}) - extra = deepcopy(row); extra['task_name'] = 'thumb_cmc_roll_front'; extra['command_vector'][2] = 0 - evidence.raw_frames = {'side:1': row, 'side:2': extra} - evidence.completed = {record_scan_identity(r): dict(r, passed=True) for r in (row, extra)} - primary = SimpleNamespace(role='index_dip', sample_id='side:1') - # This test isolates evidence routing; the end-to-end test uses independent pixels. - evidence.primary_holdout = lambda _: (primary,) - evidence._image = lambda frame, **kwargs: primary if kwargs['sample_id']=='side:1' else None - mappings = {n: SimpleNamespace(motor_index=profile.command.command_index_by_joint[n]) - for n in profile.measurement.measurements} - assert evidence.all_view_holdout((primary,), mappings=mappings, input_kind='command', holdout_cycle=3) == (primary,) - assert evidence.out_of_scope_images == [{'sample_id': 'side:2', 'role': 'index_dip', - 'task_name': 'thumb_cmc_roll_front', 'retained_ancestors': ['index_mcp_roll'], - 'reason': 'unmeasured_ancestor_outside_declared_hold'}] - row['command_vector'][2] = 0 - with pytest.raises(ValueError, match='primary_image_has_uncalibrated_moving_ancestor'): - evidence.all_view_holdout((primary,), mappings=mappings, input_kind='command', holdout_cycle=3) diff --git a/src/linkerhand_calibration/test/test_passed_resume.py b/src/linkerhand_calibration/test/test_passed_resume.py deleted file mode 100644 index 3ae8875..0000000 --- a/src/linkerhand_calibration/test/test_passed_resume.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Passed-task continuation skips old motion/replay and retains live safety.""" - -from dataclasses import replace -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.joint_resume import JointResume -from linkerhand_calibration.runtime import passed_resume -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from runtime_host_fixture import coordinator_fixture, ready - - -def passed_records(engine, task): - records = [dict(kind='scan_unit_complete', task_name=unit.task_key, cycle=unit.cycle, - direction=unit.direction, segment_key=unit.segment_key, attempt=1, passed=True) - for unit in engine.scan_units() if unit.task_key == task.key] - return [*records, dict(kind='task_input_support_checked', task_name=task.key, passed=True)] - - -def stub_references(tasks): - return {name: SimpleNamespace(joint=name, task_key=task.key, samples=(), - as_record=lambda name=name: dict(kind='joint_zero_reference', joint=name)) - for task in tasks for name in task.joints} - - -def test_passed_parent_model_keeps_original_source_in_new_observation_epoch(): - from linkerhand_calibration.runtime.parent_reference import ParentReferenceRegistry - from test_image_motion_provenance import recorded_image - - frame, _ = recorded_image() - moving = frame['roles']['moving'] - report = dict(task_name='task', view='front', session_epoch=2, - evidence_ids={'moving': 'a' * 64}, frozen_image_model=moving['frozen_image_model'], - image_model_sha256=moving['image_model_sha256']) - registry = ParentReferenceRegistry(None, {}) - passed_resume.restore_parent_models(registry, [report], 7) - task = SimpleNamespace(view='front', parent_reference=SimpleNamespace(pose_joint='hinge', geometry_joint='hinge')) - pose_source, geometry_source = registry.sources(task, 7) - assert pose_source == geometry_source - assert pose_source[1] is report and report['session_epoch'] == 2 - assert dict(pose_source[0].evidence_ids)['moving'] == 'a' * 64 - - -def test_passed_resume_does_not_restore_incomplete_task_preparation(monkeypatch): - profile = load_bundled_hand_profile('o30_right_18') - engine, resume = CalibrationEngine(profile), JointResume(profile) - passed, incomplete = profile.motion.tasks[:2] - rows = [*passed_records(engine, passed), - dict(kind='motion_branch_observation', task_name=passed.key, - view=passed.view, image_stamp_ns=1, session_epoch=1), - dict(kind='motion_branch_observation', task_name=incomplete.key, - view=incomplete.view, image_stamp_ns=2, session_epoch=1), - dict(kind='motion_branch_initialization', task_name=incomplete.key, - view=incomplete.view, status='resolved', session_epoch=1)] - monkeypatch.setattr(passed_resume, 'read_joint_zero_references', - lambda *args: stub_references([passed, incomplete])) - - passed_resume.stage_passed_tasks(resume, engine, rows) - pending = passed_resume.reference_import(resume) - - assert {row.get('task_name') for row in pending.rows if row.get('task_name')} == {passed.key} - assert not pending.model_records - - -def test_taskwise_passed_resume_does_not_restore_unpassed_visit_model(): - from linkerhand_calibration.runtime import staged_resume - - profile = load_bundled_hand_profile('o30_right_18') - passed, incomplete = profile.motion.tasks[:2] - references = stub_references([passed, incomplete]) - rows = [ - dict(kind='motion_branch_observation', task_name=passed.key, - view=passed.view, image_stamp_ns=1, session_epoch=1), - dict(kind='motion_branch_observation', task_name=incomplete.key, - view=incomplete.view, image_stamp_ns=2, session_epoch=1), - dict(kind='motion_branch_initialization', task_name=incomplete.key, - view=incomplete.view, status='resolved', session_epoch=1), - ] - accepted = next(unit for unit in CalibrationEngine(profile).scan_units() - if unit.task_key == passed.key) - resume = JointResume(profile) - resume.rows, resume.references, resume.units = tuple(rows), references, {accepted.identity} - - pending = staged_resume.reference_import(resume, reuse_passed_visits=True) - - assert set(pending.references) == set(passed.joints) - assert {row.get('task_name') for row in pending.rows if row.get('task_name')} == {passed.key} - assert not pending.model_records - - -def test_passed_resume_omits_orphaned_observer_cancellation(): - from linkerhand_calibration.runtime import staged_resume - - profile = load_bundled_hand_profile('o30_right_18') - task = profile.motion.tasks[0] - accepted = next(unit for unit in CalibrationEngine(profile).scan_units() - if unit.task_key == task.key) - orphan = dict(kind='observer_preparation_cancelled_before_excursion', - joint=task.joints[0], observer_preparation_id='a'*64, - session_epoch=1, stamp_ns=1) - resume = JointResume(profile) - resume.rows = (orphan,) - resume.references = stub_references([task]) - resume.units = {accepted.identity} - - pending = staged_resume.reference_import(resume, reuse_passed_visits=True) - - assert orphan not in pending.rows - - -@pytest.mark.parametrize('failure', [None, 'missing', 'failed', 'support', 'new_attempt']) -def test_last_group_failure_keeps_96_prior_passes_without_recalculating(monkeypatch, failure): - profile = load_bundled_hand_profile('o30_right_18') - engine, resume = CalibrationEngine(profile), JointResume(profile) - rows = [row for task in profile.motion.tasks for row in passed_records(engine, task)] - if failure == 'missing': - rows.pop(-2) - elif failure == 'failed': - rows[-2]['passed'] = False - elif failure == 'support': - rows[-1]['passed'] = False - elif failure == 'new_attempt': - rows.append({**rows[-2], 'attempt': 2}) - monkeypatch.setattr(passed_resume, 'read_joint_zero_references', lambda *args: stub_references(profile.motion.tasks)) - from linkerhand_calibration.runtime import motion_provenance, scan_quality, task_quality - for module, name in ((motion_provenance, 'validate_motion_provenance'), - (scan_quality, 'evaluate_capture_unit'), (task_quality, 'evaluate_task_input_support')): - monkeypatch.setattr(module, name, lambda *a, **kw: pytest.fail('unexpected historical validation')) - passed_resume.stage_passed_tasks(resume, engine, rows) - assert len(resume.units) == (108 if failure is None else 96) - assert len(resume.references) == (20 if failure is None else 16) - assert resume.verified == set(resume.references) - - -@pytest.mark.parametrize('interruption', [None, 'abort', 'feedback']) -@pytest.mark.parametrize('staged', [False, True]) -def test_direct_resume_imports_durably_then_skips_old_joint_motion(tmp_path, monkeypatch, interruption, staged): - from linkerhand_calibration.core.domain.capture_plan import configure_training, STAGED - from linkerhand_calibration.runtime import staged_resume - host, clock = coordinator_fixture(tmp_path, model='o30' if staged else 'o6', - profile_transform=(lambda p: configure_training(p, STAGED)) if staged else None) - try: - host.parameters = replace(host.parameters, resume_mode='passed') - task = host.profile.motion.tasks[0] - expected_units = sum(unit.task_key == task.key for unit in host.execution.session.engine.scan_units()) - rows = [dict(kind='motion_branch_observation', view=task.view, image_stamp_ns=i, - session_epoch=1, task_name=task.key) - for i in range(257)] + [dict(kind='observer_preparation_decision', - task_name=task.key, joint=task.joints[0], action='excite', - action_index=0, session_epoch=1, stamp_ns=1)] + passed_records( - host.execution.session.engine, task) - references = stub_references([task]) - monkeypatch.setattr(passed_resume, 'read_joint_zero_references', lambda *args: references) - resume = JointResume(host.profile) - if staged: - rows = [r for r in rows if r.get('kind') != 'scan_unit_complete' or r['cycle'] == 0] - resume.references, resume.rows = references, tuple(rows) - resume.units = {u.identity for u in host.execution.session.engine.scan_units() - if u.task_key == task.key and u.cycle == 0} - expected_units = len(resume.units) - pending = staged_resume.reference_import(resume, reuse_passed_visits=True) - assert resume.verified_visits == {(name, 0) for name in task.joints} - else: - passed_resume.stage_passed_tasks(resume, host.execution.session.engine, rows) - pending = passed_resume.reference_import(resume) - host._resume_checkpoint = SimpleNamespace(joints=resume, - passed_import=pending, rows=rows, frozen_training=None, - fixed_reference={'protected_hashes': dict(host.parameters.protected_inputs)}) - monkeypatch.setattr(passed_resume, 'reference_import', - lambda *a: pytest.fail('checkpoint indexing must finish before live callbacks')) - from linkerhand_calibration.runtime.resume import ResumeVerifier - monkeypatch.setattr(ResumeVerifier, 'compare', lambda *a: pytest.fail('unexpected physical recheck')) - monkeypatch.setattr(host, '_lock_joint_zeros', lambda *a: pytest.fail('old zero must not be acquired')) - ready(host, clock) - assert host.start().success - clock.now += .1 - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.execution.session.phase = Phase.RESUME_VERIFY - host._current_fingerprint = {'protected_hashes': dict(host.parameters.protected_inputs)} - host.tick() - assert host._passed_reference_import is not None - host.tick() - assert not host.execution.zero_references and host._resumed_count == 0 - if interruption: - if interruption == 'abort': - host.abort() - else: - clock.now += 2 - host.tick() - assert host.state in {'ABORTED', 'PAUSED'} - assert not host.execution.zero_references and host._resumed_count == 0 - else: - # Reference restoration is intentionally sliced across callbacks - # and journal barriers run on a worker. Give that worker real - # scheduling opportunities instead of spinning only the fake clock. - import time - for _ in range(400): - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - if host.execution.session.current_unit.task_key != task.key: - break - time.sleep(.001) - assert host._resumed_count == expected_units - assert host.execution.zero_references == references - assert not host.branch_initialization.observer_preparation.decisions - assert host.segment is None - import json - imported = [json.loads(line) for line in host.raw_path.read_text().splitlines()] - if staged: - assert sum(r.get('kind') == 'scan_unit_complete' for r in imported) == expected_units - audit = next(r for r in imported if r.get('kind') == 'passed_tasks_reused') - assert audit['historical_replay_performed'] is False - assert audit['full_replay_required_before_publication'] is True - assert audit['physical_zero_reverification_performed'] is False - binding = next(r for r in imported if r.get('kind') == 'passed_pose_models_restored') - assert binding['unchanged_installation_declared'] is True - else: - again = JointResume(host.profile) - passed_resume.stage_passed_tasks(again, host.execution.session.engine, imported) - assert len(again.units) == expected_units - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_paused_snapshot.py b/src/linkerhand_calibration/test/test_paused_snapshot.py deleted file mode 100644 index 8f343c6..0000000 --- a/src/linkerhand_calibration/test/test_paused_snapshot.py +++ /dev/null @@ -1,106 +0,0 @@ -"""A paused task must never display another scan's successful statistics.""" - -import pytest - -from linkerhand_calibration.runtime.engine import SweepQuality -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.status import render_status_zh -from runtime_host_fixture import coordinator_fixture, ready - - -@pytest.mark.parametrize('current_samples', [0, 37]) -def test_paused_front_task_reports_its_own_partial_capture(tmp_path, current_samples): - host, clock = coordinator_fixture(tmp_path, diagnostic_capture='o6_thumb') - try: - ready(host, clock) - assert host.start().success - session = host.execution.session - session._unit_index = next(i for i, unit in enumerate(session._units) - if unit.task_key == 'thumb_pitch_ip_front' and unit.direction == 'decreasing') - session.phase = Phase.PAUSED - unit = session.current_unit - host.execution.last_quality = SweepQuality(True, (), (), {'previous_yaw': { - 'valid_frames': 194, 'feedback_span': 1., 'feedback_bins': 132, 'maximum_bin_gap': 6}}) - rows = [] - for joint in ('rh_thumb_cmc_pitch', 'rh_thumb_ip'): - for index in range(current_samples): - rows.append({'kind': 'joint_sample', 'joint': joint, 'view': 'front', - 'task_name': unit.task_key, 'cycle': unit.cycle, 'direction': unit.direction, - 'attempt': 1, 'sample_phase': 'sweep', 'image_stamp_ns': index+1, - 'feedback_u8': 255-index}) - # Stale or different-direction observations cannot fill current bins. - rows += [dict(row, task_name='thumb_yaw_top') for row in rows] - rows += [dict(row, direction='increasing') for row in rows] - host._unit_rows = rows - status = host.snapshot().calibration - assert status.task.key == unit.task_key - assert status.acquisition.valid_samples == current_samples - assert status.acquisition.coverage_01 < .2 - assert status.acquisition.bins <= current_samples - assert host.execution.last_quality.metrics['previous_yaw']['valid_frames'] == 194 - finally: - host.close() - - -@pytest.mark.parametrize('phase', [Phase.PAUSED, Phase.ABORTED, Phase.FAILED]) -def test_interruption_phase_is_not_hidden_by_last_joint_zero_motion(tmp_path, phase): - host, clock = coordinator_fixture(tmp_path, diagnostic_capture='o6_thumb') - try: - ready(host, clock) - assert host.start().success - host.execution.session.phase = phase - host._motion = MotionCommand('joint_zero', host.profile.command.baseline_values, 40., - task_key='thumb_yaw_top', command_index=1, zero_joints=('rh_thumb_cmc_yaw',)) - snapshot = host.snapshot() - assert snapshot.calibration.phase == phase.value - assert snapshot.calibration.task.label == '采集关节零位:rh_thumb_cmc_yaw' - assert '阶段:采集并冻结关节零位' not in render_status_zh(snapshot.as_dict()) - finally: - host.close() - - -@pytest.mark.parametrize('diagnostic', [True, False]) -def test_diagnostic_status_separates_raw_corner_coverage_from_accepted_pose_coverage(tmp_path, diagnostic): - from test_diagnostic_quality import raw_frames - - host, clock = coordinator_fixture(tmp_path, diagnostic_capture='o6_thumb' if diagnostic else '') - try: - ready(host, clock) - assert host.start().success - host.execution.session.phase = Phase.SWEEP - unit = host.execution.session.current_unit - host._unit_rows = raw_frames(host.profile, unit) - snapshot = host.snapshot() - rendered = render_status_zh(snapshot.as_dict()) - if diagnostic: - assert snapshot.calibration.acquisition.valid_samples == 129 - assert snapshot.calibration.acquisition.coverage_01 == 1. - assert snapshot.diagnostic_capture['accepted_pose_quality']['passed'] is False - assert snapshot.diagnostic_capture['accuracy_validated'] is False - assert '原始观测 129;覆盖 100.0%' in rendered - assert '可信姿态样本 0;覆盖 0.0%(未进行精度验收)' in rendered - else: - assert snapshot.calibration.acquisition.valid_samples == 0 - assert snapshot.diagnostic_capture is None - assert '有效样本 0' in rendered and '原始观测' not in rendered - finally: - host.close() - - -def test_paused_retry_does_not_reuse_first_attempt_samples(tmp_path): - host, clock = coordinator_fixture(tmp_path, diagnostic_capture='o6_thumb') - try: - ready(host, clock) - assert host.start().success - session = host.execution.session - unit = session.current_unit - session._retry_by_unit[(unit.task_key, unit.cycle, unit.direction)] = 1 - session.phase = Phase.PAUSED - host._unit_rows = [{'joint': 'rh_thumb_cmc_yaw', 'view': 'top', - 'task_name': unit.task_key, 'cycle': unit.cycle, 'direction': unit.direction, - 'attempt': 1, 'sample_phase': 'sweep', 'image_stamp_ns': i+1, - 'feedback_u8': i} for i in range(256)] - assert host.snapshot().calibration.acquisition.valid_samples == 0 - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_pnp.py b/src/linkerhand_calibration/test/test_pnp.py deleted file mode 100644 index e180b4b..0000000 --- a/src/linkerhand_calibration/test/test_pnp.py +++ /dev/null @@ -1,895 +0,0 @@ -from __future__ import annotations - -import cv2 -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.pnp import ( - SquareTagGroupPoseTracker, - SquareTagPose, - SquareTagPoseTracker, - rotation_distance_rad, - select_continuous_pose, - select_rigid_group_trajectory, - solve_square_tag_ippe, - square_object_points, -) - - -def _camera_matrix() -> np.ndarray: - return np.asarray( - [ - [650.0, 0.0, 640.0], - [0.0, 650.0, 360.0], - [0.0, 0.0, 1.0], - ] - ) - - -def _project( - rotation: Rotation, - translation_xyz_m: np.ndarray, - *, - tag_size_m: float = 0.01, -) -> np.ndarray: - rotation_vector, _ = cv2.Rodrigues(rotation.as_matrix()) - corners, _ = cv2.projectPoints( - square_object_points(tag_size_m), - rotation_vector, - translation_xyz_m, - _camera_matrix(), - np.zeros((4, 1)), - ) - return corners.reshape(4, 2) - - -def test_ippe_recovers_known_square_tag_pose() -> None: - expected_rotation = Rotation.from_euler( - "xyz", [10.0, -15.0, 25.0], degrees=True - ) - expected_translation = np.asarray([0.02, -0.01, 0.25]) - candidates = solve_square_tag_ippe( - _project(expected_rotation, expected_translation), - tag_size_m=0.01, - camera_matrix=_camera_matrix(), - ) - - assert len(candidates) == 2 - actual = min(candidates, key=lambda item: item.reprojection_error_px) - assert ( - rotation_distance_rad( - expected_rotation.as_quat(), - actual.quaternion_xyzw, - ) - < 1.0e-8 - ) - assert np.allclose(actual.translation_xyz_m, expected_translation) - assert actual.reprojection_error_px < 1.0e-8 - - -def test_temporal_selection_breaks_near_reprojection_tie() -> None: - previous = SquareTagPose( - quaternion_xyzw=(0.0, 0.0, 0.0, 1.0), - translation_xyz_m=(0.0, 0.0, 0.25), - reprojection_error_px=0.2, - ) - continuous = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("y", 2.0, degrees=True).as_quat() - ), - translation_xyz_m=(0.001, 0.0, 0.25), - reprojection_error_px=0.11, - ) - flipped = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("y", 55.0, degrees=True).as_quat() - ), - translation_xyz_m=(0.0, 0.0, 0.25), - reprojection_error_px=0.1, - ) - - selected, reason = select_continuous_pose( - [flipped, continuous], - previous=previous, - maximum_reprojection_error_px=1.5, - reprojection_tie_px=0.03, - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - maximum_tag_tilt_rad=np.deg2rad(75.0), - ) - - assert reason == "" - assert selected == continuous - - -def test_stateless_selector_requires_confirmation_for_large_branch_correction() -> None: - stale_mirror = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("y", 55.0, degrees=True).as_quat() - ), - translation_xyz_m=(0.0, 0.0, 0.25), - reprojection_error_px=0.25, - ) - true_pose = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("y", 2.0, degrees=True).as_quat() - ), - translation_xyz_m=(0.001, 0.0, 0.25), - reprojection_error_px=0.05, - ) - continued_mirror = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("y", 54.0, degrees=True).as_quat() - ), - translation_xyz_m=(0.0, 0.0, 0.25), - reprojection_error_px=0.24, - ) - - selected, reason = select_continuous_pose( - [continued_mirror, true_pose], - previous=stale_mirror, - maximum_reprojection_error_px=1.5, - reprojection_tie_px=0.03, - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - maximum_tag_tilt_rad=np.deg2rad(75.0), - ) - - assert reason == "pose_branch_unconfirmed" - assert selected is None - - -@pytest.mark.parametrize("tie", [-0.01, 1.5, 2.0, float("nan"), float("inf")]) -def test_invalid_branch_tolerance_is_rejected_before_tracking(tie) -> None: - parameters = dict( - maximum_reprojection_error_px=1.5, reprojection_tie_px=tie, - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, maximum_tag_tilt_rad=np.deg2rad(75.0), - ) - with pytest.raises(ValueError, match="reprojection_tie_px"): - SquareTagPoseTracker(**parameters, reset_after_seconds=5.0) - with pytest.raises(ValueError, match="reprojection_tie_px"): - select_continuous_pose([], previous=None, **parameters) - - -def test_tracker_recovers_after_timestamp_gap() -> None: - tracker = SquareTagPoseTracker( - maximum_reprojection_error_px=1.5, - maximum_pose_jump_rad=np.deg2rad(1.0), - maximum_translation_jump_m=0.04, - maximum_tag_tilt_rad=np.deg2rad(75.0), - reset_after_seconds=0.5, - ) - first_rotation = Rotation.from_euler("y", 0.0, degrees=True) - # Keep both IPPE solutions near-tied so this exercises gap recovery, - # rather than correction of a branch with clearly worse image fit. - second_rotation = Rotation.from_euler("y", 2.0, degrees=True) - for index in range(5): - first, first_reason = tracker.estimate( - "t0", _project(first_rotation, np.asarray([0.0, 0.0, 0.25])), - tag_size_m=0.01, camera_matrix=_camera_matrix(), - stamp_ns=1_000_000_000 + index * 30_000_000) - rejected, rejection_reason = tracker.estimate( - "t0", - _project(second_rotation, np.asarray([0.0, 0.0, 0.25])), - tag_size_m=0.01, - camera_matrix=_camera_matrix(), - stamp_ns=1_150_000_000, - ) - ambiguous, ambiguous_reason = tracker.estimate( - "t0", - _project(second_rotation, np.asarray([0.0, 0.0, 0.25])), - tag_size_m=0.01, - camera_matrix=_camera_matrix(), - stamp_ns=1_700_000_000, - ) - - assert first is not None - assert first_reason == "" - assert rejected is None - assert rejection_reason == "pose_jump" - assert ambiguous is None - assert ambiguous_reason == "pose_branch_unconfirmed" - # A real timestamp gap permits reacquisition, but cannot itself resolve - # ambiguous image evidence. A larger projected Tag supplies a distinct - # image-fit winner; independent frames must then confirm it again. - for index in range(5): - recovered, recovered_reason = tracker.estimate( - "t0", _project(second_rotation, np.asarray([0.0, 0.0, 0.25]), tag_size_m=0.05), - tag_size_m=0.05, camera_matrix=_camera_matrix(), - stamp_ns=1_800_000_000 + index * 30_000_000) - assert recovered is not None - assert recovered_reason == "" - - -def _pose( - rotation_deg: float, - x_m: float, - reprojection_error_px: float, -) -> SquareTagPose: - return SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler( - "y", rotation_deg, degrees=True - ).as_quat() - ), - translation_xyz_m=(x_m, 0.0, 0.25), - reprojection_error_px=reprojection_error_px, - ) - - -def test_group_tracker_receives_oblique_reprojection_valid_candidates() -> None: - per_tag = SquareTagPoseTracker( - maximum_reprojection_error_px=1.5, - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - maximum_tag_tilt_rad=np.deg2rad(75.0), - reset_after_seconds=5.0, - ) - oblique_rotation = Rotation.from_euler("y", 89.0, degrees=True) - - independent, reason = per_tag.estimate( - "child", - _project(oblique_rotation, np.asarray([0.03, 0.0, 0.25])), - tag_size_m=0.01, - camera_matrix=_camera_matrix(), - stamp_ns=1_000_000_000, - ) - - assert independent is None - assert reason == "no_pose_within_reprojection_or_tilt_limit" - assert per_tag.last_candidates_by_role["child"] - diagnostics = per_tag.last_candidate_diagnostics_by_role["child"] - assert diagnostics["reprojection_candidate_count"] == 2 - assert diagnostics["independent_tilt_candidate_count"] == 0 - assert diagnostics["minimum_candidate_tilt_deg"] > 75.0 - - group = SquareTagGroupPoseTracker( - roles=("base", "child"), - adjacent_pairs=(("base", "child"),), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - ) - selected, group_reason = group.select( - { - "base": (_pose(0.0, 0.0, 0.05),), - "child": per_tag.last_candidates_by_role["child"], - }, - stamp_ns=1_000_000_000, - ) - - assert group_reason == "" - assert selected is not None - assert "child" in selected - - -def test_group_tracker_exposes_roles_without_candidates() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("base", "parent", "child"), - adjacent_pairs=(("base", "parent"), ("parent", "child")), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - ) - - selected, reason = tracker.select( - { - "base": (_pose(0.0, 0.0, 0.05),), - "parent": (), - }, - stamp_ns=1_000_000_000, - ) - - assert selected is None - assert reason == "group_missing_pose_candidates" - assert tracker.last_missing_roles == ("parent", "child") - - -def test_group_tracker_uses_coupling_to_choose_branch_but_never_rejects_measurement() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("base", "mcp", "ip"), - adjacent_pairs=(("base", "mcp"), ("mcp", "ip")), - maximum_pose_jump_rad=np.deg2rad(360.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(1000.0), - relative_translation_scale_m=1.0, - reprojection_scale_px=0.1, - reprojection_weight=1.0, - reset_after_seconds=5.0, - coupled_rotation_pairs=( - ("base", "mcp", "mcp", "ip", 1.03), - ), - coupled_rotation_scale_rad=np.deg2rad(3.0), - maximum_coupled_rotation_residual_rad=np.deg2rad(7.5), - ) - baseline = { - "base": (_pose(0.0, 0.00, 0.05),), - "mcp": (_pose(0.0, 0.03, 0.05),), - "ip": (_pose(0.0, 0.06, 0.05),), - } - selected, reason = tracker.select( - baseline, - stamp_ns=1_000_000_000, - trajectory_command_u8=255, - trajectory_direction="decreasing", - ) - assert selected is not None - assert reason == "" - - driver = _pose(10.0, 0.03, 0.05) - measured_ip = _pose(20.3, 0.06, 0.20) - lower_reprojection_mirror = _pose(30.0, 0.06, 0.05) - selected, reason = tracker.select( - { - "base": (_pose(0.0, 0.00, 0.05),), - "mcp": (driver,), - "ip": (lower_reprojection_mirror, measured_ip), - }, - stamp_ns=1_033_000_000, - trajectory_command_u8=128, - trajectory_direction="decreasing", - ) - - assert reason == "" - assert selected is not None - assert selected["ip"] == measured_ip - - fallback, fallback_reason = tracker.select( - { - "base": (_pose(0.0, 0.00, 0.05),), - "mcp": (driver,), - "ip": (lower_reprojection_mirror,), - }, - stamp_ns=1_066_000_000, - trajectory_command_u8=128, - trajectory_direction="decreasing", - ) - assert fallback is not None - assert fallback_reason == "" - assert fallback["ip"] == lower_reprojection_mirror - - -def test_group_tracker_prevents_incompatible_t4_t5_branch_switch() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("t0", "t3", "t4", "t5"), - adjacent_pairs=( - ("t0", "t3"), - ("t3", "t4"), - ("t4", "t5"), - ), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - ) - first = { - "t0": (_pose(0.0, 0.00, 0.05),), - "t3": (_pose(5.0, 0.03, 0.05),), - "t4": (_pose(15.0, 0.06, 0.05),), - "t5": (_pose(25.0, 0.09, 0.05),), - } - selected_first, first_reason = tracker.select( - first, - stamp_ns=1_000_000_000, - ) - assert first_reason == "" - assert selected_first is not None - - # The per-tag minimum-error solutions move only a few degrees and can - # therefore fool independent trackers. Together they change T4->T5 by - # 8 deg; the slightly higher-error pair preserves the physical chain. - continuous_t4 = _pose(16.0, 0.061, 0.20) - continuous_t5 = _pose(26.0, 0.091, 0.20) - independent_best_t4 = _pose(19.0, 0.061, 0.05) - independent_best_t5 = _pose(21.0, 0.091, 0.05) - second = { - "t0": (_pose(0.2, 0.00, 0.05),), - "t3": (_pose(5.2, 0.03, 0.05),), - "t4": (independent_best_t4, continuous_t4), - "t5": (independent_best_t5, continuous_t5), - } - selected, reason = tracker.select( - second, - stamp_ns=1_033_000_000, - ) - - assert reason == "" - assert selected is not None - assert selected["t4"] == continuous_t4 - assert selected["t5"] == continuous_t5 - assert tracker.branch_correction_counts == {"t4": 1, "t5": 1} - - -def test_group_tracker_keeps_same_pair_across_sweep_turnaround() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("t4", "t5"), - adjacent_pairs=(("t4", "t5"),), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - ) - true_t4 = _pose(30.0, 0.06, 0.05) - true_t5 = _pose(65.0, 0.09, 0.05) - selected, _ = tracker.select( - {"t4": (true_t4,), "t5": (true_t5,)}, - stamp_ns=1_000_000_000, - ) - assert selected is not None - - return_t4 = _pose(29.5, 0.06, 0.20) - return_t5 = _pose(64.5, 0.09, 0.20) - mirror_t4 = _pose(33.0, 0.06, 0.04) - mirror_t5 = _pose(57.0, 0.09, 0.04) - selected, reason = tracker.select( - { - "t4": (mirror_t4, return_t4), - "t5": (mirror_t5, return_t5), - }, - stamp_ns=1_033_000_000, - ) - - assert reason == "" - assert selected == {"t4": return_t4, "t5": return_t5} - - -def test_group_tracker_uses_outbound_pose_at_same_command_on_return() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("parent", "child"), - adjacent_pairs=(("parent", "child"),), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - ) - parent = _pose(0.0, 0.00, 0.05) - for stamp, command, angle in ( - (1_000_000_000, 255, 20.0), - (1_033_000_000, 64, 45.0), - (1_066_000_000, 0, 50.0), - ): - selected, reason = tracker.select( - {"parent": (parent,), "child": (_pose(angle, 0.03, 0.05),)}, - stamp_ns=stamp, - trajectory_command_u8=command, - trajectory_direction="decreasing", - ) - assert reason == "" - assert selected is not None - - # The true return contains 3 deg of real hysteresis along the learned - # y-axis. The lower-error mirror candidate is temporally smoother but - # adds a 2 deg tilt outside that physical motion axis. - true_return = _pose(42.0, 0.03, 0.20) - smoother_mirror = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("xy", [2.0, 49.0], degrees=True).as_quat() - ), - translation_xyz_m=(0.03, 0.0, 0.25), - reprojection_error_px=0.01, - ) - selected, reason = tracker.select( - { - "parent": (parent,), - "child": (smoother_mirror, true_return), - }, - stamp_ns=1_099_000_000, - trajectory_command_u8=64, - trajectory_direction="increasing", - ) - - assert reason == "" - assert selected == {"parent": parent, "child": true_return} - - -def test_group_tracker_initializes_from_multiple_static_frames() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("parent", "child"), - adjacent_pairs=(("parent", "child"),), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - initialization_frames=8, - ) - parent = _pose(0.0, 0.00, 0.05) - true_child = _pose(20.0, 0.03, 0.10) - - for index in range(7): - false_child = _pose(5.0 * index, 0.03, 0.05) - selected, reason = tracker.select( - { - "parent": (parent,), - "child": (false_child, true_child), - }, - stamp_ns=1_000_000_000 + index * 33_000_000, - ) - assert selected is None - assert reason == f"group_initializing:{index + 1}/8" - - false_child = _pose(35.0, 0.03, 0.05) - selected, reason = tracker.select( - { - "parent": (parent,), - "child": (false_child, true_child), - }, - stamp_ns=1_231_000_000, - ) - - assert reason == "" - assert selected == {"parent": parent, "child": true_child} - assert ( - tracker.last_initialization_quality["initialization_search"] - == "static_reference" - ) - assert tracker.last_initialization_quality[ - "p95_pair_rotation_drift_rad" - ] < np.deg2rad(1.0) - - -def test_group_tracker_preserves_task_branch_anchor_across_cycles() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("parent", "child"), - adjacent_pairs=(("parent", "child"),), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - initialization_frames=8, - ) - parent = _pose(0.0, 0.00, 0.05) - anchored_child = _pose(20.0, 0.03, 0.20) - for index in range(8): - selected, reason = tracker.select( - {"parent": (parent,), "child": (anchored_child,)}, - stamp_ns=1_000_000_000 + index * 33_000_000, - trajectory_command_u8=255, - trajectory_direction="decreasing", - ) - assert reason == "" - assert selected == {"parent": parent, "child": anchored_child} - - tracker.reset(preserve_task_reference=True) - lower_error_mirror = _pose(5.0, 0.03, 0.01) - for index in range(8): - selected, reason = tracker.select( - { - "parent": (parent,), - "child": (lower_error_mirror, anchored_child), - }, - stamp_ns=2_000_000_000 + index * 33_000_000, - trajectory_command_u8=255, - trajectory_direction="decreasing", - ) - - assert reason == "" - assert selected == {"parent": parent, "child": anchored_child} - assert tracker.last_initialization_quality["task_reference_used"] == "true" - - tracker.reset() - for index in range(8): - selected, reason = tracker.select( - { - "parent": (parent,), - "child": (lower_error_mirror, anchored_child), - }, - stamp_ns=3_000_000_000 + index * 33_000_000, - trajectory_command_u8=255, - trajectory_direction="decreasing", - ) - assert reason == "" - assert selected == {"parent": parent, "child": lower_error_mirror} - - -def test_static_group_normal_prior_rejects_stable_ippe_mirror() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("mcp", "pip", "dip"), - adjacent_pairs=(("mcp", "pip"), ("pip", "dip")), - normal_alignment_pairs=(("mcp", "pip"), ("pip", "dip")), - normal_alignment_scale_rad=np.deg2rad(5.0), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - initialization_frames=8, - maximum_normal_alignment_rad=np.deg2rad(15.0), - ) - mcp = _pose(12.0, 0.00, 0.05) - correct_pip = _pose(14.0, 0.03, 0.10) - mirror_pip = _pose(50.0, 0.03, 0.05) - dip = _pose(15.0, 0.06, 0.05) - - for index in range(8): - selected, reason = tracker.select( - { - "mcp": (mcp,), - "pip": (mirror_pip, correct_pip), - "dip": (dip,), - }, - stamp_ns=1_000_000_000 + index * 33_000_000, - ) - - assert reason == "" - assert selected == { - "mcp": mcp, - "pip": correct_pip, - "dip": dip, - } - assert tracker.last_initialization_quality[ - "maximum_normal_alignment_rad" - ] < np.deg2rad(3.0) - - -def test_static_group_rejects_when_no_aligned_branch_exists() -> None: - tracker = SquareTagGroupPoseTracker( - roles=("base", "moving"), - adjacent_pairs=(("base", "moving"),), - normal_alignment_pairs=(("base", "moving"),), - normal_alignment_scale_rad=np.deg2rad(5.0), - maximum_normal_alignment_rad=np.deg2rad(15.0), - maximum_pose_jump_rad=np.deg2rad(35.0), - maximum_translation_jump_m=0.04, - relative_rotation_scale_rad=np.deg2rad(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=5.0, - initialization_frames=8, - ) - base = _pose(0.0, 0.00, 0.05) - mirror = _pose(38.0, 0.03, 0.05) - - for index in range(8): - selected, reason = tracker.select( - {"base": (base,), "moving": (mirror,)}, - stamp_ns=1_000_000_000 + index * 33_000_000, - ) - - assert selected is None - assert reason == "group_normal_alignment" - - -def test_whole_trajectory_recovers_rigid_group_from_mirror_drift() -> None: - roles = ("t3", "t4", "t5") - mount_rotations = { - "t3": Rotation.identity(), - "t4": Rotation.from_euler("z", 20.0, degrees=True), - "t5": Rotation.from_euler("z", -15.0, degrees=True), - } - mount_positions = { - "t3": np.asarray([0.0, 0.0, 0.0]), - "t4": np.asarray([0.025, 0.0, 0.0]), - "t5": np.asarray([0.05, 0.0, 0.0]), - } - false_factors = {"t3": 0.5, "t4": -0.5, "t5": 1.0} - frames = [] - true_frames = [] - for angle_deg in np.linspace(0.0, 45.0, 30): - group_rotation = Rotation.from_euler( - "y", angle_deg, degrees=True - ) - origin = np.asarray([0.0, 0.0, 0.3]) - candidates = {} - truths = {} - for role in roles: - true_rotation = group_rotation * mount_rotations[role] - true_translation = origin + group_rotation.apply( - mount_positions[role] - ) - true_pose = SquareTagPose( - quaternion_xyzw=tuple(true_rotation.as_quat()), - translation_xyz_m=tuple(true_translation), - reprojection_error_px=0.10, - ) - false_rotation = true_rotation * Rotation.from_euler( - "x", - false_factors[role] * angle_deg, - degrees=True, - ) - false_pose = SquareTagPose( - quaternion_xyzw=tuple(false_rotation.as_quat()), - translation_xyz_m=tuple( - true_translation - + np.asarray( - [ - 0.0, - false_factors[role] * angle_deg / 10000.0, - 0.0, - ] - ) - ), - reprojection_error_px=0.05, - ) - candidates[role] = (false_pose, true_pose) - truths[role] = true_pose - frames.append(candidates) - true_frames.append(truths) - - selected, quality = select_rigid_group_trajectory( - frames, - roles=roles, - fixed_pairs=(("t3", "t4"), ("t4", "t5")), - reprojection_scale_px=0.1, - rotation_scale_rad=np.deg2rad(5.0), - translation_scale_m=0.01, - ) - - for role in roles: - assert ( - rotation_distance_rad( - selected[-1][role].quaternion_xyzw, - true_frames[-1][role].quaternion_xyzw, - ) - < 1.0e-8 - ) - assert quality["maximum_pair_rotation_drift_rad"] < np.deg2rad(5.0) - assert quality["p95_pair_rotation_drift_rad"] < np.deg2rad(5.0) - - -def test_trajectory_quality_uses_robust_rigid_reference() -> None: - frames = [] - for index in range(30): - child_rotation = Rotation.identity() - if index == 0: - child_rotation = Rotation.from_euler( - "x", 10.0, degrees=True - ) - frames.append( - { - "parent": ( - SquareTagPose( - quaternion_xyzw=tuple( - Rotation.identity().as_quat() - ), - translation_xyz_m=(0.0, 0.0, 0.3), - reprojection_error_px=0.1, - ), - ), - "child": ( - SquareTagPose( - quaternion_xyzw=tuple(child_rotation.as_quat()), - translation_xyz_m=(0.03, 0.0, 0.3), - reprojection_error_px=0.1, - ), - ), - } - ) - - _, quality = select_rigid_group_trajectory( - frames, - roles=("parent", "child"), - fixed_pairs=(("parent", "child"),), - reprojection_scale_px=0.1, - rotation_scale_rad=np.deg2rad(5.0), - translation_scale_m=0.01, - ) - - assert quality["maximum_pair_rotation_drift_rad"] == pytest.approx( - np.deg2rad(10.0) - ) - assert quality["p95_pair_rotation_drift_rad"] == pytest.approx(0.0) - assert quality["median_pair_rotation_drift_rad"] == pytest.approx(0.0) - - -def test_distance_geometry_ignores_planar_orientation_drift() -> None: - frames = [] - for angle_deg in np.linspace(0.0, 45.0, 30): - group = Rotation.from_euler("y", angle_deg, degrees=True) - origin = np.asarray([0.0, 0.0, 0.3]) - parent_position = origin - child_position = origin + group.apply([0.04, 0.0, 0.0]) - # The centres form a perfect rigid pair, while the planar-PnP parent - # orientation contains a pose-dependent error. - parent_rotation = group * Rotation.from_euler( - "z", 0.25 * angle_deg, degrees=True - ) - frames.append( - { - "parent": ( - SquareTagPose( - quaternion_xyzw=tuple(parent_rotation.as_quat()), - translation_xyz_m=tuple(parent_position), - reprojection_error_px=0.1, - ), - ), - "child": ( - SquareTagPose( - quaternion_xyzw=tuple(group.as_quat()), - translation_xyz_m=tuple(child_position), - reprojection_error_px=0.1, - ), - ), - } - ) - - _, quality = select_rigid_group_trajectory( - frames, - roles=("parent", "child"), - fixed_pairs=(("parent", "child"),), - reprojection_scale_px=0.1, - rotation_scale_rad=np.deg2rad(5.0), - translation_scale_m=0.01, - pair_geometry="distance", - ) - - assert quality["pair_geometry"] == "distance" - assert quality["maximum_pair_distance_drift_m"] < 1.0e-10 - assert quality["p95_pair_distance_drift_m"] < 1.0e-10 - assert quality["p95_pair_translation_drift_m"] > 0.001 - - -def test_distance_geometry_rejects_pose_branch_with_changing_length() -> None: - frames = [] - true_children = [] - for index in range(30): - parent = SquareTagPose( - quaternion_xyzw=tuple(Rotation.identity().as_quat()), - translation_xyz_m=(0.0, 0.0, 0.3), - reprojection_error_px=0.1, - ) - true_child = SquareTagPose( - quaternion_xyzw=tuple(Rotation.identity().as_quat()), - translation_xyz_m=(0.04, 0.0, 0.3), - reprojection_error_px=0.1, - ) - false_child = SquareTagPose( - quaternion_xyzw=tuple( - Rotation.from_euler("x", 10.0, degrees=True).as_quat() - ), - translation_xyz_m=( - 0.04, - 0.020 * np.sin(np.pi * index / 29.0), - 0.3, - ), - reprojection_error_px=0.05, - ) - frames.append( - { - "parent": (parent,), - "child": (false_child, true_child), - } - ) - true_children.append(true_child) - - selected, quality = select_rigid_group_trajectory( - frames, - roles=("parent", "child"), - fixed_pairs=(("parent", "child"),), - reprojection_scale_px=0.1, - rotation_scale_rad=np.deg2rad(5.0), - translation_scale_m=0.002, - pair_geometry="distance", - ) - - assert selected[len(selected) // 2]["child"] == ( - true_children[len(true_children) // 2] - ) - assert quality["p95_pair_distance_drift_m"] < 1.0e-6 diff --git a/src/linkerhand_calibration/test/test_pnp_branch_confirmation.py b/src/linkerhand_calibration/test/test_pnp_branch_confirmation.py deleted file mode 100644 index 09bdc82..0000000 --- a/src/linkerhand_calibration/test/test_pnp_branch_confirmation.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Distinct image evidence must authorize planar branch changes before capture. - -The small real fixture records six O6 discontinuities, not physical ground -truth. Smooth or repeatedly preferred IPPE branches are not accuracy evidence. -""" - -from __future__ import annotations - -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 import tracking -from linkerhand_calibration.pnp import SquareTagPose, SquareTagPoseTracker, solve_square_tag_ippe - - -REAL = json.loads((Path(__file__).parent / "fixtures" / - "o6_thumb_20260914_branch_events.json").read_text()) -START_NS = 1_000_000_000 -STEP_NS = 33_000_000 - - -def _distance(first, second): - return float(np.degrees((Rotation.from_quat(first).inv() * - Rotation.from_quat(second)).magnitude())) - - -def _pose(angle, error): - return SquareTagPose(tuple(Rotation.from_euler("y", angle, degrees=True).as_quat()), - (0.0, 0.0, 0.5), float(error)) - - -@pytest.fixture -def source(monkeypatch): - """Control candidate evidence while exercising the real stateful tracker.""" - class Source: - candidates = [_pose(0, .05), _pose(60, .20)] - - def sample(self, tracker, stamp_ns, *, role="moving"): - return tracker.estimate(role, [(0, 0), (1, 0), (1, 1), (0, 1)], - tag_size_m=.016, camera_matrix=np.eye(3), stamp_ns=stamp_ns) - - instance = Source() - monkeypatch.setattr(tracking, "solve_square_tag_ippe", - lambda *args, **kwargs: list(instance.candidates)) - return instance - - -def _confirm(source, tracker, *, start=START_NS, role="moving"): - outcomes = [source.sample(tracker, start + i * STEP_NS, role=role) for i in range(5)] - assert all(pose is None and reason for pose, reason in outcomes[:-1]) - pose, reason = outcomes[-1] - assert pose is not None and reason == "" - return pose, start + 4 * STEP_NS - - -def _freeze(tracker, role="moving"): - revision = tracker.last_candidate_diagnostics_by_role[role]["branch_revision"] - assert tracker.freeze_roles({role: revision}) is True - return revision - - -def test_separated_initial_candidates_need_five_unique_images_and_elapsed_time(source): - tracker = SquareTagPoseTracker() - for i in range(8): - pose, reason = source.sample(tracker, START_NS + i * 1_000_000) - assert pose is None and reason - pose, reason = source.sample(tracker, START_NS + 100_000_000) - assert pose is not None and reason == "" - assert _distance(pose.quaternion_xyzw, _pose(0, .05).quaternion_xyzw) < 1e-9 - detail = tracker.last_candidate_diagnostics_by_role["moving"] - assert detail["observation_stamp_ns"] == START_NS + 100_000_000 - - -def test_duplicate_images_do_not_advance_confirmation(source): - tracker = SquareTagPoseTracker() - for _ in range(8): - pose, reason = source.sample(tracker, START_NS) - assert pose is None and reason - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_confirmation_count"] == 1 - for i in range(1, 4): - pose, reason = source.sample(tracker, START_NS + i * STEP_NS) - assert pose is None and reason - pose, reason = source.sample(tracker, START_NS + 4 * STEP_NS) - assert pose is not None and reason == "" - - -def test_near_equal_image_fit_never_accumulates_false_initial_confidence(source): - tracker = SquareTagPoseTracker() - source.candidates = [_pose(0, .05), _pose(60, .079)] - for i in range(20): - pose, reason = source.sample(tracker, START_NS + i * STEP_NS) - assert pose is None and reason - assert tracker.reprojection_tie_px == .03 - assert tracker.freeze_roles({"moving": 0}) is False - - -def test_confirmation_does_not_pool_alternating_branches_or_other_roles(source): - tracker = SquareTagPoseTracker() - for i in range(16): - source.candidates = ([_pose(0, .05), _pose(60, .20)] if (i // 2) % 2 == 0 - else [_pose(0, .20), _pose(60, .05)]) - pose, reason = source.sample(tracker, START_NS + i * STEP_NS) - assert pose is None and reason - source.candidates = [_pose(0, .05), _pose(60, .20)] - pose, reason = source.sample(tracker, START_NS + 16 * STEP_NS, role="another") - assert pose is None and reason - assert tracker.last_candidate_diagnostics_by_role["another"]["branch_confirmation_count"] == 1 - - -@pytest.mark.parametrize("candidates", [ - [_pose(0, .05)], [_pose(0, .05), _pose(.5, .06)], - [_pose(0, .05), _pose(60, 1.51)], -]) -def test_single_or_equivalent_solution_confirms_without_an_image_error_margin(source, candidates): - source.candidates = candidates - pose, _ = _confirm(source, SquareTagPoseTracker()) - assert pose is not None - - -def test_unfrozen_stale_branch_is_revised_without_emitting_the_switch_frame(source): - tracker = SquareTagPoseTracker() - old, stamp = _confirm(source, tracker) - old_revision = tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] - source.candidates = [_pose(0, .20), _pose(60, .05)] - for i in range(1, 5): - pose, reason = source.sample(tracker, stamp + i * STEP_NS) - assert pose is None and reason - pose, reason = source.sample(tracker, stamp + 5 * STEP_NS) - assert pose is None and reason == "pose_branch_corrected" - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] == old_revision + 1 - assert tracker.branch_correction_counts["moving"] == 1 - pose, reason = source.sample(tracker, stamp + 6 * STEP_NS) - assert pose is not None and reason == "" - assert _distance(old.quaternion_xyzw, pose.quaternion_xyzw) == pytest.approx(60) - assert tracker.freeze_roles({"moving": old_revision}) is False - assert tracker.freeze_roles({"moving": old_revision + 1}) is True - - -def test_freezing_multiple_roles_is_atomic_and_requires_confirmed_revisions(source): - tracker = SquareTagPoseTracker() - _confirm(source, tracker, role="first") - _confirm(source, tracker, role="second") - revisions = {role: tracker.last_candidate_diagnostics_by_role[role]["branch_revision"] - for role in ("first", "second")} - assert tracker.freeze_roles({**revisions, "second": revisions["second"] + 1}) is False - for role in revisions: - source.sample(tracker, START_NS + 5 * STEP_NS, role=role) - assert tracker.last_candidate_diagnostics_by_role[role]["branch_frozen"] is False - assert tracker.freeze_roles(revisions) is True - - -def test_short_conflict_discards_images_then_original_evidence_can_resume(source): - tracker = SquareTagPoseTracker() - old, stamp = _confirm(source, tracker) - revision = _freeze(tracker) - source.candidates = [_pose(0, .20), _pose(60, .05)] - for i in (1, 2): - pose, reason = source.sample(tracker, stamp + i * STEP_NS) - assert pose is None and reason - source.candidates = [_pose(.2, .05), _pose(60, .20)] - pose, reason = source.sample(tracker, stamp + 3 * STEP_NS) - assert pose is not None and reason == "" - assert _distance(old.quaternion_xyzw, pose.quaternion_xyzw) == pytest.approx(.2) - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] == revision - assert tracker.branch_correction_counts.get("moving", 0) == 0 - - -def test_frozen_tracker_keeps_original_continuity_inside_the_unchanged_point03_tie(source): - tracker = SquareTagPoseTracker() - original, stamp = _confirm(source, tracker) - revision = _freeze(tracker) - source.candidates = [_pose(.2, .079), _pose(60, .05)] - pose, reason = source.sample(tracker, stamp + STEP_NS) - assert pose is not None and reason == "" - assert _distance(original.quaternion_xyzw, pose.quaternion_xyzw) == pytest.approx(.2) - assert tracker.reprojection_tie_px == .03 - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] == revision - - -def test_observation_snapshot_matches_only_its_own_image(source): - tracker = SquareTagPoseTracker() - _, stamp = _confirm(source, tracker) - candidates, detail, corrections = tracker.observation_snapshot("moving", stamp) - assert len(candidates) == 2 - assert detail["observation_stamp_ns"] == stamp - assert corrections == 0 - source.sample(tracker, stamp + STEP_NS) - assert tracker.observation_snapshot("moving", stamp) is None - assert tracker.observation_snapshot("unknown", stamp + STEP_NS) is None - assert tracker.observation_snapshot("moving", stamp + STEP_NS) is not None - - -def test_persistent_ambiguity_keeps_the_frozen_path_and_can_recover_after_five_seconds(source): - tracker = SquareTagPoseTracker() - _, stamp = _confirm(source, tracker) - revision = _freeze(tracker) - source.candidates = [_pose(0, .20), _pose(60, .05)] - for i in range(1, 190): - pose, reason = source.sample(tracker, stamp + i * STEP_NS) - assert pose is None and reason == "pose_branch_ambiguous" - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] == revision - source.candidates = [_pose(0, .05), _pose(60, .20)] - pose, reason = source.sample(tracker, stamp + 190 * STEP_NS) - assert pose is not None and reason == "" - assert not tracker._branches.states['moving'].conflict - assert tracker.branch_correction_counts.get('moving', 0) == 0 - - -@pytest.mark.parametrize("ambiguous_frames", [0, 12]) -def test_frozen_tracker_still_stops_when_no_candidate_continues_the_original_path(source, ambiguous_frames): - tracker = SquareTagPoseTracker() - _, stamp = _confirm(source, tracker) - revision = _freeze(tracker) - source.candidates = [_pose(0, .20), _pose(60, .05)] - for index in range(1, ambiguous_frames + 1): - assert source.sample(tracker, stamp + index*STEP_NS) == (None, "pose_branch_ambiguous") - stamp += ambiguous_frames * STEP_NS - source.candidates = [_pose(60, .05), _pose(70, .2)] - for index in range(1, 7): - pose, reason = source.sample(tracker, stamp + index*STEP_NS) - assert pose is None - assert reason == ('pose_branch_unconfirmed' if index < 5 else 'pose_branch_conflict') - source.candidates = [_pose(0, .05), _pose(60, .2)] - assert source.sample(tracker, stamp + 7*STEP_NS) == (None, 'pose_branch_conflict') - assert tracker._branches.states['moving'].revision == revision - - -def test_unfrozen_actual_gap_allows_new_confirmation(source): - tracker = SquareTagPoseTracker() - _, stamp = _confirm(source, tracker) - source.candidates = [_pose(0, .20), _pose(60, .05)] - pose, _ = _confirm(source, tracker, start=stamp + 6_000_000_000) - assert _distance(pose.quaternion_xyzw, _pose(60, .05).quaternion_xyzw) < 1e-9 - - -def test_frozen_actual_gap_requires_original_pose_before_reconfirmation(source): - tracker = SquareTagPoseTracker() - old, stamp = _confirm(source, tracker) - revision = _freeze(tracker) - source.candidates = [_pose(2, .05), _pose(60, .20)] - start = stamp + 6_000_000_000 - for i in range(6): - pose, reason = source.sample(tracker, start + i * STEP_NS) - assert pose is None and reason == "pose_branch_recovery_required" - source.candidates = [_pose(.5, .05), _pose(60, .20)] - pose, _ = _confirm(source, tracker, start=start + 6 * STEP_NS) - assert _distance(old.quaternion_xyzw, pose.quaternion_xyzw) == pytest.approx(.5) - assert tracker.last_candidate_diagnostics_by_role["moving"]["branch_revision"] == revision - - -def _real_sample(tracker, role, frame): - return tracker.estimate(role, frame["corners_xy"], tag_size_m=REAL["tag_size_m"], - camera_matrix=REAL["camera_matrix"], stamp_ns=frame["stamp_ns"]) - - -@pytest.mark.parametrize("sequence", REAL["zero_sequences"], ids=lambda s: f"real_ID{s['tag_id']}") -def test_real_stationary_zero_frames_confirm_consistent_evidence_without_ground_truth(sequence): - tracker = SquareTagPoseTracker() - role = sequence["role"] - output = [_real_sample(tracker, role, frame) for frame in sequence["frames"]] - assert all(pose is None and reason for pose, reason in output[:4]) - assert all(pose is not None and reason == "" for pose, reason in output[4:]) - _freeze(tracker, role) - # Independent IPPE replay shows the recorded preference exceeds the existing - # 0.03 px tie tolerance; this is explicitly not a true-pose assertion. - for frame in sequence["frames"]: - candidates = solve_square_tag_ippe(frame["corners_xy"], - tag_size_m=REAL["tag_size_m"], camera_matrix=REAL["camera_matrix"]) - assert len(candidates) == 2 - errors = sorted(p.reprojection_error_px for p in candidates) - assert errors[1] - errors[0] > .03 - - -@pytest.mark.parametrize("event", REAL["jump_events"], - ids=lambda e: f"real_ID{e['tag_id']}_{e['event_stamp_ns']}") -def test_real_six_discontinuities_never_enter_a_frozen_observation_stream(event): - tracker = SquareTagPoseTracker() - role = event["role"] - warmup = event["warmup_frames"] - assert len({frame["stamp_ns"] for frame in warmup}) >= 5 - assert warmup[-1]["stamp_ns"] - warmup[0]["stamp_ns"] >= 100_000_000 - outputs = [_real_sample(tracker, role, frame) for frame in warmup] - previous, reason = outputs[-1] - assert previous is not None and reason == "" - revision = _freeze(tracker, role) - for frame in event["frames"]: - selected, reason = _real_sample(tracker, role, frame) - if selected is None: - assert reason - continue - assert reason == "" - assert _distance(previous.quaternion_xyzw, selected.quaternion_xyzw) <= 35 - previous = selected - detail = tracker.last_candidate_diagnostics_by_role[role] - assert detail["branch_revision"] == revision - assert detail["branch_frozen"] is True - assert tracker.branch_correction_counts.get(role, 0) == 0 - # The source really contains the claimed discontinuity; the regression - # cannot become vacuous by replacing the fixture with benign corners. - frames = event["frames"] - index = next(i for i, f in enumerate(frames) if f["stamp_ns"] == event["event_stamp_ns"]) - jump = _distance(frames[index - 1]["recorded_selected_quaternion_xyzw"], - frames[index]["recorded_selected_quaternion_xyzw"]) - assert jump == pytest.approx(event["recorded_jump_deg"], abs=1e-9) - assert jump > 35 diff --git a/src/linkerhand_calibration/test/test_pnp_metadata_concurrency.py b/src/linkerhand_calibration/test/test_pnp_metadata_concurrency.py deleted file mode 100644 index dec45d9..0000000 --- a/src/linkerhand_calibration/test/test_pnp_metadata_concurrency.py +++ /dev/null @@ -1,93 +0,0 @@ -"""PnP computation must not block freezing or commit across reset/image versions.""" - -from concurrent.futures import ThreadPoolExecutor -from threading import Event - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.tag_pose import tracking -from linkerhand_calibration.core.geometry.pnp import SquareTagPose, SquareTagPoseTracker - - -def pose(angle, error): - return SquareTagPose(tuple(Rotation.from_euler("y", angle, degrees=True).as_quat()), - (0., 0., .5), error) - - -def sample(tracker, stamp): - return tracker.estimate("tag", np.zeros((4, 2)), tag_size_m=.016, - camera_matrix=np.eye(3), stamp_ns=stamp) - - -def initialized(monkeypatch): - tracker = SquareTagPoseTracker() - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, **k: [pose(0, .05), pose(60, .2)]) - for i in range(5): - selected, _ = sample(tracker, 1_000_000_000 + i * 30_000_000) - assert selected is not None - return tracker - - -def test_freeze_during_inflight_pnp_applies_before_branch_selection(monkeypatch): - tracker = initialized(monkeypatch) - entered, release = Event(), Event() - - def solve(*args, **kwargs): - entered.set() - assert release.wait(5) - return [pose(0, .2), pose(60, .05)] - - monkeypatch.setattr(tracking, "solve_square_tag_ippe", solve) - with ThreadPoolExecutor(max_workers=2) as pool: - pending = pool.submit(sample, tracker, 1_150_000_000) - try: - assert entered.wait(5) - # This completes while the expensive solver is still blocked. - assert pool.submit(tracker.freeze_roles, {"tag": 1}).result(timeout=2) - finally: - release.set() - selected, reason = pending.result(timeout=2) - assert selected is None and reason == "pose_branch_ambiguous" - for i in range(1, 5): - selected, reason = sample(tracker, 1_150_000_000 + i * 30_000_000) - assert selected is None and reason == "pose_branch_ambiguous" - assert tracker.last_candidate_diagnostics_by_role["tag"]["branch_revision"] == 1 - - -@pytest.mark.parametrize("operation", ["reset", "newer_image"]) -def test_late_pnp_cannot_overwrite_newer_metadata_or_repopulate_reset(monkeypatch, operation): - tracker = initialized(monkeypatch) - entered, release = Event(), Event() - calls = 0 - - def solve(*args, **kwargs): - nonlocal calls - calls += 1 - if calls == 1: - entered.set() - assert release.wait(5) - return [pose(60, .05), pose(0, .2)] - return [pose(.1, .05), pose(60, .2)] - - monkeypatch.setattr(tracking, "solve_square_tag_ippe", solve) - with ThreadPoolExecutor(max_workers=1) as pool: - pending = pool.submit(sample, tracker, 1_150_000_000) - try: - assert entered.wait(5) - if operation == "reset": - tracker.reset() - else: - assert sample(tracker, 1_180_000_000)[0] is not None - finally: - release.set() - selected, reason = pending.result(timeout=2) - assert selected is None - if operation == "reset": - assert reason == "pose_observation_superseded" - assert not tracker.last_candidate_diagnostics_by_role - else: - assert reason == "pose_observation_not_new" - assert tracker.observation_snapshot("tag", 1_150_000_000) is None - assert tracker.observation_snapshot("tag", 1_180_000_000)[1]["branch_status"] == "tracking" diff --git a/src/linkerhand_calibration/test/test_pnp_parameter_wiring.py b/src/linkerhand_calibration/test/test_pnp_parameter_wiring.py deleted file mode 100644 index 4459931..0000000 --- a/src/linkerhand_calibration/test/test_pnp_parameter_wiring.py +++ /dev/null @@ -1,50 +0,0 @@ -"""ROS and offline capture share typed tracking defaults and parameter units.""" - -from dataclasses import asdict -import math -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.core.geometry.pnp import DEFAULT_POSE_TRACKING_PARAMETERS -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime import parameters as runtime_parameters -from linkerhand_calibration.runtime.ros.parameters import parameter_defaults, tracking_parameters - - -@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) -def test_every_profile_uses_same_shared_confirmation_defaults(layout): - values = parameter_defaults(load_bundled_hand_profile(layout)) - resolved = tracking_parameters(values.__getitem__) - assert resolved == DEFAULT_POSE_TRACKING_PARAMETERS - assert values["pnp_branch_confirmation_frames"] == 5 - assert values["pnp_branch_confirmation_minimum_seconds"] == .1 - assert values["pnp_branch_equivalence_deg"] == 1. - assert resolved.reprojection_tie_px == .03 - assert resolved.maximum_pose_jump_rad == math.radians(35) - assert resolved.maximum_tag_tilt_rad == math.radians(75) - assert resolved.maximum_reprojection_error_px == 1.5 - - -def test_ros_converts_branch_angle_and_preserves_custom_confirmation_values(): - values = parameter_defaults(load_bundled_hand_profile("o6_right_8")) - values.update(pnp_branch_confirmation_frames=7, - pnp_branch_confirmation_minimum_seconds=.25, pnp_branch_equivalence_deg=.5) - resolved = tracking_parameters(values.__getitem__) - assert resolved.branch_confirmation_frames == 7 - assert resolved.branch_confirmation_minimum_seconds == .25 - assert resolved.branch_equivalence_rad == math.radians(.5) - - -def test_runtime_passes_all_tracking_fields_to_each_independent_view(monkeypatch): - values = parameter_defaults(load_bundled_hand_profile("o6_right_8")) - values.update(pnp_branch_confirmation_frames=8, - pnp_branch_confirmation_minimum_seconds=.2, pnp_branch_equivalence_deg=.8) - tracking = tracking_parameters(values.__getitem__) - monkeypatch.setattr(runtime_parameters, "SquareTagPoseTracker", - lambda **kwargs: SimpleNamespace(parameters=kwargs)) - trackers = runtime_parameters.RuntimeParameters.new_trackers( - SimpleNamespace(tracking=tracking), ("front", "side", "top")) - assert set(trackers) == {"front", "side", "top"} - assert len({id(value) for value in trackers.values()}) == 3 - assert all(value.parameters == asdict(tracking) for value in trackers.values()) diff --git a/src/linkerhand_calibration/test/test_pnp_runtime_policy.py b/src/linkerhand_calibration/test/test_pnp_runtime_policy.py deleted file mode 100644 index d065bee..0000000 --- a/src/linkerhand_calibration/test/test_pnp_runtime_policy.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Exercise branch selection with shipped parameters and capture defaults.""" - -from pathlib import Path -from dataclasses import asdict - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation -import yaml - -from linkerhand_calibration.core.geometry import pnp -from linkerhand_calibration.core.geometry.tag_pose import tracking -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.capture import ObservationCapture -from linkerhand_calibration.runtime.ros.parameters import parameter_defaults, tracking_parameters - - -PACKAGE = Path(__file__).resolve().parents[1] -LAYOUTS = ("g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16") - - -def _pose(angle, error): - return pnp.SquareTagPose( - tuple(Rotation.from_euler("y", angle, degrees=True).as_quat()), - (0.0, 0.0, 0.25), error, - ) - - -def _check_branch_selection(tracker, monkeypatch): - stale = _pose(55, 0.25) - accurate = _pose(2, 0.05) - continued = _pose(3, 0.11) - frames = iter([ - *[(stale,)] * 5, - *[(_pose(54, 0.24), accurate)] * 5, - # A real near-tie still favors continuous motion over a mirror flip. - (_pose(55, 0.10), continued), - ]) - monkeypatch.setattr(tracking, "solve_square_tag_ippe", lambda *a, **kw: next(frames)) - for index in range(11): - selected, reason = tracker.estimate( - "moving", np.zeros((4, 2)), tag_size_m=0.016, - camera_matrix=np.eye(3), stamp_ns=1_000_000_000 + index * 30_000_000, - ) - if index in (4, 10): - assert reason == "" - assert selected == (stale if index == 4 else continued) - else: - assert selected is None - assert reason == ("pose_branch_corrected" if index == 9 else "pose_branch_unconfirmed") - assert tracker.branch_correction_counts == {"moving": 1} - - -@pytest.mark.parametrize("layout,filename", [ - ("g20_right_19", "three_camera_calibration.yaml"), - ("l6_right_8", "l6_three_camera_calibration.yaml"), - ("o6_right_8", "o6_three_camera_calibration.yaml"), - ("o12_right_16", "o12_three_camera_calibration.yaml"), - ("g20_right_19", "calibration.yaml"), -]) -def test_shipped_ros_parameters_release_stale_branch(layout, filename, monkeypatch): - profile = load_bundled_hand_profile(layout) - config = yaml.safe_load((PACKAGE / "config" / filename).read_text()) - params = dict(next(iter(config.values()))["ros__parameters"]) - params = {**parameter_defaults(profile), **params} - for _view in profile.vision.view_names: - tracker = pnp.SquareTagPoseTracker(**asdict(tracking_parameters(params.__getitem__))) - _check_branch_selection(tracker, monkeypatch) - - -@pytest.mark.parametrize("layout", LAYOUTS) -def test_capture_defaults_release_stale_branch(layout, monkeypatch): - capture = ObservationCapture(load_bundled_hand_profile(layout), - reference_lock=None, extrinsics=None) - for tracker in capture.trackers.values(): - _check_branch_selection(tracker, monkeypatch) diff --git a/src/linkerhand_calibration/test/test_pose_capture_evidence.py b/src/linkerhand_calibration/test/test_pose_capture_evidence.py deleted file mode 100644 index ae965d3..0000000 --- a/src/linkerhand_calibration/test/test_pose_capture_evidence.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Capture diagnostics preserve actual image evidence without changing poses.""" - -from dataclasses import replace -import json - -import numpy as np -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.domain.reference import build_joint_zero_reference -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.capture import CaptureTiming - -from test_observation_capture import frame, make_capture - - -def _lock(profile, lock, capture): - lock.begin_session() - lock.start_locking() - for stamp in range(1, 15): - capture.consume(frame(profile, stamp)) - - -def _sweep(profile): - return MotionCommand("sweep", tuple(profile.command.baseline_values), 1, - "thumb_pitch_dip_front", 0, 0, "decreasing") - - -def test_evidence_contains_this_images_corners_sizes_candidates_and_selected_pose(): - profile, _, capture = make_capture() - for stamp in range(1, 6): - capture.consume(frame(profile, stamp)) - observation = frame(profile, 20) - rows, movement = capture.consume(observation, _sweep(profile)) - assert movement is None and len(rows) == 2 - assert {row["kind"] for row in rows} == {"joint_sample"} - row = next(row for row in rows if row["joint"] == "rh_thumb_cmc_pitch") - evidence = row["pnp_observation_evidence"] - sizes = {tag.role: tag.size_m for tag in profile.vision.views[0].tags} - for item in evidence.values(): - role = item["tag_role"] - assert item["pose_source"] == "current_image" - assert item["corners_xy"] == observation.corners[role].tolist() - assert item["tag_size_m"] == sizes[role] - candidates = capture.trackers["front"].last_candidates_by_role[role] - assert len(item["reprojection_valid_candidates"]) == len(candidates) > 0 - for recorded, actual in zip(item["reprojection_valid_candidates"], candidates): - assert recorded["quaternion_xyzw"] == list(actual.quaternion_xyzw) - assert recorded["translation_xyz_m"] == list(actual.translation_xyz_m) - assert recorded["reprojection_error_px"] == actual.reprojection_error_px - assert item["candidate_diagnostics"] == capture.trackers["front"].last_candidate_diagnostics_by_role[role] - # The image fixture places both Tags at the same orientation, with a - # known translation. Adding diagnostics must retain the actual solve. - parent = evidence["parent"]["selected_pose"] - child = evidence["child"]["selected_pose"] - rp = Rotation.from_quat(parent["quaternion_xyzw"]) - rc = Rotation.from_quat(child["quaternion_xyzw"]) - np.testing.assert_allclose((rp.inv()*rc).as_quat(), row["relative_quaternion_xyzw"], atol=1e-12) - np.testing.assert_allclose(parent["translation_xyz_m"], [0, 0, .45], atol=1e-8) - np.testing.assert_allclose(child["translation_xyz_m"], [.05, .03, .45], atol=1e-8) - frozen = json.dumps(evidence, sort_keys=True) - observation.corners["thumb_pitch"][:] = 0 - capture.trackers["front"].last_candidate_diagnostics_by_role["thumb_pitch"]["solved_candidate_count"] = 999 - capture.consume(frame(profile, 21, shift=1), _sweep(profile)) - assert json.dumps(evidence, sort_keys=True) == frozen - - -def test_zero_reference_ignores_additional_diagnostics_and_retains_original_pose(): - profile, lock, capture = make_capture() - _lock(profile, lock, capture) - name = "rh_thumb_cmc_pitch" - spec = profile.motion.joint_zero_references[name] - motion = MotionCommand("joint_zero", spec.command, 1, task_key=spec.task_key, - command_index=0, cycle=0, zero_joints=(name,)) - rows = [] - for stamp in range(20, 30): - samples, movement = capture.consume(frame(profile, stamp, omit=("thumb_dip",)), motion) - assert movement is None - rows.extend(samples) - assert len(rows) == 10 and all(row["kind"] == "joint_zero_sample" for row in rows) - assert all(row["pnp_observation_evidence"]["child"]["tag_role"] == "thumb_pitch" for row in rows) - legacy = [{key: value for key, value in row.items() if key != "pnp_observation_evidence"} for row in rows] - assert build_joint_zero_reference(profile, name, rows, session_epoch=1, motion_version=2) == ( - build_joint_zero_reference(profile, name, legacy, session_epoch=1, motion_version=2)) - - -def test_missing_fixed_tag_uses_frozen_pose_but_never_copies_earlier_image_candidates(): - profile, lock, capture = make_capture() - _lock(profile, lock, capture) - assert capture.trackers["front"].last_candidates_by_role["front_base"] - rows, movement = capture.consume(frame(profile, 20, omit=("front_base",)), _sweep(profile)) - assert movement is None and len(rows) == 2 - row = next(row for row in rows if row["joint"] == "rh_thumb_cmc_pitch") - parent = row["pnp_observation_evidence"]["parent"] - assert parent["pose_source"] == "cached_fixed_reference" - assert parent["corners_xy"] is None - assert parent["reprojection_valid_candidates"] == [] - assert parent["candidate_diagnostics"] == {} - assert parent["selected_pose"]["quaternion_xyzw"] == list(capture.locked_poses["front"].quaternion_xyzw) - assert "image_relative_xy_px" not in row - assert row["pnp_observation_evidence"]["child"]["pose_source"] == "current_image" - - -def test_rejected_current_fixed_tag_corners_are_distinct_from_cached_pose(): - profile, lock, capture = make_capture() - _lock(profile, lock, capture) - observation = frame(profile, 20) - observation = replace(observation, corners={**observation.corners, "front_base": np.zeros((4, 2))}) - rows, movement = capture.consume(observation, _sweep(profile)) - assert movement is None - assert not any(row.get("joint") == "rh_thumb_cmc_pitch" for row in rows) - - -def test_unmatched_diagnostic_frame_preserves_timing_without_claiming_zero_skew(): - profile, lock, capture = make_capture() - _lock(profile, lock, capture) - observation = replace(frame(profile, 20), feedback=None, - timing=CaptureTiming(30, 35, 10, 18)) - rows, movement = capture.consume(observation, _sweep(profile), diagnostic=True) - assert movement is None - row = next(row for row in rows if row["kind"] == "diagnostic_observation_frame") - assert row["feedback_vector"] is None - assert row["state_image_sync_error_ms"] is None - assert row["timing"] == {"received_stamp_ns": 30, "history_snapshot_stamp_ns": 35, - "latest_feedback_stamp_ns": 10, "latest_command_stamp_ns": 18} - assert not any(row.get("kind") == "joint_sample" for row in rows) diff --git a/src/linkerhand_calibration/test/test_pose_tracking_resume_policy.py b/src/linkerhand_calibration/test/test_pose_tracking_resume_policy.py deleted file mode 100644 index 7ae59e3..0000000 --- a/src/linkerhand_calibration/test/test_pose_tracking_resume_policy.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Older pose and capture schedules remain readable without authorizing reuse.""" - -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.acquisition import load_capture -from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine -from linkerhand_calibration.runtime.joint_resume import JointResume -from linkerhand_calibration.runtime.resume import discover_resume_candidate - - -def header(profile, policy, *, schedule="current"): - result = {"kind": "session_start", "profile_id": profile.key.profile_id, - "serial_number": "TEST", "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "protected_hashes": {"source": "a" * 64}} - if policy is not None: - result["pose_tracking_policy_version"] = policy - if schedule is not None: - result["capture_schedule_version"] = ( - CalibrationEngine(profile).capture_schedule_version if schedule == "current" else schedule) - return result - - -def journal(directory, profile, policy, *, schedule="current"): - directory.mkdir() - rows = [header(profile, policy, schedule=schedule), {"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 rows), encoding="utf-8") - return path, rows - - -@pytest.mark.parametrize("policy", [None, "", "legacy_single_frame", "confirmed_branch_v0", "confirmed_branch_v1", - "confirmed_branch_v5_image_motion"]) -def test_discovery_skips_old_pose_policy_but_preserves_offline_reading(tmp_path, policy): - profile = load_bundled_hand_profile("o6_right_8") - current = tmp_path / "01_current" - journal(current, profile, POSE_TRACKING_POLICY_VERSION) - old, rows = journal(tmp_path / "02_old", profile, policy) - before = old.read_bytes() - found = discover_resume_candidate(tmp_path, profile_id=profile.key.profile_id, - serial_number="TEST", protected_hashes={"source": "a" * 64}) - assert found == current - assert load_capture(old) == rows - assert old.read_bytes() == before - - -@pytest.mark.parametrize("schedule", [None, "unified_schedule_v1_sweep_then_steady"]) -def test_old_capture_schedule_is_readable_but_cannot_be_discovered_or_staged(tmp_path, monkeypatch, schedule): - from linkerhand_calibration.runtime import joint_resume - profile = load_bundled_hand_profile("o6_right_8") - current = tmp_path / "01_current" - journal(current, profile, POSE_TRACKING_POLICY_VERSION) - old, rows = journal(tmp_path / "02_old", profile, POSE_TRACKING_POLICY_VERSION, - schedule=schedule) - before = old.read_bytes() - engine = CalibrationEngine(profile) - assert not engine.resume_compatible(rows[0]) - assert discover_resume_candidate(tmp_path, profile_id=profile.key.profile_id, - serial_number="TEST", protected_hashes={"source": "a" * 64}) == current - monkeypatch.setattr(joint_resume, "read_joint_zero_references", - lambda *args: pytest.fail("old schedule must be rejected before importing zeros")) - resume = JointResume(profile) - with pytest.raises(ValueError, match="joint_resume_checkpoint_policy_incompatible"): - resume.stage(engine, rows, set()) - assert resume.references == {} and resume.rows == () - assert load_capture(old) == rows - assert old.read_bytes() == before - - -@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) -@pytest.mark.parametrize("policy", [None, "old_unconfirmed_branch", "confirmed_branch_v5_image_motion", - POSE_TRACKING_POLICY_VERSION]) -def test_explicit_engine_resume_requires_current_pose_policy(layout, policy): - profile = load_bundled_hand_profile(layout) - assert CalibrationEngine(profile).resume_compatible(header(profile, policy)) == ( - policy == POSE_TRACKING_POLICY_VERSION) - - -@pytest.mark.parametrize("policy", [None, "old_unconfirmed_branch", "confirmed_branch_v5_image_motion"]) -def test_joint_staging_rejects_old_policy_before_reading_references(monkeypatch, policy): - from linkerhand_calibration.runtime import joint_resume - profile = load_bundled_hand_profile("o6_right_8") - monkeypatch.setattr(joint_resume, "read_joint_zero_references", - lambda *args: pytest.fail("read old zero references")) - resume = JointResume(profile) - with pytest.raises(ValueError, match="joint_resume_checkpoint_policy_incompatible"): - resume.stage(CalibrationEngine(profile), [header(profile, policy)], set()) - assert resume.references == {} - assert resume.rows == () - - -def test_joint_staging_accepts_current_policy_without_changing_it(monkeypatch): - from linkerhand_calibration.runtime import joint_resume - from linkerhand_calibration.runtime import motion_provenance - profile = load_bundled_hand_profile("o6_right_8") - rows = [header(profile, POSE_TRACKING_POLICY_VERSION)] - monkeypatch.setattr(joint_resume, "read_joint_zero_references", lambda *args: {}) - # This isolated test exercises policy dispatch only. Real reference and - # provenance acceptance is covered with complete evidence in dedicated tests. - validated = [] - monkeypatch.setattr(motion_provenance, "validate_motion_provenance", - lambda *args, **kwargs: validated.append((args, kwargs))) - resume = JointResume(profile) - resume.stage(CalibrationEngine(profile), rows, set()) - assert resume.rows == tuple(rows) - assert rows[0]["pose_tracking_policy_version"] == POSE_TRACKING_POLICY_VERSION - assert rows[0]["capture_schedule_version"] == CalibrationEngine(profile).capture_schedule_version - assert len(validated) == 1 and validated[0][1]["references"] == {} diff --git a/src/linkerhand_calibration/test/test_preparation_admission.py b/src/linkerhand_calibration/test/test_preparation_admission.py deleted file mode 100644 index 24a94ba..0000000 --- a/src/linkerhand_calibration/test/test_preparation_admission.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Bad preparation images cannot replace valid evidence or authorize a model.""" - -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.branch_initialization import ( - BranchInitialization, initialization_artifacts, solve_initialization, -) -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from test_branch_initialization import arrival -from o30_capture_fixture import SOURCE - - -@pytest.mark.parametrize("defect", ["missing_candidates", "invalid_candidates", "unfrozen_root"]) -def test_bad_later_frame_cannot_replace_valid_motion_bin(defect): - profile = load_bundled_hand_profile("o6_right_8") - approach, zero, rows = arrival(profile) - image_geometry = defect == "unfrozen_root" - owner = BranchInitialization(profile, image_geometry=image_geometry) - owner.begin(approach, session_epoch=1, motion_version=7) - expected = BranchInitialization(profile, image_geometry=image_geometry) - expected.begin(approach, session_epoch=1, motion_version=7) - for row in rows: - owner.observe(row) - expected.observe(row) - bad = deepcopy(row) - bad["image_stamp_ns"] += 1 - for tag in bad["tags"].values(): - tag["candidate_diagnostics"]["observation_stamp_ns"] = bad["image_stamp_ns"] - root, child = bad["tags"].values() - if defect == "missing_candidates": - child["reprojection_valid_candidates"] = [] - elif defect == "invalid_candidates": - for candidate in child["reprojection_valid_candidates"]: - candidate["translation_xyz_m"] = [0., 0., -1.] - else: - root["candidate_diagnostics"]["branch_frozen"] = False - owner.observe(bad) - request = owner.request(rows[0]["view"], zero, session_epoch=1, motion_version=8) - original = expected.request(rows[0]["view"], zero, session_epoch=1, motion_version=8) - assert request.frames == original.frames - assert request.source_frame_hashes == original.source_frame_hashes - assert sum(dict(request.rejected_observations).values()) == len(rows) - # The admission gate keeps both alternatives, even if one looks worse. - assert all(len(frame.roles[1].candidates) == 2 for frame in request.frames) - if not image_geometry: - resolution = solve_initialization(request) - assert resolution.resolved - _, report = initialization_artifacts(request, resolution) - assert report["preparation_observation_rejections"] == dict(request.rejected_observations) - - -def test_dropping_all_invalid_images_never_produces_a_model(): - 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: - next(iter(row["tags"].values()))["selected_pose"]["translation_xyz_m"] = [0., 0., -1.] - owner.observe(row) - request = owner.request(rows[0]["view"], zero, session_epoch=1, motion_version=8) - assert not request.frames - assert not solve_initialization(request).resolved - - -def test_actual_ring_preparation_rejects_dropouts_without_claiming_geometry_passed(): - payload = json.loads(gzip.decompress((Path(__file__).parent / - "fixtures/o30_ring_preparation_admission.json.gz").read_bytes())) - profile = load_bundled_hand_profile("o30_right_18") - owner = BranchInitialization(profile, source_urdf=SOURCE) - reference = profile.motion.joint_zero_references["ring_mcp_pitch"] - motion = MotionCommand("zero_approach", reference.command, 200., - task_key="ring_mcp_pitch_side", reference_joints=("ring_mcp_pitch",)) - owner.begin(motion, session_epoch=1, motion_version=159) - for row in payload["records"]: - owner.observe(row) - zero = replace(motion, phase="joint_zero", zero_joints=motion.reference_joints) - request = owner.request("side", zero, session_epoch=1, motion_version=160) - assert payload["original_report"]["reason"] == "motion_candidates_missing_or_invalid" - assert len(payload["original_report"]["source_image_stamps"]) == 105 - assert len(request.frames) == 97 - assert dict(request.rejected_observations) == {"motion_candidates_missing_or_invalid": 14} - actual_hashes = {str(row["image_stamp_ns"]): source_frame_sha256(row) for row in payload["records"]} - assert all(actual_hashes[stamp] == digest for stamp, digest in request.source_frame_hashes) - resolution = solve_initialization(request) - assert resolution.reason != "motion_candidates_missing_or_invalid" - # This historical arc also contains independent geometry/coverage defects. - # Removing broken detections must not relabel it as a successful capture. - assert not resolution.resolved and resolution.model is None diff --git a/src/linkerhand_calibration/test/test_preparation_contract.py b/src/linkerhand_calibration/test/test_preparation_contract.py deleted file mode 100644 index 700f3d1..0000000 --- a/src/linkerhand_calibration/test/test_preparation_contract.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Preparation upgrades cannot silently reuse frozen upstream geometry.""" - -from copy import deepcopy -from dataclasses import replace -import json - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import STAGED, configure_training -from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract -from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine -from linkerhand_calibration.runtime.joint_resume import PreparedResume -from linkerhand_calibration.runtime.resume import discover_resume_candidate - - -def dense_profile(): - return configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), STAGED), - 'occlusion_aware_witness_v1') - - -def header(profile): - return dict(kind='session_start', serial_number='O30_TEST', profile_id=profile.key.profile_id, - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, - capture_schedule_version=CalibrationEngine(profile).capture_schedule_version, - capture_plan={'policy': STAGED}, protected_hashes={'source': 'a'*64}, - preparation_contract=preparation_contract(profile), resume_checkpoint_requested=True) - - -def checkpoint(root, name, start, *, bad_tail=False): - path = root/name - path.mkdir() - rows = [start, dict(kind='fixed_base_reference_locked'), - dict(kind='scan_unit_complete', task_name='thumb_cmc_roll_front', - cycle=0, direction='increasing', passed=True)] - (path/'raw_samples.jsonl').write_text('\n'.join(json.dumps(row) for row in rows) - + ('\nnot-json' if bad_tail else '\n')) - return path - - -def discover(root, profile, rejected): - return discover_resume_candidate(root, profile_id=profile.key.profile_id, serial_number='O30_TEST', - protected_hashes={'source': 'a'*64}, training_policy=STAGED, - preparation_contract=preparation_contract(profile), rejected_candidates=rejected) - - -def test_old_budget_rejected_at_header_before_loading_large_history(tmp_path): - profile = dense_profile() - old = header(profile) - old.pop('preparation_contract') - checkpoint(tmp_path, '001', old, bad_tail=True) - rejected = [] - assert discover(tmp_path, profile, rejected) is None - assert rejected == [dict(session='001', reason='checkpoint preparation contract changed')] - assert not CalibrationEngine(profile).resume_compatible(old) - - -def test_matching_contract_resumes_and_json_roundtrip_is_identical(tmp_path): - profile = dense_profile() - start = json.loads(json.dumps(header(profile))) - source = checkpoint(tmp_path, '001', start) - assert CalibrationEngine(profile).resume_compatible(start) - assert discover(tmp_path, profile, []) == source - # Capture validity is separate: readable historical artifacts are not - # relabelled corrupt simply because a new live plan uses a different budget. - old = {key: value for key, value in start.items() if key != 'preparation_contract'} - assert CalibrationEngine(profile).capture_compatible(old) - - -@pytest.mark.parametrize('field', ['budget', 'witness', 'parent', 'version']) -def test_changed_dependency_graph_invalidates_whole_preparation_before_motion(tmp_path, field): - profile = dense_profile() - changed = header(profile) - contract = changed['preparation_contract'] - if field == 'budget': - contract['tasks']['pinky_pip_side']['command_intervals'] = 128 - elif field == 'witness': - contract['witnesses'].pop('pinky_mcp_pitch') - elif field == 'parent': - contract['tasks']['pinky_dip_side']['parent_reference']['geometry_joint'] = 'ring_pip' - else: - contract['version'] = 'future_strategy' - checkpoint(tmp_path, '001', changed) - assert discover(tmp_path, profile, []) is None - assert not CalibrationEngine(profile).resume_compatible(changed) - with pytest.raises(ValueError, match='identity/policy changed'): - PreparedResume.load(profile, CalibrationEngine(profile), tmp_path/'001/raw_samples.jsonl', 'O30_TEST') - - -def test_incompatible_fresh_run_cannot_resurrect_older_lineage(tmp_path): - profile = dense_profile() - checkpoint(tmp_path, '001', header(profile)) - fresh = header(profile) - fresh.pop('preparation_contract') - fresh['resume_checkpoint_requested'] = False - checkpoint(tmp_path, '002', fresh) - assert discover(tmp_path, profile, []) is None - - -def test_resume_upgrade_does_not_rewrite_historical_capture(tmp_path): - profile = dense_profile() - old = header(profile) - old.pop('preparation_contract') - path = checkpoint(tmp_path, '001', old)/'raw_samples.jsonl' - before = path.read_bytes() - assert discover(tmp_path, profile, []) is None - assert path.read_bytes() == before - - -def test_capture_cycle_supplement_keeps_preparation_but_changed_pose_does_not(): - from linkerhand_calibration.core.domain.capture_plan import select_task_training - profile = dense_profile() - supplemented = select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2)) - assert preparation_contract(supplemented) == preparation_contract(profile) - zeros = dict(profile.motion.joint_zero_references) - spec = zeros['pinky_pip'] - command = list(spec.command) - command[0] -= 1 - zeros['pinky_pip'] = replace(spec, command=tuple(command)) - changed = replace(profile, motion=replace(profile.motion, joint_zero_references=zeros)) - assert preparation_contract(changed) != preparation_contract(profile) - - -def test_live_coordinator_writes_effective_preparation_graph(tmp_path): - from runtime_host_fixture import coordinator_fixture - host, _ = coordinator_fixture(tmp_path, model='o30', profile_transform=lambda _: dense_profile()) - try: - from linkerhand_calibration.storage import load_jsonl - row = load_jsonl(host.raw_path)[0] - assert row['preparation_contract'] == preparation_contract(host.profile) - assert CalibrationEngine(host.profile).resume_compatible(row) - finally: - host.close() - - -def test_legacy_profiles_keep_existing_resume_contract(): - profile = load_bundled_hand_profile('o30_right_18') - assert preparation_contract(profile) is None - - -def test_new_header_cannot_relabel_old_parent_models(): - from linkerhand_calibration.profiles.preparation import validate_preparation_contract - from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import DENSE_PREPARATION_SAMPLING - profile = dense_profile() - old = dict(kind='motion_branch_initialization', task_name='pinky_pip_side', - zero_joints=['pinky_pip'], status='resolved') - with pytest.raises(ValueError, match='model_budget_changed'): - validate_motion_provenance(profile, [header(profile), old]) - dense = {**old, 'image_sampling_policy': DENSE_PREPARATION_SAMPLING} - validate_preparation_contract(profile, [header(profile), dense]) - changed = deepcopy(header(profile)) - changed['preparation_contract']['version'] = 'another_graph' - with pytest.raises(ValueError, match='profile_changed'): - validate_preparation_contract(profile, [changed, dense]) diff --git a/src/linkerhand_calibration/test/test_preparation_density.py b/src/linkerhand_calibration/test/test_preparation_density.py deleted file mode 100644 index 2cc14c9..0000000 --- a/src/linkerhand_calibration/test/test_preparation_density.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Replay real PIP/DIP pixels with a budget fixed before either fit. - -This is an offline counterfactual, not a passed capture: the new parent model -is projected onto the original DIP images. Original journals and frozen zeros -are never overwritten, and no artifact publication is authorized here. -""" -from copy import deepcopy -from dataclasses import asdict, replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import configure_training -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.geometry.image_motion_replay import frozen_image_model -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ( - ImageMotionParameters, resolve_image_motion, select_image_motion_frame, -) -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionEvidenceParameters -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_image_budget -from linkerhand_calibration.runtime.branch_initialization import ( - BranchInitialization, solve_initialization, initialization_artifacts, -) -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.passed_resume import restore_parent_models -from linkerhand_calibration.runtime.shared_tag_reference import bridge_frames -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def replay(): - data = json.loads(gzip.decompress((Path(__file__).parent / - 'fixtures/o30_pinky_preparation_density.json.gz').read_bytes())) - profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - owner = BranchInitialization(profile, source_urdf=SOURCE) - old = data['pip_report'] - reference = JointZeroReference.from_record(old['shared_tag_pose_bridges'][0]['source_reference']) - restore_parent_models(owner.parent_references, [data['source_report']], 1) - for row in data['source_window']: - owner.parent_references.handoff.observe(row) - def request(joint, report, rows, references=None): - spec = profile.motion.joint_zero_references[joint] - motion = MotionCommand('zero_approach', spec.command, 200., - task_key=report['task_name'], reference_joints=(joint,)) - owner.begin(motion, session_epoch=1, motion_version=report['motion_version']) - for row in rows: - owner.observe(row) - result = owner.request('side', replace(motion, phase='joint_zero', zero_joints=(joint,)), - session_epoch=1, motion_version=report['motion_version']+1, zero_references=references) - assert not result.invalid_reason - return result - pip_request = request('pinky_pip', old, data['pip_rows'], {reference.joint: reference}) - pip = solve_initialization(pip_request) - assert pip.resolved, pip.reason - _, pip_report = initialization_artifacts(pip_request, pip) - owner.accept(pip_request, pip) - # Geometry extraction requires current-pose admission. This offline marker - # only exercises the request builder, not the full pose/zero authorization. - owner.parent_references.verified[(1, 'pinky_dip_side')] = {'offline_counterfactual': True} - rows = deepcopy(data['dip_rows']) - for row in rows: - image = bridge_frames([row], pip.model.relations[0])[0] - selected = select_image_motion_frame(pip.model, image) - assert selected.resolved, selected.reason - tag = row['tags']['pinky_pip'] - tag['selected_pose'] = asdict(next(s.pose for s in selected.selections if s.role == 'pinky_pip')) - tag['frozen_image_model'] = pip_report['frozen_image_model'] - tag['image_model_sha256'] = pip_report['image_model_sha256'] - tag['candidate_diagnostics']['motion_evidence_id'] = pip_report['evidence_ids']['pinky_pip'] - dip_request = request('pinky_dip', data['dip_report'], rows) - dip = solve_initialization(dip_request) - return data, profile, pip_request, pip, dip_request, dip - - -@pytest.mark.replay -def test_real_parent_child_precision_with_disjoint_more_complete_evidence(replay): - data, _, pip_request, pip, dip_request, dip = replay - assert data['dip_report']['reason'] == 'image_motion_no_observable_image_model' - assert all(h['reason'] == 'image_motion_geometry_uncertain' for h in data['dip_report']['hypotheses']) - assert dip.resolved, dip.reason - for request, result in ((pip_request, pip), (dip_request, dip)): - assert 200 < len(request.frames) <= 257 - model = result.model - assert len(model.training_stamps_ns) > 100 - assert len(model.validation_stamps_ns) > 100 - assert not set(model.training_stamps_ns) & set(model.validation_stamps_ns) - assert set(model.training_stamps_ns) | set(model.validation_stamps_ns) == set(model.source_stamps_ns) - winner = result.hypotheses[result.selected_hypothesis] - assert all(v <= MotionEvidenceParameters().maximum_split_axis_difference_rad - for _, v in winner.axis_uncertainty_95_rad) - assert model.maximum_reprojection_error_px == 1.5 - _, report = initialization_artifacts(request, result) - assert frozen_image_model(report) == model - assert all(p < .01 for _, p in dip.hypotheses[dip.selected_hypothesis].comparison_adjusted_p_values) - - -@pytest.mark.replay -def test_dense_child_does_not_relabel_coarse_frozen_parent(replay): - data, _, _, _, request, _ = replay - from linkerhand_calibration.core.geometry.tag_pose.source_hinge import SourceHinge - old = data['pip_report'] - source = SourceHinge(frozen_image_model(old).geometry[0], old['evidence_ids']['pinky_pip'], - old['image_model_sha256'], *old['geometry_uncertainty'][0][1:]) - result = resolve_image_motion(request.image_frames, request.relations, - parameters=request.image_parameters, source_hinges=(source,), - geometry_constraints=request.geometry_constraints, source_urdf_sha256=request.source_urdf_sha256) - assert not result.resolved - assert result.reason == 'image_motion_families_not_distinguishable' - - -def test_preparation_budget_does_not_expand_multi_joint_graph_or_other_profiles(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), 'staged_2_to_3') - assert preparation_image_budget(profile) == (128, ImageMotionParameters()) - dense = configure_preparation(profile, 'occlusion_aware_witness_v1') - assert preparation_image_budget(dense)[0] == 256 - assert preparation_image_budget(dense, joint_count=4) == (128, ImageMotionParameters()) - with pytest.raises(ValueError, match='limits_invalid'): - ImageMotionParameters(maximum_training_frames=145) - - -@pytest.mark.replay -def test_sampling_budget_is_bound_and_cannot_be_removed_on_readback(replay): - from linkerhand_calibration.runtime.motion_provenance import _initializations, _check_image_initialization - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import DENSE_PREPARATION_SAMPLING - data, _, request, result, _, _ = replay - _, report = initialization_artifacts(request, result) - assert report['image_sampling_policy'] == DENSE_PREPARATION_SAMPLING - assert _initializations([*data['pip_rows'], report]) - changed = deepcopy(report) - changed.pop('image_sampling_policy') - with pytest.raises(ValueError, match='binding_changed'): - _initializations([*data['pip_rows'], changed]) - payload = deepcopy(next(iter(report['evidence_payloads'].values()))) - payload.pop('image_sampling_policy') - observations = {(r['view'], r['image_stamp_ns']): r for r in data['pip_rows']} - with pytest.raises(ValueError, match='support_or_roles_changed'): - _check_image_initialization(payload, observations) diff --git a/src/linkerhand_calibration/test/test_preparation_sync.py b/src/linkerhand_calibration/test/test_preparation_sync.py deleted file mode 100644 index 74597eb..0000000 --- a/src/linkerhand_calibration/test/test_preparation_sync.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Real capture gaps must not become identity changes or bypass geometric gates.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation -from linkerhand_calibration.core.domain.capture_plan import configure_training -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.preparation_witness import select_witness_rows, build_preparation_witness -from linkerhand_calibration.runtime.motion_provenance import _initializations, source_frame_sha256 -from linkerhand_calibration.runtime.preparation_witness_evidence import validate_preparation_witnesses -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def real_capture(): - data = json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_pinky_witness_sync.json.gz').read_bytes())) - profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - owner = BranchInitialization(profile, source_urdf=SOURCE) - failure = data['failure'] - motion = MotionCommand('zero_approach', profile.motion.joint_zero_references['pinky_mcp_pitch'].command, - 200., task_key=failure['task_name'], reference_joints=('pinky_mcp_pitch',)) - owner.begin(motion, session_epoch=failure['session_epoch'], motion_version=failure['motion_version']) - for row in data['rows']: - if row.get('preparation_witness_joint'): - owner.witness_capture.observe(row) - elif row['motion_version'] == failure['motion_version']: - owner.observe(row) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=('pinky_mcp_pitch',)), - session_epoch=failure['session_epoch'], motion_version=failure['motion_version']+1) - assert not request.invalid_reason - return profile, data, request - - -@pytest.fixture(scope='module') -def replayed(real_capture): - profile, data, request = real_capture - result = solve_initialization(request) - assert result.resolved, result.reason - _, report = initialization_artifacts(request, result) - return result, json.loads(json.dumps([*data['rows'], report])) - - -@pytest.mark.replay -def test_real_gap_preserves_all_pixels_and_independent_acceptance(real_capture, replayed): - profile, _, request = real_capture - source = request.witness_sources[0] - assert len(source.rows) == 219 - missing = [r for r in source.rows if r['command_vector'] is None] - assert len(missing) == 2 - result, records = replayed - decision = result.preparation_witnesses[0] - assert decision['decision'] == 'independent_before_fit' - assert decision['reason'] == 'insufficient_rotation_evidence' - assert set(decision['source_frame_hashes']) == {str(r['image_stamp_ns']) for r in source.rows} - assert decision['sample_admission']['rejected_frames'] == [ - {'image_stamp_ns': r['image_stamp_ns'], 'missing': ['command_vector']} for r in missing] - assert not set(r['image_stamp_ns'] for r in missing) & set(decision['selected_image_stamps']) - assert all(r['command_vector'] is None for r in missing) # No imputation or mutation. - assert result.hypotheses[result.selected_hypothesis].comparison_adjusted_p_values[0][1] < .01 - evidence = _initializations(records) - validate_preparation_witnesses(profile, records, evidence) - - -@pytest.mark.parametrize('fault', ['held_feedback_on_missing_command', 'held_command_on_missing_feedback', - 'epoch', 'camera', 'vector_shape', 'nonfinite', 'endpoint', 'long_gap', 'many_missing']) -def test_missing_samples_cannot_hide_invalid_evidence(real_capture, fault): - profile, _, request = real_capture - source = request.witness_sources[0] - rows = deepcopy(source.rows) - index = next(i for i, r in enumerate(rows) if r['command_vector'] is None) - if fault == 'held_feedback_on_missing_command': - rows[index]['feedback_vector'][10] += 8 - elif fault == 'held_command_on_missing_feedback': - rows[index]['command_vector'] = list(profile.motion.preparation_witnesses[source.joint].command) - rows[index]['command_vector'][10] += 1 - rows[index]['feedback_vector'] = None - elif fault == 'epoch': - rows[index]['session_epoch'] += 1 - elif fault == 'camera': - rows[index]['camera_matrix'][0][0] += 1 - elif fault == 'vector_shape': - rows[index]['feedback_vector'] = [1.] - elif fault == 'nonfinite': - rows[index]['feedback_vector'][10] = float('nan') - elif fault == 'endpoint': - rows[-1]['command_vector'] = None - elif fault == 'long_gap': - for row in rows[index+2:]: - row['image_stamp_ns'] += 300_000_000 - else: - for row in rows[20:40]: - row['command_vector'] = None - with pytest.raises(ValueError): - select_witness_rows(profile, replace(source, rows=tuple(rows))) - - -def test_preflight_declines_before_any_auxiliary_fit(real_capture, monkeypatch): - from linkerhand_calibration.runtime import preparation_witness as module - profile, _, request = real_capture - monkeypatch.setattr(module, 'resolve_image_motion', lambda *a, **k: pytest.fail('auxiliary fitting must not start')) - bridge, record = build_preparation_witness(profile, request.witness_sources[0]) - assert bridge is None and record['decision'] == 'independent_before_fit' - - -def test_incomplete_candidate_is_not_proof_of_insufficient_motion(monkeypatch): - from linkerhand_calibration.runtime.preparation_witness import _insufficient_rotation_before_fit - from linkerhand_calibration.core.geometry.tag_pose import motion_image_diagnostics as diagnostics - monkeypatch.setattr(diagnostics, '_validate_frames', lambda *a: ({'tag': ((None,),)}, ('tag',))) - assert not _insufficient_rotation_before_fit((), None) - - -def test_declined_auxiliary_does_not_overrule_ambiguous_main_images(real_capture, monkeypatch): - from linkerhand_calibration.runtime import branch_initialization as module - from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionResolution - _, _, request = real_capture - monkeypatch.setattr(module, 'resolve_image_motion', lambda *a, **k: - ImageMotionResolution(False, 'image_motion_families_not_distinguishable')) - result = solve_initialization(request) - assert not result.resolved and result.reason == 'image_motion_families_not_distinguishable' - - -def test_auxiliary_fit_failure_cannot_choose_another_acceptance_route(real_capture, monkeypatch): - from linkerhand_calibration.runtime import branch_initialization as main, preparation_witness as auxiliary - from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionResolution - _, _, request = real_capture - monkeypatch.setattr(auxiliary, '_insufficient_rotation_before_fit', lambda *a: False) - monkeypatch.setattr(auxiliary, 'resolve_image_motion', lambda *a, **k: - ImageMotionResolution(False, 'image_motion_families_not_distinguishable')) - monkeypatch.setattr(main, 'resolve_image_motion', lambda *a, **k: pytest.fail('no fallback after auxiliary fit')) - result = solve_initialization(request) - assert not result.resolved and result.reason.startswith('preparation_witness_unobservable:') - - -def test_excessive_sync_loss_has_its_own_category_without_motion_retry(): - from linkerhand_calibration.runtime.zero_recovery import failure_category, FailureCategory, permits_recovery - from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh - for reason in ('preparation_sync_coverage_missing', 'preparation_sync_endpoint_missing', - 'preparation_sync_gap_too_long', 'preparation_state_vector_invalid'): - assert failure_category(reason) == FailureCategory.EVIDENCE_GAP - assert not permits_recovery([reason], 0) - assert reason_zh({'reason': 'joint_zero_motion_unresolved:'+reason}, model_name='O30')[0] == 'OBS-PREP-SYNC-119' - - -@pytest.mark.replay -@pytest.mark.parametrize('fault', ['erase_missing', 'change_rejection', 'change_reason', 'remove_record']) -def test_raw_gaps_and_admission_decision_remain_auditable(real_capture, replayed, fault): - profile, _, _ = real_capture - _, original = replayed - records = deepcopy(original) - report = records[-1] - if fault == 'erase_missing': - stamp = report['preparation_witnesses'][0]['sample_admission']['rejected_frames'][0]['image_stamp_ns'] - records = [r for r in records if r.get('image_stamp_ns') != stamp] - else: - for container in [report, *report['evidence_payloads'].values()]: - if fault == 'remove_record': - container.pop('preparation_witnesses') - elif fault == 'change_rejection': - container['preparation_witnesses'][0]['sample_admission']['rejected_frames'] = [] - else: - container['preparation_witnesses'][0]['reason'] = 'ignore_bad_fit' - if fault == 'remove_record': - report['image_model_selection_policy'] = 'training_squared_loss_holm_v1' - for role, payload in report['evidence_payloads'].items(): - report['evidence_ids'][role] = source_frame_sha256(payload) - with pytest.raises(ValueError): - evidence = _initializations(records) - validate_preparation_witnesses(profile, records, evidence) diff --git a/src/linkerhand_calibration/test/test_preparation_transition.py b/src/linkerhand_calibration/test/test_preparation_transition.py deleted file mode 100644 index 67bcce5..0000000 --- a/src/linkerhand_calibration/test/test_preparation_transition.py +++ /dev/null @@ -1,216 +0,0 @@ -"""The real 2026-09-20 MCP pause, through runtime admission and journal audit.""" - -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import _initializations, source_frame_sha256, validate_source_geometry -from linkerhand_calibration.runtime.passed_resume import restore_parent_models -from linkerhand_calibration.runtime.preparation_transition import build_preparation_transition -from linkerhand_calibration.runtime.preparation_transition_evidence import validate_preparation_transitions -from o30_capture_fixture import SOURCE - -pytestmark = pytest.mark.replay - - -@pytest.fixture(scope='module') -def recording(): - path = Path(__file__).parent/'fixtures/o30_thumb_preparation_transition.json.gz' - return json.loads(gzip.decompress(path.read_bytes())) - - -def request_from_recording(recording): - profile = load_bundled_hand_profile('o30_right_18') - owner = BranchInitialization(profile, source_urdf=SOURCE) - reference = JointZeroReference.from_record(next(r for r in recording['records'] if r['kind'] == 'joint_zero_reference')) - report = next(r for r in recording['records'] if r['kind'] == 'motion_branch_initialization') - restore_parent_models(owner.parent_references, [report], 1) - owner.parent_references.remember_zero(reference, 1) - spec = profile.motion.joint_zero_references['thumb_mcp'] - motion = MotionCommand('zero_approach', spec.command, 200., task_key=spec.task_key, reference_joints=('thumb_mcp',)) - for version in (46, 48): - owner.begin(motion, session_epoch=1, motion_version=version) - for row in recording['records']: - if row['kind'] == 'motion_branch_observation' and row['task_name'] == spec.task_key and row['motion_version'] == version: - owner.observe(row) - request = owner.request('front', replace(motion, phase='joint_zero', zero_joints=('thumb_mcp',)), - session_epoch=1, motion_version=49, zero_references={reference.joint: reference}) - return owner, request - - -@pytest.fixture(scope='module') -def replayed(recording, tmp_path_factory): - owner, request = request_from_recording(recording) - assert not request.invalid_reason and request.transition_source is not None - result = solve_initialization(request) - assert result.resolved, result.reason - _, report = initialization_artifacts(request, result) - (tmp_path_factory.mktemp('preparation_transition')/'replay_report.json').write_text( - json.dumps(report, indent=2, allow_nan=False)) - records = [*recording['records'], report] - return owner.profile, request, result, records - - -def test_real_mcp_requires_transition_evidence_not_a_lower_error_winner(replayed): - _, request, result, _ = replayed - old = resolve_image_motion(request.image_frames, request.relations) - assert not old.resolved and old.reason == 'image_motion_families_not_distinguishable' - assert len(request.frames) == 128 and len(request.transition_source.rows) == 138 - assert len(result.hypotheses) == 4 - accepted = [h for h in result.hypotheses if not h.reason] - assert len(accepted) == 2 - assert result.model.maximum_reprojection_error_px == 1.5 - assert result.model.reprojection_tie_px == .03 - assert all(q.maximum_frame_rms_px < 1.5 for h in accepted - for q in (*h.training_quality, *h.validation_quality)) - assert all(h.reason == 'image_motion_reprojection_failed' for h in result.hypotheses if h.reason) - assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns) - assert result.preparation_transitions[0]['source_frame_hashes'] == { - str(r['image_stamp_ns']): source_frame_sha256(r) for r in request.transition_source.rows} - - -def test_real_transition_rebuilds_through_formal_evidence_boundary(replayed): - profile, _, _, records = replayed - evidence = _initializations(records) - validate_source_geometry(profile, SOURCE, records) - validate_preparation_transitions(profile, records, evidence) - - -@pytest.mark.parametrize('fault', ['held_feedback', 'held_command', 'feedback_stuck', 'endpoint', - 'camera', 'epoch', 'validation', 'duplicate', 'installation']) -def test_invalid_entry_evidence_cannot_authorize_a_model(replayed, fault): - profile, request, _, _ = replayed - rows = deepcopy(request.transition_source.rows) - if fault == 'held_feedback': - rows[25]['feedback_vector'][6] += 8 - elif fault == 'held_command': - rows[25]['command_vector'][6] = 1 - elif fault == 'feedback_stuck': - for row in rows: - row['feedback_vector'][1] = 0 - elif fault == 'endpoint': - rows = tuple(r for r in rows if r['command_vector'][1] != 80) + tuple( - r for r in rows if r['command_vector'][1] == 80)[:9] - elif fault == 'camera': - for row in rows: - row['camera_matrix'][0][0] += 1 - elif fault == 'epoch': - rows[25]['session_epoch'] += 1 - elif fault == 'validation': - rows[25]['cycle'] = 3 - elif fault == 'duplicate': - rows[25]['image_stamp_ns'] = rows[24]['image_stamp_ns'] - else: - for row in rows[:7]: - row['tags']['thumb_mcp']['corners_xy'][0][0] += 30 - source = replace(request.transition_source, rows=tuple(rows)) - with pytest.raises(ValueError): - build_preparation_transition(profile, source, request.current_source_rows) - - -@pytest.mark.parametrize('fault', ['missing_pixels', 'policy', 'winner', 'source_zero', 'omitted_frame']) -def test_tampered_evidence_is_rejected_before_publication(replayed, fault): - _, _, _, records = replayed - records = deepcopy(records) - report = records[-1] - source_stamp = int(next(iter(report['preparation_transitions'][0]['source_frame_hashes']))) - if fault == 'missing_pixels': - records[:] = [r for r in records if not (r.get('kind') == 'motion_branch_observation' and r.get('image_stamp_ns') == source_stamp)] - elif fault == 'policy': - report['image_model_selection_policy'] = 'training_squared_loss_holm_v1' - elif fault == 'winner': - report['selected_hypothesis'] = 1 - elif fault == 'source_zero': - records[:] = [r for r in records if r.get('kind') != 'joint_zero_reference'] - else: - for payload in [report, *report['evidence_payloads'].values()]: - payload['preparation_transitions'][0]['source_frame_hashes'].pop(str(source_stamp), None) - for role, payload in report['evidence_payloads'].items(): - report['evidence_ids'][role] = source_frame_sha256(payload) - with pytest.raises(ValueError): - _initializations(records) - - -def test_cancellation_and_new_epoch_do_not_reuse_partial_transition(recording, replayed): - owner, request = request_from_recording(recording) - owner.clear() - assert owner.accept(request, replayed[2]) is None - assert not owner.transition_capture.rows - motion = MotionCommand('zero_approach', owner.profile.motion.joint_zero_references['thumb_mcp'].command, - 200., task_key='thumb_mcp_front', reference_joints=('thumb_mcp',)) - owner.begin(motion, session_epoch=2, motion_version=1) - assert owner.transition_capture.source is None - - -def test_transition_holdout_only_vetoes_without_retraining_or_selecting_another_family(replayed): - profile, request, original, _ = replayed - transition, _ = build_preparation_transition(profile, request.transition_source, request.current_source_rows) - witness = next(frame.evidence.stamp_ns for i, frame in enumerate(transition.frames) - if i % 2 == 1 and transition.endpoints[i] == -1) - rows = deepcopy(request.transition_source.rows) - row = next(r for r in rows if r['image_stamp_ns'] == witness) - for corner in row['tags']['thumb_mcp']['corners_xy']: - corner[0] += 40 - result = solve_initialization(replace(request, transition_source=replace(request.transition_source, rows=rows))) - assert not result.resolved - assert [h.training_quality for h in result.hypotheses] == [h.training_quality for h in original.hypotheses] - - -def test_accepted_transition_allows_runtime_to_continue(recording, replayed): - owner, request = request_from_recording(recording) - assert owner.accept(request, replayed[2]) is not None - spec = owner.profile.motion.joint_zero_references['thumb_mcp'] - motion = MotionCommand('joint_zero', spec.command, 200., task_key=spec.task_key, - zero_joints=('thumb_mcp',)) - assert owner.ready(motion) - assert owner.error() == '' - - -@pytest.mark.parametrize('independently_observable', [False, True]) -def test_missing_optional_transition_uses_original_observability_gate(replayed, independently_observable): - _, request, _, _ = replayed - request = replace(request, transition_source=replace(request.transition_source, - rows=request.transition_source.rows[:20])) - if independently_observable: - from test_motion_image_diagnostics import frames, RELATIONS - request = replace(request, image_frames=frames(), relations=RELATIONS) - result = solve_initialization(request) - assert result.resolved == independently_observable - assert result.preparation_transition_rejections == ('preparation_transition_samples_missing',) - assert not result.preparation_transitions - if not independently_observable: - assert result.reason == 'image_motion_families_not_distinguishable' - - -def test_changed_dependency_mask_cannot_hide_a_moving_joint(replayed): - profile, _, _, records = replayed - records = deepcopy(records) - report = records[-1] - for payload in [report, *report['evidence_payloads'].values()]: - payload['preparation_transitions'][0]['relevant_channels'] = [1] - for role, payload in report['evidence_payloads'].items(): - report['evidence_ids'][role] = source_frame_sha256(payload) - with pytest.raises(ValueError, match='preparation_transition_topology_changed'): - validate_source_geometry(profile, SOURCE, records) - - -def test_changed_frozen_model_cannot_replace_original_image_solution(replayed): - profile, _, _, records = replayed - evidence = deepcopy(_initializations(records)) - for payload in evidence.values(): - if payload.get('preparation_transitions'): - # Recomputing the outer digest cannot replace raw-image replay. - geometry = payload['frozen_image_model']['geometry'][0] - geometry['mount_child_xyz_m'] = tuple(v+.001 for v in geometry['mount_child_xyz_m']) - payload['image_model_sha256'] = source_frame_sha256(payload['frozen_image_model']) - with pytest.raises(ValueError, match='preparation_transition_frozen_model_changed'): - validate_preparation_transitions(profile, records, evidence) diff --git a/src/linkerhand_calibration/test/test_preparation_witness.py b/src/linkerhand_calibration/test/test_preparation_witness.py deleted file mode 100644 index 2a55004..0000000 --- a/src/linkerhand_calibration/test/test_preparation_witness.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Real ambiguity stays rejected; independent synthetic views exercise the new path.""" -from copy import deepcopy -from dataclasses import asdict, replace -import gzip -import json -from pathlib import Path - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation -from linkerhand_calibration.core.domain.capture_plan import configure_training, CapturePlan -from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver -from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points, solve_square_tag_ippe -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.preparation_witness import WitnessInput, build_preparation_witness, select_witness_rows -from linkerhand_calibration.runtime.motion_provenance import _initializations, source_frame_sha256 -from linkerhand_calibration.runtime.preparation_witness_evidence import validate_preparation_witnesses, validate_witness_topology -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def ring_source(): - data = json.loads(gzip.decompress(Path(__file__).with_name('fixtures').joinpath('o30_ring_mcp_ambiguous.json.gz').read_bytes())) - profile = load_bundled_hand_profile('o30_right_18') - owner = BranchInitialization(profile, source_urdf=SOURCE) - failure = data['failure'] - spec = profile.motion.joint_zero_references['ring_mcp_pitch'] - motion = MotionCommand('zero_approach', spec.command, 200., task_key=failure['task_name'], reference_joints=('ring_mcp_pitch',)) - owner.begin(motion, session_epoch=1, motion_version=failure['motion_version']) - for row in data['rows']: - owner.observe(row) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=('ring_mcp_pitch',)), - session_epoch=1, motion_version=failure['motion_version']+1) - fits = [] - original = solver._fit_hypothesis - def trace(*args, **kwargs): - fit = original(*args, **kwargs) - fits.append(fit) - return fit - with pytest.MonkeyPatch.context() as patch: - patch.setattr(solver, '_fit_hypothesis', trace) - result = solve_initialization(request) - return profile, owner, request, result, tuple(fits), tuple(data['rows']) - - -@pytest.mark.replay -def test_real_ring_is_not_authorized_without_new_pose_information(ring_source): - _, _, request, result, _, _ = ring_source - assert len(request.image_frames) == 127 - assert not result.resolved and result.reason == 'image_motion_families_not_distinguishable' - assert all(h.training_accepted for h in result.hypotheses) - - -@pytest.fixture(scope='module') -def witnessed_ring(ring_source): - old_profile, owner, request, _, fits, rows = ring_source - profile = configure_preparation(configure_training(old_profile, 'staged_2_to_3'), 'occlusion_aware_witness_v1') - channels = tuple(sorted(owner.tag_feedback_channels['ring_pip'])) - arc_stamps = {f.evidence.stamp_ns for f in request.image_frames} - endpoints = tuple(r for r in owner._endpoint_records['side'] if r['image_stamp_ns'] not in arc_stamps) - # This is a *synthetic* independent yaw arc, anchored to candidate 0 to - # exercise selection. It does not claim that real hardware chose branch 0. - model = fits[0][1] - pose = next(s.pose for s in select_image_motion_frame(model, request.image_frames[-1]).selections if s.role == 'ring_pip') - orientation = Rotation.from_quat(pose.quaternion_xyzw) - point = np.asarray(pose.translation_xyz_m) - pivot = point-np.array([0., .03, -.04]) - donor = profile.motion.preparation_witnesses['ring_mcp_pitch'] - target_spec = profile.motion.joint_zero_references['ring_mcp_pitch'] - start_command, excursion = donor.command[4], donor.approach_commands[1][4] - segments = [(start_command, excursion), (excursion, start_command)] - start = request.image_frames[0].evidence.stamp_ns-20_000_000_000 - sources = [] - for path, (before, after) in enumerate(segments, 1): - values = list(np.linspace(before, after, 33)) - if path == len(segments): - values += [after]*11 - for value in values: - row = deepcopy(rows[0]); stamp = start+len(sources)*40_000_000 - row.update(task_name=target_spec.task_key, zero_joints=['ring_mcp_pitch'], - preparation_witness_joint='ring_mcp_roll', - motion_version=100+path, reference_path_index=path, image_stamp_ns=stamp, - command_vector=list(donor.command), feedback_vector=list(donor.command)) - row['command_vector'][4] = row['feedback_vector'][4] = float(value) - turn = Rotation.from_rotvec(np.array([0., 1., 0.])*np.deg2rad((value-113)*.3)) - rotation, translation = turn*orientation, pivot+turn.apply(point-pivot) - points = rotation.apply(square_object_points(.0165))+translation - image = points@np.asarray(row['camera_matrix']).T - corners = image[:, :2]/image[:, 2:] - tag = row['tags']['ring_pip'] - tag['corners_xy'] = corners.tolist() - tag['selected_pose'] = None - tag['reprojection_valid_candidates'] = [asdict(p) for p in solve_square_tag_ippe(corners, - tag_size_m=.0165, camera_matrix=row['camera_matrix'])] - row['tags'] = {r: row['tags'][r] for r in ('side_base', 'ring_pip')} - for item in row['tags'].values(): - item['candidate_diagnostics']['observation_stamp_ns'] = stamp - sources.append(row) - source = WitnessInput('ring_mcp_pitch', 'ring_mcp_roll', tuple(sources), endpoints, channels) - bridge, record = build_preparation_witness(profile, source) - result = solver.resolve_image_motion(request.image_frames, request.relations, pose_bridges=(bridge,)) - assert result.resolved, result.reason - request = replace(request, witness_sources=(source,), witness_profile=profile) - result = replace(result, preparation_witnesses=(record,)) - _, report = initialization_artifacts(request, result) - # Journal round trip makes tuple/list normalization part of this test. - records = json.loads(json.dumps([*sources, *rows, report])) - return profile, source, bridge, record, request, result, records - - -def test_independent_witness_resolves_real_recipient_without_changing_limits(witnessed_ring): - profile, source, bridge, record, request, result, _ = witnessed_ring - assert result.resolved and len(result.hypotheses) == 2 - assert sum(h.training_accepted for h in result.hypotheses) == 1 - assert result.model.maximum_reprojection_error_px == 1.5 and result.model.reprojection_tie_px == .03 - assert bridge.rotation_uncertainty_rad > 0 and bridge.translation_uncertainty_m > 0 - assert set(record['source_model']['training_stamps_ns']).isdisjoint(record['source_model']['validation_stamps_ns']) - assert set(record['source_frame_hashes']).isdisjoint(record['endpoint_frame_hashes']) - assert set(record['endpoint_frame_hashes']).isdisjoint(dict(request.source_frame_hashes)) - assert source.rows[-1]['image_stamp_ns'] < request.image_frames[0].evidence.stamp_ns - - -def test_witness_formal_evidence_replays_original_pixels_and_model(witnessed_ring): - from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - profile, _, _, _, _, _, records = witnessed_ring - evidence = _initializations(records) - validate_witness_topology(profile, records, evidence, UrdfKinematicModel(SOURCE)) - validate_preparation_witnesses(profile, records, evidence) - - -@pytest.mark.legacy -def test_legacy_witness_evidence_keeps_its_original_admission_contract(witnessed_ring): - profile, source, _, _, request, result, records = witnessed_ring - _, legacy = build_preparation_witness(profile, source, legacy=True) - assert legacy['version'] == 'preparation_witness_v1' and 'sample_admission' not in legacy - for hypothesis in legacy['source_hypotheses']: - hypothesis.pop('training_solver_attempts', None) - _, report = initialization_artifacts(request, replace(result, preparation_witnesses=(legacy,))) - records = json.loads(json.dumps([*records[:-1], report])) - evidence = _initializations(records) - validate_preparation_witnesses(profile, records, evidence) - - -@pytest.mark.parametrize('change', ['pixels', 'missing', 'policy', 'window']) -def test_witness_tampering_is_rejected_without_whole_hand_refitting(witnessed_ring, change): - _, _, _, _, _, _, original = witnessed_ring - records = deepcopy(original) - if change == 'pixels': - records[1]['tags']['ring_pip']['corners_xy'][0][0] += .1 - elif change == 'missing': - del records[1] - elif change == 'policy': - records[-1]['image_model_selection_policy'] = 'training_squared_loss_holm_v1' - else: - payload = next(iter(records[-1]['evidence_payloads'].values())) - # Even coordinated hash updates must not hide an inconvenient raw frame. - record = records[-1]['preparation_witnesses'][0] - stamp = sorted(record['source_frame_hashes'], key=int)[1] - del record['source_frame_hashes'][stamp] - payload['preparation_witnesses'] = deepcopy(records[-1]['preparation_witnesses']) - role = payload['tag_role']; records[-1]['evidence_ids'][role] = source_frame_sha256(payload) - with pytest.raises(ValueError): - _initializations(records) - - -@pytest.mark.parametrize('change', ['command', 'feedback', 'formal', 'endpoint', 'missing']) -def test_invalid_witness_never_triggers_silent_fallback(witnessed_ring, change): - profile, source, _, _, request, _, _ = witnessed_ring - rows = deepcopy(source.rows) - endpoints = deepcopy(source.endpoint_rows) - if change == 'command': - rows[3]['command_vector'][9] += 1 - elif change == 'feedback': - rows[3]['feedback_vector'][9] += 3 - elif change == 'formal': - rows[3]['cycle'] = 3 - elif change == 'endpoint': - endpoints[0]['feedback_vector'][4] += 11 - else: - rows = () - changed = replace(source, rows=tuple(rows), endpoint_rows=tuple(endpoints)) - result = solve_initialization(replace(request, witness_sources=(changed,))) - assert not result.resolved and result.reason.startswith('preparation_witness_') - - -def test_plan_changes_are_opt_in_and_preserve_existing_tag_and_motion_contracts(): - from linkerhand_calibration.profiles.validator import validate_executable_profile - old = configure_training(load_bundled_hand_profile('o30_right_18'), 'staged_2_to_3') - new = configure_preparation(old, 'occlusion_aware_witness_v1') - assert new.vision == old.vision and new.quality == old.quality - assert {t.key: t for t in new.motion.tasks} == {t.key: t for t in old.motion.tasks} - assert new.motion.joint_zero_references == old.motion.joint_zero_references - assert CapturePlan.from_profile(new).sha256 != CapturePlan.from_profile(old).sha256 - assert 'preparation_witnesses' not in CapturePlan.from_profile(old).as_dict()['motion_contract']['motion'] - validate_executable_profile(new, SOURCE) - - -def test_live_collector_and_request_use_same_witness_as_offline(witnessed_ring, ring_source): - profile, source, bridge, _, _, expected, _ = witnessed_ring - _, _, old_request, _, _, rows = ring_source - owner = BranchInitialization(profile, source_urdf=SOURCE) - # The donor's optional side view never becomes a formal roll measurement. - for row in source.rows: - owner.observe(row) - assert owner._frames == {} - target = profile.motion.joint_zero_references[source.joint] - motion = MotionCommand('zero_approach', target.command, 200., task_key=target.task_key, - reference_joints=(source.joint,)) - owner.begin(motion, session_epoch=1, motion_version=old_request.motion_version) - for row in rows: - owner.observe(row) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=(source.joint,)), - session_epoch=1, motion_version=old_request.motion_version+1) - assert len(request.witness_sources) == 1 - result = solve_initialization(request) - # The live budget now retains more recipient frames. The independent - # witness stays identical; reproduce the recipient with that exact budget. - reproduced = solver.resolve_image_motion(request.image_frames, request.relations, - pose_bridges=(bridge,), parameters=request.image_parameters) - assert result.resolved and result.model == reproduced.model - assert result.preparation_witnesses == expected.preparation_witnesses - model, report = owner.accept(request, result) - assert model is not None and report['preparation_witnesses'] - - -def test_collector_is_bounded_and_epoch_reset_does_not_relabel_history(witnessed_ring): - from linkerhand_calibration.runtime.preparation_witness import MAXIMUM_WINDOW_FRAMES - profile, source, *_ = witnessed_ring - owner = BranchInitialization(profile, source_urdf=SOURCE) - row = source.rows[0] - for _ in range(MAXIMUM_WINDOW_FRAMES+20): - owner.witness_capture.observe(row) - assert len(next(iter(owner.witness_capture.windows.values()))) == MAXIMUM_WINDOW_FRAMES+1 - owner.witness_capture.observe(dict(row, session_epoch=2)) - assert list(owner.witness_capture.windows) == [(2, row['task_name'], row['view'])] - assert len(next(iter(owner.witness_capture.windows.values()))) == 1 - - -def test_missing_optional_tag_does_not_block_donor_capture(witnessed_ring): - from linkerhand_calibration.runtime.preparation_witness import witness_roles - profile, source, *_ = witnessed_ring - owner = BranchInitialization(profile, source_urdf=SOURCE) - motion = MotionCommand('zero_approach', profile.motion.joint_zero_references[source.donor].command, - 200., task_key='fingers_mcp_roll_front', reference_joints=(source.donor,)) - motion = replace(motion, reference_joints=('ring_mcp_pitch',), preparation_witness_joint='ring_mcp_roll') - assert witness_roles(profile, motion) == {'side_base', 'ring_pip', 'front_base', 'ring_mcp_roll'} - row = deepcopy(source.rows[0]); row['tags'].pop('ring_pip') - owner.observe(row) - assert not owner._invalid_reason - assert owner._frames == {} - - -def test_reference_change_and_unobservability_cannot_trigger_same_arc_retry(): - from linkerhand_calibration.runtime.zero_recovery import failure_category, permits_recovery, FailureCategory - assert failure_category('preparation_witness_unobservable:ambiguous') == FailureCategory.UNOBSERVABLE - assert failure_category('preparation_witness_reference_feedback_changed') == FailureCategory.REFERENCE_CHANGED - assert not permits_recovery(('preparation_witness_unobservable:ambiguous',), 0) - - -@pytest.mark.parametrize('change', ['holdout', 'installation']) -def test_new_images_can_veto_but_cannot_replace_witness(witnessed_ring, change): - profile, source, _, record, request, _, _ = witnessed_ring - rows, endpoints = deepcopy(source.rows), deepcopy(source.endpoint_rows) - if change == 'holdout': - stamp = record['source_model']['validation_stamps_ns'][4] - affected = [r for r in rows if r['image_stamp_ns'] == stamp] - else: - affected = endpoints - for row in affected: - row['tags']['ring_pip']['corners_xy'] = [[x+10, y+8] for x, y in row['tags']['ring_pip']['corners_xy']] - result = solve_initialization(replace(request, witness_sources=(replace(source, - rows=tuple(rows), endpoint_rows=tuple(endpoints)),))) - assert not result.resolved and result.reason.startswith('preparation_witness_') - assert result.model is None - - -def test_required_witness_cannot_be_removed_by_updating_hashes(witnessed_ring): - profile, _, _, _, _, _, original = witnessed_ring - records = deepcopy(original) - report = records[-1] - del report['preparation_witnesses'] - report['image_model_selection_policy'] = 'training_squared_loss_holm_v1' - for role, payload in report['evidence_payloads'].items(): - del payload['preparation_witnesses'] - report['evidence_ids'][role] = source_frame_sha256(payload) - evidence = _initializations(records) - with pytest.raises(ValueError, match='required_evidence_missing'): - validate_preparation_witnesses(profile, records, evidence) - - -def test_witness_configuration_round_trip_and_topology_are_explicit(tmp_path): - from linkerhand_calibration.profiles.export import write_hand_profile - from linkerhand_calibration.profiles import load_hand_profile - from linkerhand_calibration.profiles.preparation import validate_preparation_witnesses - from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - path = tmp_path/'witness.yaml'; write_hand_profile(profile, path) - restored = load_hand_profile(path) - assert CapturePlan.from_profile(restored) == CapturePlan.from_profile(profile) - invalid = replace(profile, motion=replace(profile.motion, - preparation_witnesses={'ring_mcp_pitch': replace(profile.motion.preparation_witnesses['ring_mcp_pitch'], joint='pinky_mcp_roll')})) - with pytest.raises(ValueError, match='posture_not_shared'): - validate_preparation_witnesses(invalid, UrdfKinematicModel(SOURCE)) - - -@pytest.mark.parametrize('joint', ['pinky_mcp_pitch', 'ring_mcp_pitch', 'middle_mcp_pitch', 'index_mcp_pitch']) -def test_occluded_fingers_get_one_local_excitation_after_clearance(joint): - from linkerhand_calibration.runtime.execution import SessionExecution - from linkerhand_calibration.runtime.session import CalibrationPhase as Phase - from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint - profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - driver = SessionExecution(profile) - task_key = profile.motion.joint_zero_references[joint].task_key - driver.session._unit_index = next(i for i, unit in enumerate(driver.session._units) if unit.task_key == task_key) - driver.session.phase = Phase.PREPARE - effects = driver._make_effects(profile.command.baseline_values) - witness = profile.motion.preparation_witnesses[joint] - excitation = [m for m in effects if m.preparation_witness_joint] - assert len(excitation) == 3 and [m.reference_path_index for m in excitation] == [0, 1, 2] - first = effects.index(excitation[0]) - assert first > 0 and effects[first-1].phase == 'clearance' - assert effects[first-1].target == witness.command == excitation[-1].target - channel = channel_for_joint(profile, witness.joint) - for motion in excitation: - assert not motion.recording and motion.command_index == channel - assert all(v == int(v) for v in motion.target) - assert all(v == witness.command[i] for i, v in enumerate(motion.target) if i != channel) - normal = next(i for i, m in enumerate(effects) if m.phase == 'zero_approach' and not m.preparation_witness_joint) - assert effects.index(excitation[-1]) < normal - # Revisit and independent validation do not re-excite or refit a frozen zero. - driver = SessionExecution(profile) - driver.zero_references[joint] = object() - driver.session._unit_index = next(i for i, unit in enumerate(driver.session._units) - if unit.task_key == task_key and unit.cycle == 1) - driver.session.phase = Phase.PREPARE - assert not any(m.preparation_witness_joint for m in driver._make_effects(witness.command)) diff --git a/src/linkerhand_calibration/test/test_prepared_resume.py b/src/linkerhand_calibration/test/test_prepared_resume.py deleted file mode 100644 index 00293ad..0000000 --- a/src/linkerhand_calibration/test/test_prepared_resume.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Checkpoint decoding/geometry replay must finish before live feedback starts.""" - -from copy import deepcopy -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.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine -from linkerhand_calibration.runtime.joint_resume import JointResume -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from runtime_host_fixture import coordinator_fixture, ready - - -@pytest.mark.parametrize("changed_reference", [False, True]) -@pytest.mark.parametrize("model,layout", [("o6", "o6_right_8"), ("l6", "l6_right_8"), - ("o12", "o12_right_16"), ("g20", "g20_right_19"), ("o30", "o30_right_18")]) -def test_resume_tick_uses_prepared_data_and_still_checks_current_reference( - tmp_path, monkeypatch, model, layout, changed_reference): - profile = load_bundled_hand_profile(layout) - transform = None - if model == "o30": - from linkerhand_calibration.core.domain.capture_plan import STAGED, CapturePlan, configure_training - from linkerhand_calibration.runtime import staged_resume - transform = lambda p: configure_training(p, STAGED) - profile = transform(profile) - fingerprint = {"kind": "fixed_base_reference_locked", "profile_id": profile.key.profile_id, - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, "protected_hashes": {"source": "a" * 64}, - "fixed_corners_by_view": {v: [[10, 10], [20, 10], [20, 20], [10, 20]] for v in profile.vision.view_names}, - "fixed_poses": {v: {"rotation_xyzw": [0, 0, 0, 1], "translation_xyz_m": [0, 0, .5]} - for v in profile.vision.view_names}, "moving_tag_poses": {}} - rows = [{"kind": "session_start", "serial_number": "OFFLINE_COORDINATOR", - "profile_id": profile.key.profile_id, "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION, - "capture_schedule_version": CalibrationEngine(profile).capture_schedule_version}, fingerprint] - if model == "o30": - rows[0]["capture_plan"] = CapturePlan.from_profile(profile).as_dict() - path = tmp_path / "previous.jsonl" - path.write_text("\n".join(json.dumps(row) for row in rows)) - staged = [] - # Actual provenance and per-unit quality replay are covered by their own - # recorded-data tests; here assert that this expensive boundary is pre-live. - monkeypatch.setattr(JointResume, "stage", lambda self, *args: staged.append(self)) - prepared_imports = [] - if model == "o30": - original_import = staged_resume.reference_import - def prepare_visits(resume, engine, records): - resume.rows = tuple(records) - staged.append(resume) - def prepare_import(resume, **kwargs): - pending = original_import(resume, **kwargs) - prepared_imports.append(pending) - return pending - monkeypatch.setattr(staged_resume, "stage_visits", prepare_visits) - monkeypatch.setattr(staged_resume, "stage_passed_visits", prepare_visits) - monkeypatch.setattr(staged_resume, "reference_import", prepare_import) - host, clock = coordinator_fixture(tmp_path, model=model, resume_raw_samples_path=path, - profile_transform=transform) - try: - assert len(staged) == 1 and not host.started and not clock.positions - assert host.joint_resume is not staged[0] - if model == "o30": - assert len(prepared_imports) == 1 - assert host._resume_checkpoint.passed_import is prepared_imports[0] - assert host._passed_reference_import is None - monkeypatch.setattr(staged_resume, "reference_import", - lambda *args: pytest.fail("historical provenance indexing in live callback")) - path.unlink() # The immutable loaded snapshot is sufficient after startup. - monkeypatch.setattr(JointResume, "stage", lambda *args: pytest.fail("geometry replay in live callback")) - ready(host, clock) - assert host.start().success - clock.now += .1 - host.receive_feedback(profile.command.names, profile.command.baseline_values, host.ports.clock_ns()) - host._current_fingerprint = deepcopy(fingerprint) - if changed_reference: - view = profile.vision.view_names[0] - host._current_fingerprint["fixed_corners_by_view"][view][0][0] += 20 - host.execution.session.phase = Phase.RESUME_VERIFY - host.tick() - assert host.execution.session.phase == (Phase.PAUSED if model == "o30" and changed_reference else Phase.PREPARE) - if model == "o30": - assert (host._passed_reference_import is prepared_imports[0]) is not changed_reference - assert host.feedback_fresh() - assert (host.joint_resume is staged[0]) is not changed_reference - assert host._resumed_count == 0 # Each old zero still needs physical verification. - finally: - host.close() - - -def test_unreadable_checkpoint_is_rejected_without_delaying_live_motion(tmp_path): - host, clock = coordinator_fixture(tmp_path, resume_raw_samples_path=tmp_path / "missing.jsonl") - try: - assert host._resume_checkpoint is None and host._resume_checkpoint_error - ready(host, clock) - assert host.start().success - clock.now += .1 - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.execution.session.phase = Phase.RESUME_VERIFY - host.tick() - assert host.execution.session.phase == Phase.PREPARE - assert host._resumed_count == 0 and not host.joint_resume.references - finally: - host.close() - - -@pytest.mark.parametrize("abort_during_import", [False, True]) -def test_large_task_import_yields_to_feedback_and_commits_only_after_fsync( - tmp_path, monkeypatch, abort_during_import): - from linkerhand_calibration.runtime import coordinator - - host, clock = coordinator_fixture(tmp_path) - try: - ready(host, clock) - assert host.start().success - clock.now += .1 - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.execution.session.phase = Phase.PREPARE - unit = host.execution.session.current_unit - key = (unit.task_key, unit.cycle, unit.direction) - rows = tuple({"kind": "import_test", "index": i} for i in range(257)) - monkeypatch.setattr(host.joint_resume, "take_task", lambda task: ((key,), rows)) - writes, events = [], [] - original = coordinator.append_jsonl_many - def write(path, batch, *, durable=True): - assert not durable and len(batch) <= 64 - # The whole transfer would exceed the one-second feedback gate; - # each bounded callback permits another real feedback reception. - clock.now += len(batch) * .01 - writes.extend(batch) - original(path, batch, durable=durable) - monkeypatch.setattr(coordinator, "append_jsonl_many", write) - original_sync = coordinator.sync_jsonl - def sync(path): - assert host._resumed_count == 0 - original_sync(path) - events.append("durable") - monkeypatch.setattr(coordinator, "sync_jsonl", sync) - host.tick() - assert host._resumed_count == 0 and len(writes) == 64 - if abort_during_import: - assert host.abort().success - host.tick() - assert host.state == "ABORTED" and host._resumed_count == 0 - assert len(writes) == 64 - else: - while host._resume_import is not None: - assert host.feedback_fresh() - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - assert writes == list(rows) and events == ["durable"] - assert host._resumed_count == 1 and host.state != "PAUSED" - finally: - monkeypatch.undo() - host.close() - - -@pytest.mark.parametrize("interruption", [None, "abort", "feedback_loss"]) -def test_zero_provenance_import_yields_and_never_authorizes_an_incomplete_zero( - tmp_path, monkeypatch, interruption): - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime import coordinator - from test_motion_reference_provenance import motion_capture - - host, clock = coordinator_fixture(tmp_path) - try: - ready(host, clock) - assert host.start().success - host.execution.session.phase = Phase.PREPARE - _, reference, _ = motion_capture(layout="o6_right_8") - motion = MotionCommand('joint_zero', host.profile.command.baseline_values, 1., - task_key=reference.task_key, zero_joints=(reference.joint,)) - # Geometry and old/new pose comparisons have their own independent - # provenance tests. Exercise the real coordinator's transfer boundary. - monkeypatch.setattr(host.branch_initialization, "ready", lambda _: True) - monkeypatch.setattr(coordinator, "build_joint_zero_reference", lambda *a, **k: reference) - monkeypatch.setattr(coordinator, "reference_branch_revisions", lambda *a, **k: {}) - monkeypatch.setattr(host, "_freeze_reference_branches", lambda _: True) - provenance = tuple({"kind": "import_source", "index": i} for i in range(257)) - monkeypatch.setattr(host.joint_resume, "take_motion_provenance_records", lambda: provenance) - assert host._lock_joint_zeros(motion) is False - assert not host.execution.zero_references and host._reference_import is not None - - writes, barriers = [], [] - original_write, original_sync = coordinator.append_jsonl_many, coordinator.sync_jsonl - def write(path, rows, *, durable=True): - assert not durable and len(rows) <= 64 - clock.now += len(rows) * .01 - writes.extend(rows) - original_write(path, rows, durable=durable) - def sync(path): - assert not host.execution.zero_references - assert writes[-1]["kind"] == "joint_zero_reference" - original_sync(path) - barriers.append("durable") - monkeypatch.setattr(coordinator, "append_jsonl_many", write) - monkeypatch.setattr(coordinator, "sync_jsonl", sync) - - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - assert len(writes) == 64 and not host.execution.zero_references - if interruption: - if interruption == "abort": - assert host.abort().success - else: - clock.now += 2. - host.tick() - assert host.state in {"ABORTED", "PAUSED"} - assert len(writes) == 64 and not barriers and not host.execution.zero_references - else: - while not host._reference_import.complete: - assert not host.execution.zero_references - host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) - host.tick() - assert barriers == ["durable"] and writes[:-1] == list(provenance) - assert host._lock_joint_zeros(motion) is True - assert host.execution.zero_references == {reference.joint: reference} - assert host._reference_import is None and host.state != "PAUSED" - finally: - monkeypatch.undo() - host.close() diff --git a/src/linkerhand_calibration/test/test_process_finalizer.py b/src/linkerhand_calibration/test/test_process_finalizer.py deleted file mode 100644 index 2773f30..0000000 --- a/src/linkerhand_calibration/test/test_process_finalizer.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from threading import Event -import time -from functools import partial -import hashlib -import tracemalloc - -import pytest - -from linkerhand_calibration.runtime.artifacts.process_finalizer import ( - isolated_finalize_profile_session, read_journal_prefix, -) - - -def sample_finalizer(*, session_dir, records, publish, phase_changed): - assert publish is False - for phase in ("fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"): - phase_changed(phase) - return tuple(row["value"] for row in records) - - -def waiting_finalizer(*, session_dir, records, publish, phase_changed): - phase_changed("entered") - time.sleep(30) - Path(session_dir, "unexpected_completion").touch() - - -def test_bounded_journal_and_process_preserve_data_and_event_order(tmp_path): - path = tmp_path / "raw.jsonl" - prefix = b'{"value": 1}\n{"value": 2}\n' - path.write_bytes(prefix + b'{"value": 999}\n') - events = [] - result = isolated_finalize_profile_session(journal_path=path, journal_size=len(prefix), - records=({"value": -1},), phase_changed=events.append, cancelled=lambda: False, - finalizer=sample_finalizer, session_dir=tmp_path) - assert result == (1, 2) - assert events == ["fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"] - timing = json.loads((tmp_path/"finalization_timing.json").read_text()) - assert timing["backend"] == "isolated_process" - assert timing["journal_prefix_bytes"] == len(prefix) - with pytest.raises(ValueError, match="incomplete"): - read_journal_prefix(path, len(prefix)-1) - with pytest.raises(ValueError, match="cannot_publish"): - isolated_finalize_profile_session(journal_path=path, journal_size=len(prefix), - records=(), phase_changed=events.append, cancelled=lambda: False, publish=True) - - -def test_cancel_terminates_child_without_waiting_for_fit_or_publication(tmp_path): - path = tmp_path/"raw.jsonl" - path.write_bytes(b'{"value": 1}\n') - entered, cancelled = Event(), Event() - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(isolated_finalize_profile_session, journal_path=path, - journal_size=path.stat().st_size, records=(), phase_changed=lambda _: entered.set(), - cancelled=cancelled.is_set, finalizer=waiting_finalizer, session_dir=tmp_path) - assert entered.wait(10) - cancelled.set() - with pytest.raises(ValueError, match="operator_abort"): - future.result(timeout=3) - assert not (tmp_path/"unexpected_completion").exists() - - -def test_child_invalid_input_is_reported_without_a_result(tmp_path): - path = tmp_path/"raw.jsonl" - path.write_bytes(b'{"value": 1}') - with pytest.raises(RuntimeError, match="incomplete_finalization_journal_prefix"): - isolated_finalize_profile_session(journal_path=path, journal_size=path.stat().st_size, - records=(), phase_changed=lambda _: None, cancelled=lambda: False, - finalizer=sample_finalizer, session_dir=tmp_path) - - -def test_large_journal_has_bounded_raw_buffer_memory(tmp_path): - """Large raw evidence must not be duplicated when the fit process starts.""" - path = tmp_path / "large.jsonl" - line = b'{"value": 1}' + b" " * (256 * 1024) + b"\n" - with path.open("wb") as stream: - for _ in range(64): - stream.write(line) - tracemalloc.start() - try: - rows = read_journal_prefix(path, path.stat().st_size) - _, peak = tracemalloc.get_traced_memory() - finally: - tracemalloc.stop() - assert rows == ({"value": 1},) * 64 - assert peak < 4 * 1024 * 1024 - - -def test_finalizer_transparently_reads_indexed_compressed_prefix(tmp_path): - from linkerhand_calibration.core.artifacts.evidence_journal import initialize_evidence_journal - from linkerhand_calibration.storage import append_jsonl, append_jsonl_many - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=64) - rows = [{"value": 1}, {"value": 2, "evidence": list(range(1000))}] - append_jsonl(path, rows[0]) - append_jsonl_many(path, rows[1:]) - assert read_journal_prefix(path, path.stat().st_size) == tuple(rows) - - -@pytest.mark.parametrize("data,size,error", [ - (b'{"value": 1}\n', 100, "incomplete"), - (b'{"value": 1}\n[]\n', None, "invalid_finalization_journal_rows"), -]) -def test_journal_rejects_short_file_or_nonobject_record(tmp_path, data, size, error): - path = tmp_path / "invalid.jsonl" - path.write_bytes(data) - with pytest.raises(ValueError, match=error): - read_journal_prefix(path, len(data) if size is None else size) - - -def skip_standard_loader(_path): - pass - - -def test_real_finalizer_stages_in_child_and_only_controller_publishes(tmp_path): - from measured_capture_fixture import measured_capture - from linkerhand_calibration.runtime.artifacts.controller import FinalizationController, FinalizationInputs - from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session - from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper - profile, source, records, _ = measured_capture(tmp_path, layout="o6_right_8") - session = tmp_path/"session" - session.mkdir() - raw = session/"raw_samples.jsonl" - raw.write_text("".join(json.dumps(row)+"\n" for row in records)) - hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields} - hashes["source_urdf_sha256"] = hashlib.sha256(source.read_bytes()).hexdigest() - controller = FinalizationController(profile, session, isolated=True, - finalizer=partial(finalize_profile_session, standard_loader=skip_standard_loader)) - phases, authorizations = [], [] - def authorize(): - assert phases == ["fit_complete", "holdout_complete", "artifacts_built", "urdf_validated"] - assert not (tmp_path/profile.artifacts.publication_pointer).exists() - authorizations.append(True) - try: - controller.start(FinalizationInputs(session, "SYNTHETIC_PROCESS_ONLY", source, hashes, - tuple(records), journal_path=raw, journal_size=raw.stat().st_size)) - deadline = time.monotonic()+60 - result = None - while result is None and time.monotonic() < deadline: - result = controller.poll(phases.append, authorize) - time.sleep(.01) - assert result is not None - assert authorizations == [True] - assert controller.poll(phases.append, authorize) is None - load_unified_mapper(session/"release_manifest.json") - finally: - controller.close() diff --git a/src/linkerhand_calibration/test/test_production_image_motion.py b/src/linkerhand_calibration/test/test_production_image_motion.py deleted file mode 100644 index 9f741a3..0000000 --- a/src/linkerhand_calibration/test/test_production_image_motion.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Production image models preserve independent images and frozen geometry.""" -from dataclasses import asdict, replace -import json -import gzip -from pathlib import Path - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -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.motion_evidence import MotionRelation -from test_motion_image_diagnostics import frames, RELATIONS - - -def recorded_image_frames(rows): - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionEvidenceFrame, RoleCandidates - from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageMotionFrame, ImageRoleObservation - from linkerhand_calibration.core.geometry.tag_pose.types import SquareTagPose - - source = [] - for raw in rows: - roles = tuple(RoleCandidates(item["role"], tuple(SquareTagPose( - tuple(p["quaternion_xyzw"]), tuple(p["translation_xyz_m"]), p["reprojection_error_px"]) - for p in item["candidates"]), item["frozen"]) for item in raw["evidence"]["roles"]) - source.append(ImageMotionFrame(MotionEvidenceFrame(raw["evidence"]["stamp_ns"], roles), - tuple(tuple(v) for v in raw["camera_matrix"]), tuple(ImageRoleObservation( - o["role"], o["tag_size_m"], tuple(tuple(p) for p in o["corners_xy"])) for o in raw["observations"]))) - return tuple(source) - - -@pytest.fixture(scope="module") -def resolved(): - source = frames(depth_error=.008) - result = resolve_image_motion(source, RELATIONS) - assert result.resolved, result - return source, result - - -def test_noisy_pnp_depth_is_replaced_by_current_image_geometry(resolved): - source, result = resolved - assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns) - clean = frames() - for frame, reference in zip(source[::5], clean[::5]): - selected = select_image_motion_frame(result.model, frame) - assert selected.resolved, selected - estimate = dict((p.role, p.pose) for p in selected.selections)["moving"] - actual = reference.evidence.roles[1].candidates[0] - assert np.linalg.norm(np.subtract(estimate.translation_xyz_m, actual.translation_xyz_m)) < 1e-6 - assert (Rotation.from_quat(estimate.quaternion_xyzw).inv() - * Rotation.from_quat(actual.quaternion_xyzw)).magnitude() < 1e-5 - serialized = asdict(result.model) - json.dumps(serialized, allow_nan=False) - assert image_motion_model_from_dict(serialized) == result.model - - -def test_frozen_geometry_does_not_absorb_tag_slide(resolved): - source, result = resolved - before = asdict(result.model) - frame = source[-1] - shifted = replace(frame.observations[1], corners_xy=tuple((x + 12, y) for x, y in frame.observations[1].corners_xy)) - check = select_image_motion_frame(result.model, replace(frame, observations=(frame.observations[0], shifted))) - assert not check.resolved - assert asdict(result.model) == before - - -def test_current_roots_camera_and_source_identity_are_required(resolved): - source, result = resolved - frame = source[-1] - invalid = replace(frame, camera_matrix=((1700., 0., 640.), *frame.camera_matrix[1:])) - assert not select_image_motion_frame(result.model, invalid).resolved - unfrozen = replace(frame.evidence.roles[0], frozen=False) - invalid = replace(frame, evidence=replace(frame.evidence, roles=(unfrozen, *frame.evidence.roles[1:]))) - assert not select_image_motion_frame(result.model, invalid).resolved - changed = asdict(result.model) - changed["validation_stamps_ns"] = changed["training_stamps_ns"] - with pytest.raises(ValueError, match="source_overlap"): - image_motion_model_from_dict(changed) - changed = asdict(result.model) - changed["maximum_reprojection_error_px"] = 5. - with pytest.raises(ValueError, match="quality_policy"): - image_motion_model_from_dict(changed) - - -def test_no_rotation_cannot_authorize_model(): - source = frames() - still = tuple(replace(source[0], evidence=replace(source[0].evidence, - stamp_ns=f.evidence.stamp_ns)) for f in source) - result = resolve_image_motion(still, RELATIONS) - assert not result.resolved - assert result.model is None - - -def test_frozen_prediction_does_not_refit_angle_or_geometry(resolved, monkeypatch): - from linkerhand_calibration.core.geometry.tag_pose import image_motion_projection as projection - source, result = resolved - # Duplicate pixels with a new identity are solely a synthetic truth fixture. - frame = replace(source[-1], evidence=replace(source[-1].evidence, - stamp_ns=max(result.model.source_stamps_ns)+1)) - angles = tuple(a for _, a in select_image_motion_frame(result.model, frame).angles_rad) - before = asdict(result.model) - monkeypatch.setattr(projection, "least_squares", lambda *a, **k: - pytest.fail("Frozen prediction must not estimate validation angles")) - quality = projection.image_motion_prediction_errors(result.model, frame, angles) - assert max(q.maximum_frame_rms_px for q in quality) < 1e-5 - wrong = projection.image_motion_prediction_errors(result.model, frame, np.asarray(angles)+.2) - assert max(q.maximum_frame_rms_px for q in wrong) > 1.5 - with pytest.raises(ValueError, match="prediction_training_overlap"): - projection.image_motion_prediction_errors(result.model, source[-1], angles) - with pytest.raises(ValueError, match="prediction_training_overlap"): - projection.image_motion_prediction_errors(result.model, frame, angles, - dependency_stamps_ns=(frame.evidence.stamp_ns,)) - with pytest.raises(ValueError, match="prediction_angles_invalid"): - projection.image_motion_prediction_errors(result.model, frame, (float('nan'),)) - assert asdict(result.model) == before - - -def test_alternatives_within_image_resolution_are_not_ranked_as_truth(): - source = frames(alternatives=True) - result = resolve_image_motion(source, RELATIONS) - assert len(result.hypotheses) == 2 - # A near-frontal noiseless plane still admits distinct shallow-tilt models - # inside the public pixel tolerance. A lower score alone cannot resolve it. - assert not result.resolved and result.reason == "image_motion_families_not_distinguishable" - assert result.model is None - - -def test_serial_graph_and_previously_frozen_parent_share_same_solver(): - source = frames(chain=True, depth_error=.001) - chain = (*RELATIONS, MotionRelation("distal", "moving", "terminal")) - result = resolve_image_motion(source, tuple(reversed(chain))) - assert result.resolved, result - final = select_image_motion_frame(result.model, source[-1]) - assert final.resolved and len(final.angles_rad) == 2 - # Roots may themselves move; use each image's already selected parent. - clean = frames(chain=True) - source = tuple(replace(f, evidence=replace(f.evidence, roles=tuple( - replace(r, frozen=True) if r.role == "moving" else r for r in f.evidence.roles))) for f in clean) - distal = resolve_image_motion(source, (chain[1],)) - assert distal.resolved, distal - - -def test_motion_stratified_evidence_requires_mean_and_rank_improvement(): - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import _comparison - angles = np.linspace(0, 1.2, 72)[:, None] - baseline = np.full(72, .3) - assert _comparison(baseline, baseline + .02, angles, .03) == 1. - assert _comparison(baseline, baseline + .2, angles, .03) < .001 - alternative = baseline.copy() - alternative[-2:] += 20 - assert _comparison(baseline, alternative, angles, .03) > .01 - - -def test_recorded_preparation_only_freeze_predicts_both_later_directions(): - fixture = Path(__file__).parent / "fixtures" / "o6_image_motion_200044.json.gz" - payload = json.loads(gzip.decompress(fixture.read_bytes())) - assert payload["source_sha256"] == "52f9ed7510fc58a4271532a2dc13a678f2e4aa2f1dc3153bf749fcf377cb23c5" - models, validated = {}, [] - for stage in payload["stages"]: - source = recorded_image_frames(stage["frames"]) - metadata = stage["metadata"] - if metadata["sample_phase"] == "zero_approach": - result = resolve_image_motion(source, tuple(MotionRelation(**r) for r in stage["relations"])) - assert result.resolved, (metadata, result) - models[metadata["view"]] = result.model - else: - assert metadata["cycle"] == 0 - model = models[metadata["view"]] - frozen = asdict(model) - assert set(f.evidence.stamp_ns for f in source).isdisjoint(model.source_stamps_ns) - for frame in source: - check = select_image_motion_frame(model, frame) - assert check.resolved, (metadata, check) - assert max(q.rms_px for q in check.quality) < .5 - assert asdict(model) == frozen - validated.append((metadata["view"], metadata["direction"])) - assert set(validated) == {(view, direction) for view in ("front", "top") - for direction in ("increasing", "decreasing")} - - -def test_recorded_pinky_alternatives_converge_within_original_budget(): - """The side-view preparation previously exhausted 150 steps in two families.""" - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ImageMotionParameters - - fixture = Path(__file__).parent / "fixtures" / "o6_pinky_preparation_144514.json.gz" - payload = json.loads(gzip.decompress(fixture.read_bytes())) - assert payload["source_sha256"] == "77d3337dfd5384409fcf4352e76c925b48938912b4d47c5341cc2c92d8cef332" - source = recorded_image_frames(payload["frames"]) - assert len(source) == 120 - limits = ImageMotionParameters() - assert limits.maximum_solver_evaluations == payload["recorded_exhausted_solver_evaluations"] == 150 - result = resolve_image_motion(source, tuple(MotionRelation(**r) for r in payload["relations"])) - assert result.resolved, result - assert len(result.hypotheses) == 4 - for hypothesis in result.hypotheses: - assert hypothesis.converged and hypothesis.training_solver_status > 0 - assert 0 < hypothesis.training_solver_evaluations <= 150 - assert len(result.model.training_stamps_ns) == len(result.model.validation_stamps_ns) == 60 - assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns) - assert result.model.maximum_reprojection_error_px == 1.5 - - -def test_hinge_pivot_parameterization_is_continuous_across_axis_helper_boundary(): - from linkerhand_calibration.core.geometry.tag_pose.motion_image_solver import ImageHingeBundle - source = frames() - poses = {role.role: tuple(frame.evidence.roles[index].candidates[0] for frame in source) - for index, role in enumerate(source[0].evidence.roles)} - bundle = ImageHingeBundle(source, RELATIONS, poses) - first, second = bundle.initial.copy(), bundle.initial.copy() - first[5:7] = second[5:7] = [.02, .01] - first[0], second[0] = np.arcsin(.9) - 1e-8, np.arcsin(.9) + 1e-8 - axis_a, _, pivot_a, _ = bundle.geometry_values(first, 0) - axis_b, _, pivot_b, _ = bundle.geometry_values(second, 0) - assert np.linalg.norm(pivot_b - pivot_a) < 1e-8 - assert abs(axis_a @ pivot_a) < 1e-12 - assert abs(axis_b @ pivot_b) < 1e-12 diff --git a/src/linkerhand_calibration/test/test_profile_finalization.py b/src/linkerhand_calibration/test/test_profile_finalization.py deleted file mode 100644 index c8e3345..0000000 --- a/src/linkerhand_calibration/test/test_profile_finalization.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Production finalizer recovery using an independent elementary FK generator. - -The virtual URDF preserves the vendor's geometry but uses explicit synthetic -limits. It is not evidence that a real hand or its original limits passed. -""" - -from dataclasses import replace -from pathlib import Path -import hashlib -import xml.etree.ElementTree as ET - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.extrinsics import matrix_payload -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.domain.profile import ProfileKey -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session - - -PACKAGE = Path(__file__).resolve().parents[1] - - -def rigid(rpy, xyz): - matrix = np.eye(4) - matrix[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() - matrix[:3, 3] = xyz - return matrix - - -def virtual_capture(tmp_path, layout, *, include_baseline=False, profile_transform=None): - base_layout = "l6_right_8" if layout == "virtual_reordered" else layout - profile = load_bundled_hand_profile(base_layout) - # This synthetic fixture generates the original interleaved partitions. - profile = replace(profile, acquisition=replace(profile.acquisition, - command_capture_mode="interleaved", steady_extra_training_nodes={})) - # This independent FK sensor fixture supplies measured 3D poses, not - # corner images or IPPE candidates. Its synthetic observation profile - # therefore has no image-branch reconstruction stage. - profile = replace(profile, measurement=replace(profile.measurement, - candidate_selection_tasks=frozenset())) - # This fixture preserves the legacy v2 acceptance contract. New measured - # behavior is exercised separately using include_baseline and real v3 refs. - profile = replace(profile, artifacts=replace(profile.artifacts, output_schema_version=2, - directional_command_joints=profile.artifacts.directional_command_joints if include_baseline else frozenset())) - if include_baseline: - profile = replace(profile, artifacts=replace(profile.artifacts, output_schema_version=3)) - if profile_transform is not None: - profile = profile_transform(profile) - if layout == "virtual_reordered": - profile = replace(profile, key=ProfileKey("VIRTUAL", "right", layout), - namespace="/virtual_calibration", - artifacts=replace(profile.artifacts, output_schema_version=2), - motion=replace(profile.motion, tasks=tuple(reversed(profile.motion.tasks)))) - filename = {"l6_right_8": "l6_right/linkerhand_l6v3.1_right.urdf", - "o6_right_8": "o6_right/linkerhand_o6_right.urdf", - "g20_right_19": "g20_right/linkerhand_g20_right.urdf", - "o12_right_16": "o12_right/linkerhand_o12_t3_right-0703.urdf"}[base_layout] - original = PACKAGE / "urdf" / filename - if not original.exists(): - original = next((PACKAGE / "urdf/o6_right").glob("*.urdf")) - tree = ET.parse(original) - root = tree.getroot() - root.set("name", "virtual_geometry_acceptance_not_hardware") - joints = {node.get("name"): node for node in root.findall("joint")} - for node in joints.values(): - limit = node.find("limit") - if limit is not None and node.get("type") == "revolute": - limit.set("lower", "-10" if node.find("mimic") is not None else "-2") - limit.set("upper", "10" if node.find("mimic") is not None else "2") - for mesh in root.iter("mesh"): - filename = mesh.get("filename") - if not filename.startswith(("package://", "file://", "/")): - mesh.set("filename", (original.parent / filename).resolve().as_uri()) - source = tmp_path / "virtual_source.urdf" - tree.write(source) - parents = {node.find("child").get("link"): name for name, node in joints.items()} - base = rigid((0.11, -0.08, 0.17), (0.11, -0.04, 0.72)) - base_tag = rigid((0.2, -0.1, 0.3), (0.003, 0.008, 0.055)) - tags = {name: rigid((0.12, -0.09, 0.2), (0.009, -0.025, 0.016)) - for name in profile.measurement.measurements} - # Independent fixture declarations: one actual mount per physical Tag, - # including Tags reused by several measurement tasks. - native_tag_joint = {"thumb_cmc": "thumb_cmc_pitch", "thumb_mcp": "thumb_mcp", - "thumb_dip": "thumb_dip", "thumb_yaw": "thumb_cmc_yaw", - "middle_roll": "middle_mcp_roll", "index_roll": "index_mcp_roll", - "pinky_mcp": "pinky_mcp_pitch", "pinky_pip": "pinky_pip", "pinky_dip": "pinky_dip", - "middle_pip": "middle_pip", "middle_dip": "middle_dip", "index_pip": "index_pip", "index_dip": "index_dip"} - native_tag_joint.update(thumb_ip="thumb_ip", ring_roll="ring_mcp_roll", pinky_roll="pinky_mcp_roll", - ring_pip="ring_pip", ring_dip="ring_dip") - if base_layout == "o6_right_8": - # Confirmed physical layout: top ID7 and front ID2 share the distal - # segment, even though only yaw is swept while observing ID7. - native_tag_joint["thumb_yaw"] = "thumb_ip" - direct = profile.zero.direct_zero_joints - offsets = {name: (0.04 if name.endswith(("roll", "yaw")) else -0.02 if "thumb" in name else 0.02) - for name in direct} - for target, donor in profile.zero.transferred_zero_sources.items(): - offsets[target] = offsets[donor] - def elementary_fk(joint, angles): - # Deliberately independent of UrdfKinematicModel and patch helpers. - node = joints[joint] - parent = parents.get(node.find("parent").get("link")) - transform = np.eye(4) if parent is None else elementary_fk(parent, angles) - origin = node.find("origin") - if origin is not None: - transform = transform @ rigid([float(v) for v in origin.get("rpy", "0 0 0").split()], - [float(v) for v in origin.get("xyz", "0 0 0").split()]) - if node.get("type") != "fixed": - axis = np.asarray([float(v) for v in node.find("axis").get("xyz").split()]) - motion = np.eye(4) - motion[:3, :3] = Rotation.from_rotvec(axis/np.linalg.norm(axis)*angles[joint]).as_matrix() - transform = transform @ motion - return transform - records = [] - for task in profile.motion.tasks: - for cycle in range(4): - for direction in ("increasing", "decreasing"): - codes = (np.linspace(0, 255, 81).round().astype(int) if profile.command.unit == "u8" - else np.linspace(min(task.start_value, task.end_value), max(task.start_value, task.end_value), 81)) - if include_baseline: - from linkerhand_calibration.core.domain.sampling import command_nodes - codes = np.unique(np.r_[codes, command_nodes(profile, task), command_nodes(profile, task, holdout=True)]) - if direction == "decreasing": - codes = codes[::-1] - for index, code in enumerate(codes): - state = list(profile.command.baseline_values) - for channel, value in task.auxiliary_commands: - state[channel] = value - state[task.command_index] = float(code) - angles = {name: offsets.get(name, 0) + ((profile.command.baseline_values[channel]-state[channel])/255 * - (0.65 if "thumb" in name and "pitch" in name else 1.05) if profile.command.unit == "u8" - else profile.command.joint_directions[channel]*state[channel]*0.7) - for name, channel in profile.command.command_index_by_joint.items()} - def passive_angle(name): - if name in angles: - return angles[name] - mimic = joints[name].find("mimic") - angles[name] = float(mimic.get("multiplier", "1"))*passive_angle(mimic.get("joint")) + float(mimic.get("offset", "0")) - return angles[name] - for name in profile.zero.passive_joints: - passive_angle(name) - measurements = [(name, name, False) for name in task.joints] - measurements += [(name, profile.measurement.cross_view_sources[name], True) - for name in task.joints if name in profile.measurement.cross_view_sources] - for name, spec_name, secondary in measurements: - spec = profile.measurement.measurements[spec_name] - if base_layout in {"o12_right_16", "g20_right_19"}: - def tag_pose(role): - joint = native_tag_joint.get(role) - return base @ base_tag if joint is None else base @ elementary_fk(joint, angles) @ tags[joint] - parent, child = tag_pose(spec.parent_role), tag_pose(spec.child_role) - else: - child = base @ elementary_fk(name, angles) @ tags[name] - parent_joint = profile.zero.mimic_source_by_joint.get(name) - parent = base @ base_tag if parent_joint is None else base @ elementary_fk(parent_joint, angles) @ tags[parent_joint] - relative = np.linalg.inv(parent) @ child - records.append({"kind": "joint_sample", "observation_joint" if secondary else "joint": name, "task_name": task.key, - "view": spec.view, "sample_id": f"{spec.view}:{task.key}:{cycle}:{direction}:{index}", - "cycle": cycle, "direction": direction, "attempt": 1, f"feedback_{profile.command.unit}": float(code), - f"command_{profile.command.unit}": float(code), f"command_vector_{profile.command.unit}": list(state), - f"state_{profile.command.unit}": state, "relative_quaternion_xyzw": Rotation.from_matrix(relative[:3, :3]).as_quat().tolist(), - "image_relative_xy_px": [30+100*np.cos(angles[name]+.23), 20+100*np.sin(angles[name]+.23)], - "relative_translation_xyz_m": relative[:3, 3].tolist(), - "parent_pose_common": matrix_payload(parent), "child_pose_common": matrix_payload(child), - "view_normal_common_xyz": [0.577350269]*3, "camera_center_common_xyz_m": [0, 0, -0.5]}) - # Independent sensor observations at the requested steady grid, using the - # same elementary FK truth above, not the fitted calibration as truth. - from linkerhand_calibration.runtime.engine import CalibrationEngine - from linkerhand_calibration.runtime.steady import steady_targets - steady = [] - for unit in CalibrationEngine(profile).scan_units(): - samples = [r for r in records if (r["task_name"], r["cycle"], r["direction"]) - == (unit.task_key, unit.cycle, unit.direction)] - for index, target in enumerate(steady_targets(profile, unit)): - matched = [r for r in samples if abs(r[f"command_{profile.command.unit}"]-target) < 1e-8] - assert matched - for row in matched: - for frame in range(3): - steady.append({**row, "sample_id": row["sample_id"]+f":steady:{frame}", - "sample_phase": "steady", "steady_index": index, "steady_target": target}) - records.extend(steady) - return profile, source, records, offsets - - -@pytest.mark.parametrize("layout", ["l6_right_8", "o6_right_8", "virtual_reordered", "o12_right_16", "g20_right_19"]) -def test_profile_only_finalizer_recovers_geometry_and_serialized_urdf(tmp_path, layout): - profile, source, records, expected = virtual_capture(tmp_path, layout) - if layout == "virtual_reordered": - # The new hand is loaded through the product's real YAML contract; - # there is no Python registry entry or model-specific engine binding. - import yaml - from linkerhand_calibration.profiles import dump_hand_profile - from linkerhand_calibration.product import load_product_config - path = tmp_path/"new_hand.yaml" - path.write_text(dump_hand_profile(profile)) - product = yaml.safe_load((PACKAGE/"config/l6_right_product.yaml").read_text()) - product.update(profile_id=profile.key.profile_id, model=profile.key.model, - namespace=profile.namespace, tag_layout=profile.key.layout, - profile_config=str(path), profile_config_sha256=hashlib.sha256(path.read_bytes()).hexdigest()) - product["artifacts"].update(source_urdf=str(source), source_urdf_sha256=hashlib.sha256(source.read_bytes()).hexdigest()) - product_path = tmp_path/"new_hand_product.yaml" - product_path.write_text(yaml.safe_dump(product)) - profile = load_product_config(product_path, workspace=PACKAGE.parents[1], check_can=False).calibration_contract.typed_profile - from engine_capture_fixture import collect_with_engine - from linkerhand_calibration.runtime.session import CalibrationPhase as Phase - execution, records = collect_with_engine(profile, records) - phase_events = { - "fit_complete": execution.session.fit_complete, - "holdout_complete": lambda: execution.session.holdout_complete(passed=True), - "artifacts_built": execution.session.artifacts_built, - "urdf_validated": lambda: execution.session.urdf_validated(passed=True), - } - session = tmp_path / "session" - loaded = [] - hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields} - hashes["source_urdf_sha256"] = hashlib.sha256(source.read_bytes()).hexdigest() - payload, fit, correction = finalize_profile_session(profile=profile, session_dir=session, - serial_number="VIRTUAL_ONLY", source_urdf=source, protected_inputs=hashes, - records=records, standard_loader=lambda path: loaded.append(path), - phase_changed=lambda event: phase_events[event]()) - assert execution.session.phase == Phase.PUBLISH - execution.session.published() - assert loaded - assert (tmp_path / profile.artifacts.publication_pointer).resolve() == session - assert payload["format"] == "unified_calibration_v2" - assert payload["schema_version"] == 2 - assert (session / "calibration_report.json").is_file() - assert "quality" not in payload - for row in payload["joints"].values(): - assert "command_to_rad" not in row - assert set(row) == {"sdk_channel", "angle_rad"} | ({"input_values"} if profile.command.unit == "rad" else set()) - if profile.command.unit == "u8": - assert len(row["angle_rad"]) == 256 - if payload["schema_version"] == 7: - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.artifacts import validate_o12_runtime_payload - validate_o12_runtime_payload(payload) - if payload["schema_version"] == 4: - from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import validate_compact_payload - validate_compact_payload(payload) - for name, value in expected.items(): - assert fit.zero_offsets_rad[name] == pytest.approx(value, abs=0.004) - assert correction.path.is_file() diff --git a/src/linkerhand_calibration/test/test_profile_loader.py b/src/linkerhand_calibration/test/test_profile_loader.py deleted file mode 100644 index d357e87..0000000 --- a/src/linkerhand_calibration/test/test_profile_loader.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import pytest -import yaml - -from linkerhand_calibration.profiles import load_hand_profile -from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry -from linkerhand_calibration.profiles import dump_hand_profile -from linkerhand_calibration.runtime import CalibrationEngine -from linkerhand_calibration.profiles.loader import load_bundled_hand_profile - - -@pytest.mark.parametrize("section,key", [(None, "commmand"), ("command", "maximum_velocty"), - ("motion", "resume_verfication_waypoints"), ("zero", "cad_frozen_joint")]) -def test_profile_typo_is_rejected_before_hardware(tmp_path, section, key): - payload = yaml.safe_load(dump_hand_profile(load_bundled_hand_profile("l6_right_8"))) - (payload if section is None else payload[section])[key] = [] - path = tmp_path/"invalid.yaml" - path.write_text(yaml.safe_dump(payload)) - with pytest.raises(ValueError, match="unknown"): - load_hand_profile(path) - - -def test_new_model_loads_without_model_python_state_machine(tmp_path: Path) -> None: - profile_file = tmp_path / "virtual.yaml" - profile_file.write_text( - """schema_version: 1 -profile_id: VIRTUAL/right/one_joint/v1 -namespace: /virtual_calibration -sdk_adapter: legacy_byte_sdk -command: - names: [finger] - baseline_u8: [255] - command_index_by_joint: {finger_joint: 0} - urdf_joint_by_joint: {finger_joint: finger_joint} -vision: - common_frame: camera - extrinsic_reference_view: camera - views: - - name: camera - tags: - - {role: base, id: 0, fixed_reference: true} - - {role: finger, id: 1} -motion: - tasks: - - key: finger_sweep - view: camera - command_index: 0 - joints: [finger_joint] - start_u8: 255 - end_u8: 0 - formal_speed_u8: 40 -measurement: - measurements: - finger_joint: {kind: rotation, view: camera, parent_role: base, child_role: finger} -zero: - active_joints: [finger_joint] - passive_joints: [] - direct_zero_joints: [finger_joint] - axis_joints: [finger_joint] - mechanical_endpoint_joints: [] - post_solve_endpoint_joints: [] - mimic_source_by_joint: {} - cad_frozen_joints: [] -quality: - training_cycles: [0, 1, 2] - holdout_cycle: 3 - hard_threshold_keys: [holdout] - isolated_holdout: true -scope: - default_scope: full - calibrate_joints: {full: [finger_joint]} - frozen_joints: {full: []} -artifacts: - output_schema_version: 1 - calibration_filename: calibration.json - corrected_urdf_filename: corrected.urdf - protected_input_fields: [source_urdf_sha256] - publish_corrected_urdf: true -acquisition: {policy_version: unified_engine_v8_all_view_images} -urdf: - authorized_fields: - finger_joint: [origin.rpy, limit.lower, limit.upper] -joint_coverage: {finger_joint: measured_static_dynamic} -""", - encoding="utf-8", - ) - profile = load_hand_profile(profile_file) - assert profile.sdk_adapter == "legacy_byte_sdk" - assert profile.urdf_authorized_fields["finger_joint"] == { - "origin.rpy", "limit.lower", "limit.upper" - } - assert len(CalibrationEngine(profile).scan_units()) == 8 - - -def test_all_product_profiles_have_lossless_declarative_contracts( - tmp_path: Path, -) -> None: - expected = {"g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16"} - checked = set() - for registered in get_default_registry(): - profile = registered.profile - if profile.key.layout not in expected: - continue - assert profile == load_bundled_hand_profile(profile.key.layout) - path = tmp_path / f"{profile.key.layout}.yaml" - path.write_text(dump_hand_profile(profile), encoding="utf-8") - assert load_hand_profile(path) == profile - checked.add(profile.key.layout) - assert checked == expected - - -def test_product_loading_a_new_profile_does_not_construct_model_registry(tmp_path, monkeypatch): - import hashlib - import yaml - from linkerhand_calibration import product - root = Path(__file__).resolve().parents[1] - profile_data = yaml.safe_load((root / "config/profiles/g20_right_19.yaml").read_text()) - profile_data["profile_id"] = "VIRTUAL/right/new_configuration_only/v1" - profile_data["namespace"] = "/virtual_calibration" - profile_path = tmp_path / "virtual.yaml" - profile_path.write_text(yaml.safe_dump(profile_data)) - data = yaml.safe_load((root / "config/g20_right_product.yaml").read_text()) - data.update(profile_id=profile_data["profile_id"], model="VIRTUAL", - tag_layout="new_configuration_only", profile_config=str(profile_path), - profile_config_sha256=hashlib.sha256(profile_path.read_bytes()).hexdigest()) - path = tmp_path / "product.yaml" - path.write_text(yaml.safe_dump(data)) - def forbidden(): - raise AssertionError("declarative product must not resolve a Python model") - monkeypatch.setattr(product, "get_default_registry", forbidden) - loaded = product.load_product_config(path, workspace=root.parents[1], check_can=False) - assert loaded.model == "VIRTUAL" - assert loaded.calibration_contract.typed_profile.key.layout == "new_configuration_only" - # This is the input-contract test, deliberately not an end-to-end claim. diff --git a/src/linkerhand_calibration/test/test_progress_status.py b/src/linkerhand_calibration/test/test_progress_status.py deleted file mode 100644 index c9b750c..0000000 --- a/src/linkerhand_calibration/test/test_progress_status.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations -import pytest - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry -from linkerhand_calibration.runtime import ( - CalibrationPhase, - CalibrationSession, - ResumeDecision, - SweepQuality, -) - - -def _profile(layout: str): - return next( - item.profile for item in get_default_registry() - if item.profile.key.layout == layout - ) - - -@pytest.mark.parametrize("reason", ["fixed_reference_moved", "feedback_stale", "hardware_fault", - "duplicate_controller", "physical_range_exceeded", "protected_camera_model_changed"]) -def test_live_failures_have_specific_chinese_reasons_and_suggestions(reason): - from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh - code, message, suggestion = reason_zh({"reason": reason+":details"}, model_name="VIRTUAL") - assert code != "CALIBRATION-500" and message and suggestion - session = CalibrationSession(_profile("l6_right_8")) - session.pause(code, message, {"raw_reason": reason}, suggestion=suggestion) - assert session.status().pause.suggestion == suggestion - - -def test_one_session_machine_reports_task_tags_and_locked_warning() -> None: - session = CalibrationSession(_profile("o12_right_16")) - session.device_ready() - session.start(resume_requested=True) - session.baseline_complete() - session.reference_locked() - session.resume_checked(ResumeDecision(False, "基准变化,已重新采集")) - session.preparation_complete() - while session.phase == CalibrationPhase.MAPPING_PROBE: - session.mapping_probe_complete() - status = session.status() - assert status.state == "SWEEP" - assert status.reference_locked - assert "请勿移动" in status.reference_message - assert status.task.required_tag_ids_by_view - - -def test_one_same_speed_rescan_then_pause_with_reason() -> None: - session = CalibrationSession(_profile("l6_right_8")) - session.device_ready() - session.start() - session.baseline_complete() - session.reference_locked() - session.preparation_complete() - failed = SweepQuality(False, ("frames=20",), (), {}) - session.sweep_complete() - session.evaluation_complete(failed) - assert session.phase == CalibrationPhase.PREPARE - session.preparation_complete() - assert session.phase == CalibrationPhase.RESCAN - session.sweep_complete() - session.evaluation_complete(failed) - assert session.phase == CalibrationPhase.PAUSED - assert session.status().pause.code == "sweep_quality_failed" - - -def test_physical_mapping_probe_occurs_after_each_task_prepare() -> None: - session = CalibrationSession(_profile("o12_right_16")) - session.device_ready() - session.start() - session.baseline_complete() - session.reference_locked() - session.preparation_complete() - assert session.phase == CalibrationPhase.MAPPING_PROBE - session.mapping_probe_complete() - passed = SweepQuality(True, (), (), {}) - first_task = session.current_unit.task_key - while session.current_unit is not None and session.current_unit.task_key == first_task: - if session.phase == CalibrationPhase.PREPARE: - session.preparation_complete() - assert session.phase == CalibrationPhase.SWEEP - session.sweep_complete() - session.evaluation_complete(passed) - assert session.phase == CalibrationPhase.PREPARE - session.preparation_complete() - assert session.phase == CalibrationPhase.MAPPING_PROBE - - -def test_resume_skips_only_complete_units_and_still_prepares_current_task() -> None: - session = CalibrationSession(_profile("l6_right_8")) - first_task = session.current_unit.task_key - completed = tuple( - (unit.task_key, unit.cycle, unit.direction) - for unit in session.engine.scan_units() - if unit.task_key == first_task - ) - session.device_ready() - session.start(resume_requested=True) - session.baseline_complete() - session.reference_locked() - session.resume_checked( - ResumeDecision(True, "fixed_reference_verified; pending_joint_zero") - ) - assert session.current_unit.task_key == first_task - session.authorize_task_reuse(completed) - assert session.phase == CalibrationPhase.PREPARE - assert session.current_unit.task_key != first_task diff --git a/src/linkerhand_calibration/test/test_raw_draft.py b/src/linkerhand_calibration/test/test_raw_draft.py new file mode 100644 index 0000000..dee2bba --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_draft.py @@ -0,0 +1,552 @@ +"""Draft precision may fail; source identity, zero semantics and files may not.""" +from dataclasses import asdict +from pathlib import Path +from types import SimpleNamespace +import json +import xml.etree.ElementTree as ET + +import numpy as np +import pytest + +from linkerhand_calibration.profiles import load_bundled_hand_profile +from linkerhand_calibration.core.domain.result import JointMapping +from linkerhand_calibration.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 linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel +from linkerhand_calibration.core.urdf.limits import MotionRangeEvidence +from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan +from linkerhand_calibration.runtime.engine import CalibrationEngine +from linkerhand_calibration.runtime.raw_completion import file_digest +from linkerhand_calibration.runtime.artifacts.raw_joint_draft import ( + DRAFT_FORMAT, capture_profile_for_draft, load_training_initialization, read_draft_plan, +) +from linkerhand_calibration.runtime.artifacts.raw_joint_report import prepare_raw_motion_ranges +from linkerhand_calibration.runtime.artifacts.serializers.unified_v1 import _mapping +from linkerhand_calibration.runtime.artifacts.urdf_from_json import serialize_urdf_correction + + +@pytest.fixture +def inputs(): + package = Path(__file__).resolve().parents[1] + profile = load_bundled_hand_profile('o30_right_18') + source = package/'urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf' + return package, profile, source, UrdfKinematicModel(source) + + +def payload_for(inputs): + _, profile, source, model = inputs + name, delta = 'thumb_mcp', -.03 + mapping = JointMapping(name, 6, 'command_u8', (0., 255.), (0., .825), (0., .8), (0., .85)) + joint = model.joints[name] + evidence = MotionRangeEvidence(name, name, True, (joint.lower, joint.upper), (0., .85), + None, mapping.bounds_rad, delta, 0., (0, 1), ('front:1', 'front:2'), ('front:1',), + 'calibrated_motion_envelope', 'synthetic_training', None, '', (0, 1), ('front:1', 'front:2'), 'steady_command') + plan = build_standard_correction_plan(source_urdf=source, source_sha256=file_digest(source), + zero_offsets_rad={name: delta}, authorized_fields=profile.urdf_authorized_fields, + measured_ranges_output_rad={name: (0., .85)}, range_evidence={name: evidence}, preserve_source_mimic=True) + return json.loads(json.dumps(dict(format=DRAFT_FORMAT, profile_id=profile.key.profile_id, + accuracy_passed=False, publication_allowed=False, + joints={name: dict(zero_offset_rad=delta, zero_status='estimated_draft', command_to_rad=_mapping(mapping))}, + motion_range_evidence={name: asdict(evidence)}, urdf_correction=serialize_urdf_correction(plan)))) + + +def test_draft_exports_actual_zero_and_ranges_with_precision_unpassed(inputs, tmp_path): + _, profile, source, before = inputs + payload = payload_for(inputs) + plan, mappings = read_draft_plan(payload, source, profile) + output = plan.write(source, tmp_path/'draft.urdf') + after = UrdfKinematicModel(output) + assert after.joints['thumb_mcp'].upper == .85 + assert set(plan.patches.joints) == {'thumb_mcp'} + source_nodes = {node.get('name'): ET.tostring(node) for node in ET.parse(source).findall('joint')} + for node in ET.parse(output).findall('joint'): + if node.get('name') != 'thumb_mcp': + assert ET.tostring(node) == source_nodes[node.get('name')] + for direction in ('increasing', 'decreasing'): + for command in (0, 127, 255): + angles = {'thumb_mcp': mappings['thumb_mcp'].evaluate(command, direction)} + assert np.allclose(before.link_transform('thumb_ip', zero_offsets={'thumb_mcp': -.03}, + joint_angles=angles, independent_mimic_angles=True), + after.link_transform('thumb_ip', zero_offsets={}, joint_angles=angles, independent_mimic_angles=True), atol=1e-12) + assert not (tmp_path/'latest_passed').exists() + + +def test_operator_upright_splay_is_a_definition_and_leaves_ranges_free(inputs, tmp_path): + from raw_image_fixture import raw_image_rows + from linkerhand_calibration.profiles.observations import compile_tag_links + _, profile, source, model = inputs + selected = define_upright_splay_zero(profile, model) + scope = draft_targets(profile, selected) + assert scope['full_target_covered'] and not scope['missing_zero_joints'] + assert len(scope['estimated_zero_joints']) == 11 + assert set(scope['defined_zero_joints']) == UPRIGHT_SPLAY_JOINTS + assert len(scope['retained_leaf_zero_joints']) == 5 + assert selected.command == profile.command and selected.motion == profile.motion + bundle = DraftCommandImages(selected, model, SimpleNamespace(transform=lambda view: np.eye(4)), + raw_image_rows(cycles=(0,)), compile_tag_links(selected, model)) + assert not UPRIGHT_SPLAY_JOINTS.intersection(bundle.zero_columns) + assert UPRIGHT_SPLAY_JOINTS <= bundle.curve_columns.keys() + offsets = {name: .025 for name in bundle.zero_columns} + offsets.update({name: 0. for name in UPRIGHT_SPLAY_JOINTS}) + plan = build_standard_correction_plan(source_urdf=source, source_sha256=file_digest(source), + zero_offsets_rad=offsets, authorized_fields=profile.urdf_authorized_fields, + measured_ranges_output_rad={}, preserve_source_mimic=True) + corrected = UrdfKinematicModel(plan.write(source, tmp_path/'upright.urdf')) + # Verify the exported chain in the palm frame, with nonzero flexion offsets. + for name in UPRIGHT_SPLAY_JOINTS: + finger = name.split('_')[0] + positions = [corrected.link_transform(finger+'_'+joint, zero_offsets={}, joint_angles={}, + independent_mimic_angles=True)[:3, 3] for joint in ('mcp_pitch', 'pip', 'dip')] + assert np.ptp(np.asarray(positions)[:, 1]) < 1e-12 + + +def test_operator_zero_does_not_allow_forged_measured_offset(inputs): + _, profile, source, _ = inputs + payload = payload_for(inputs) + row = payload['joints']['thumb_mcp'] + row['zero_status'] = 'operator_defined' + with pytest.raises(ValueError, match='zero_outside_authorized_model'): + read_draft_plan(payload, source, profile) + row['zero_offset_rad'] = 0. + row['zero_assumption'] = UPRIGHT_SPLAY_REFERENCE + payload['baseline_command'] = list(profile.command.baseline_values) + with pytest.raises(ValueError, match='operator_zero_reference_invalid'): + read_draft_plan(payload, source, profile) + + +@pytest.mark.parametrize('mutation', ['zero', 'curve', 'certification', 'source']) +def test_saved_draft_cannot_change_zero_curve_source_or_claim_precision(inputs, mutation): + _, profile, source, _ = inputs + payload = payload_for(inputs) + if mutation == 'zero': + payload['joints']['thumb_mcp']['zero_offset_rad'] = .04 + elif mutation == 'curve': + payload['joints']['thumb_mcp']['command_to_rad']['increasing_rad'][-1] = 1.4 + elif mutation == 'source': + payload['urdf_correction']['source_urdf_sha256'] = '0'*64 + else: + payload['accuracy_passed'] = True + with pytest.raises(ValueError): + read_draft_plan(payload, source, profile) + + +def test_partial_training_keeps_root_gauge_and_missing_ancestors_out(inputs): + _, profile, _, model = inputs + units = CalibrationEngine(profile).scan_units() + completed = [u.identity for u in units if u.cycle in (0, 1) and + (u.task_key.startswith('thumb_') or u.task_key == 'pinky_mcp_front')] + tags = {tag.role: {} for view in profile.vision.views for tag in view.tags} + selected = draft_profile(profile, model, completed, units, [{'tags': tags}]) + assert selected.zero.active_joints == frozenset(n for n in profile.zero.active_joints if n.startswith('thumb_')) + assert set(selected.zero.cad_zero_assumptions) == {'thumb_cmc_roll', 'thumb_ip'} + assert {'thumb_cmc_yaw', 'thumb_mcp'}.isdisjoint(selected.zero.cad_zero_assumptions) + scope = draft_targets(profile, selected) + assert not scope['full_target_covered'] + assert 'thumb_cmc_roll' in scope['missing_zero_joints'] + assert len(scope['estimated_zero_joints']) == 2 + + +def test_complete_hand_targets_fifteen_zeros_and_all_twenty_ranges(inputs): + _, profile, _, model = inputs + units = CalibrationEngine(profile).scan_units() + tags = {tag.role: {} for view in profile.vision.views for tag in view.tags} + selected = draft_profile(profile, model, [unit.identity for unit in units], units, [{'tags': tags}]) + scope = draft_targets(profile, selected) + assert scope['full_target_covered'] + assert len(scope['estimated_zero_joints']) == 15 + assert len(scope['estimated_range_joints']) == 20 + assert set(scope['retained_leaf_zero_joints']) == {'thumb_ip', 'pinky_dip', 'ring_dip', 'middle_dip', 'index_dip'} + assert 'thumb_cmc_roll' in scope['estimated_zero_joints'] + assert set(scope['estimated_zero_joints']) == set(profile.zero.direct_zero_joints) + + +@pytest.mark.parametrize('missing_cycle', [0, 1]) +def test_one_missing_splay_direction_keeps_full_hand_draft_and_audits_gap(inputs, missing_cycle): + _, profile, _, model = inputs + units = CalibrationEngine(profile).scan_units() + completed = [unit.identity for unit in units if not (unit.task_key == 'fingers_mcp_roll_front' + and unit.cycle == missing_cycle and unit.segment_key == 'linked_increasing')] + tags = {tag.role: {} for view in profile.vision.views for tag in view.tags} + coverage = draft_training_coverage(completed, units) + splay = coverage['fingers_mcp_roll_front'] + assert splay['complete_cycles'] == [1-missing_cycle] + assert splay['initialization_cycle'] == 1-missing_cycle + assert splay['cycles'][missing_cycle] == dict(expected_units=4, accepted_units=3, complete=False) + selected = draft_profile(profile, model, completed, units, [{'tags': tags}]) + assert draft_targets(profile, selected)['full_target_covered'] + assert selected.zero.active_joints == profile.zero.active_joints + + +def test_holdout_and_complementary_partial_cycles_cannot_replace_complete_training(inputs): + _, profile, _, model = inputs + units = CalibrationEngine(profile).scan_units() + completed = [unit.identity for unit in units if not (unit.task_key == 'fingers_mcp_roll_front' + and ((unit.cycle == 0 and unit.segment_key == 'linked_increasing') + or (unit.cycle == 1 and unit.segment_key == 'linked_decreasing')))] + tags = {tag.role: {} for view in profile.vision.views for tag in view.tags} + coverage = draft_training_coverage(completed, units) + assert coverage['fingers_mcp_roll_front']['complete_cycles'] == [] + selected = draft_profile(profile, model, completed, units, [{'tags': tags}]) + assert all(name.startswith('thumb_') for name in selected.zero.active_joints) + + +def test_draft_initialization_uses_the_available_complete_training_cycle(inputs): + from raw_image_fixture import raw_image_rows + from linkerhand_calibration.profiles.observations import compile_tag_links + _, profile, _, model = inputs + rows = raw_image_rows(cycles=(0,)) + extrinsics = SimpleNamespace(transform=lambda view: np.eye(4)) + links = compile_tag_links(profile, model) + first = DraftCommandImages(profile, model, extrinsics, rows, links) + second = DraftCommandImages(profile, model, extrinsics, + [{**row, 'cycle': 1} for row in rows], links, + initialization_cycles={task.key: 1 for task in profile.motion.tasks}) + assert np.allclose(first.initialize((0,)*6), second.initialize((0,)*6)) + with pytest.raises(ValueError, match='training_cycle_required'): + DraftCommandImages(profile, model, extrinsics, rows, links, + initialization_cycles={task.key: 3 for task in profile.motion.tasks}) + + +def test_repeated_pose_reduction_preserves_full_objective_gradient_and_information(inputs): + from copy import copy + from raw_image_fixture import raw_image_rows + from linkerhand_calibration.profiles.observations import compile_tag_links + from linkerhand_calibration.core.geometry.tag_pose.urdf_command_images import UrdfCommandImages + _, profile, _, model = inputs + rows = raw_image_rows(cycles=(0,)) + # Separate real repeated-pose noise from the shared prediction. It must + # remain in the loss, even though its derivative is identically zero. + noisy = [] + for row in rows: + noisy.append({**row, 'cycle': 1, 'tags': {role: {**payload, + 'corners_xy': (np.asarray(payload['corners_xy'])+.2).tolist()} + for role, payload in row['tags'].items()}}) + bundle = DraftCommandImages(profile, model, SimpleNamespace(transform=lambda view: np.eye(4)), + [*rows, *noisy], compile_tag_links(profile, model)) + plain = copy(bundle) + plain.observations = bundle.original_observations + parameters = bundle.initialize((0,)*6) + parameters[bundle.zero_columns['index_pip']] += .01 + residual = bundle.residual(parameters) + original = UrdfCommandImages.residual(plain, parameters) + jacobian = bundle.jacobian(parameters) + full_jacobian = UrdfCommandImages.jacobian(plain, parameters) + assert bundle.within_group_error > 0 + assert len(residual) < len(original)/2 + assert np.allclose(bundle.original_residual(parameters), original) + assert np.isclose(residual @ residual, original @ original, rtol=1e-11) + gradient, full_gradient = jacobian.T @ residual, full_jacobian.T @ original + # Summation order changes cancellation near zero; compare the full norm. + assert np.linalg.norm(gradient-full_gradient) < 1e-11*np.linalg.norm(full_gradient) + assert np.allclose(jacobian.T @ jacobian, full_jacobian.T @ full_jacobian, rtol=1e-10, atol=1e-5) + + +def test_missing_child_observations_retain_only_its_parent_zero(inputs): + from dataclasses import replace + _, profile, _, model = inputs + units = CalibrationEngine(profile).scan_units() + completed = [unit.identity for unit in units if not unit.task_key.startswith('index_')] + tags = {tag.role: {} for view in profile.vision.views for tag in view.tags} + # A tag on the middle link also needs the missing pitch/PIP mappings. + with pytest.raises(ValueError, match='required_training_tags_missing'): + draft_profile(profile, model, completed, units, [{'tags': tags}]) + # Historical root-mounted layout can still measure root travel alone. + profile = replace(profile, vision=replace(profile.vision, views=tuple( + replace(view, tags=tuple(replace(tag, link='index_metacarpals') + if tag.role == 'index_mcp_roll' else tag for tag in view.tags)) + for view in profile.vision.views))) + selected = draft_profile(profile, model, completed, units, [{'tags': tags}]) + # The shared four-finger roll task remains complete, so index roll has a + # measured travel but lacks its downstream axis needed for an absolute zero. + assert 'index_mcp_roll' in selected.zero.active_joints + assert selected.zero.cad_zero_assumptions['index_mcp_roll'] == 'partial_missing_zero_observer:index_mcp_pitch' + scope = draft_targets(profile, selected) + assert not scope['full_target_covered'] + assert len(scope['estimated_zero_joints']) == 12 + assert len(scope['estimated_range_joints']) == 17 + assert scope['missing_zero_joints'] == ['index_mcp_pitch', 'index_mcp_roll', 'index_pip'] + + +def test_old_capture_model_correction_does_not_allow_motion_policy_changes(inputs, tmp_path): + package, profile, _, _ = inputs + text = (package/'config/profiles/o30_right_18.yaml').read_text() + original = tmp_path/'original.yaml' + # Only ID17 changes, not the front tag on the same physical link. + text = text.replace(' # 顶机位标签实际贴在近端指节,随 CMC 和 MCP 运动,不随 IP 运动。\n link: thumb_proximal', + ' link: thumb_metacarpals_base2') + text = text.replace('sdk_to_joint_direction: [1, 1, 1, 1, 1, 1,', + 'sdk_to_joint_direction: [1, 1, -1, -1, -1, -1,') + original.write_text(text) + captured = capture_profile_for_draft(profile, {'profile_config_sha256': file_digest(original)}, original) + assert captured != profile + original.write_text(text.replace('formal_speed_u8: 200', 'formal_speed_u8: 201', 1)) + assert captured.command.baseline_values == profile.command.baseline_values + assert captured.command.joint_directions != profile.command.joint_directions + with pytest.raises(ValueError, match='only_correct_model_bindings_or_directions'): + capture_profile_for_draft(profile, {'profile_config_sha256': file_digest(original)}, original) + + +def test_parallel_root_motion_rejects_reversed_sdk_signs_before_zero_fit(inputs): + from dataclasses import replace + import gzip + from linkerhand_calibration.profiles.observations import compile_tag_links + _, profile, _, model = inputs + # Explicit historical wrong signs; production now contains the correction. + signs = list(profile.command.joint_directions) + signs[2:6] = [-1]*4 + profile = replace(profile, command=replace(profile.command, sdk_to_joint_direction=tuple(signs))) + path = Path(__file__).parent/'fixtures/o30_recorded_root_directions.json.gz' + with gzip.open(path, 'rt') as stream: + rows = json.load(stream)['rows'] + assert {row['cycle'] for row in rows} == {0} + links = compile_tag_links(profile, model) + extrinsics = SimpleNamespace(transform=lambda view: np.eye(4)) + # All compared roots were recorded by the same front camera, so no + # cross-camera transform or fitted palm pose enters their relative axes. + bundle = DraftCommandImages(profile, model, extrinsics, rows, links) + audit = bundle.audit_root_directions() + assert not audit['passed'] and not audit['validation_used'] + conflicts = [row['joints'] for row in audit['comparisons'] if row['reversed_for_all_candidates']] + assert conflicts == [['thumb_cmc_roll', finger+'_mcp_roll'] + for finger in ('index', 'middle', 'ring', 'pinky')] + # The pixels stay immutable: correct only the four declared model signs. + signs = list(profile.command.joint_directions) + signs[2:6] = [1]*4 + corrected = replace(profile, command=replace(profile.command, sdk_to_joint_direction=tuple(signs))) + bundle = DraftCommandImages(corrected, model, extrinsics, rows, links) + assert bundle.audit_root_directions()['passed'] + + +def test_unsynchronized_saved_frames_do_not_supply_motion_range_baseline(inputs): + _, profile, _, model = inputs + values = list(profile.command.baseline_values) + row = dict(task_name='thumb_mcp_front', sample_phase='steady', accepted_sample_window_open=True, + command_vector=values, feedback_vector=values, target_command_vector=values, + command_image_skew_ns=0, state_image_sync_error_ms=0., view='front', cycle=0, + observation_protocol='raw_joint_images_v2', image_stamp_ns=1, + tags={'thumb_mcp': dict(tag_role='thumb_mcp', tag_id=0, image_stamp_ns=1, + pose_source='raw_corners', corners_xy=[[0, 0], [20, 0], [20, 20], [0, 20]])}) + tag = next(tag for view in profile.vision.views for tag in view.tags if tag.role == 'thumb_mcp') + row['tags']['thumb_mcp']['tag_id'] = tag.tag_id + invalid = {**row, 'image_stamp_ns': 2, 'command_vector': None} + mapping = JointMapping('thumb_mcp', 6, 'command_u8', (0., 255.), (0., .8), (0., .8), (0., .8)) + bundle = SimpleNamespace(profile=profile, model=model, cad_names={'thumb_mcp': 'thumb_mcp'}, + output_mappings=lambda _: {'thumb_mcp': mapping}, zero_offsets=lambda _: {'thumb_mcp': -.03}) + evidence = prepare_raw_motion_ranges(bundle, None, [invalid, row])['thumb_mcp'] + assert evidence.reference_sample_ids == ('front:1',) + assert evidence.training_sample_ids == ('front:1',) + assert evidence.training_cycles == (0,) + for invalid in ({**row, 'tags': {}}, {**row, 'cycle': 3}): + with pytest.raises(ValueError, match='baseline_support_missing'): + prepare_raw_motion_ranges(bundle, None, [invalid]) + + +def test_large_image_error_can_generate_draft_but_unobservable_zero_cannot(): + bundle = SimpleNamespace(tags={'tag': object()}, scale=np.ones(2), zero_columns={'joint': 0}, + fit=lambda _: SimpleNamespace(success=True, x=np.array([.02, .1]), fun=np.ones(8)*8, + nfev=1, initialization_evaluations=0), output_mappings=lambda _: {}, + zero_offsets=lambda x: {'joint': x[0]}, jacobian=lambda _: np.tile(np.eye(2), (4, 1))) + _, audit, _ = fit_draft(bundle) + assert audit['precision_certified'] is False + assert audit['candidates'][0]['original_image_precision_passed'] is False + assert audit['estimated_zeros']['joint']['estimated_deg'] > 0 + bundle.jacobian = lambda _: np.tile([[0., 1.]], (8, 1)) + with pytest.raises(ValueError, match='zero_unobservable'): + fit_draft(bundle) + + +@pytest.mark.parametrize('error', [ValueError('angle_measurement_failed'), + IndexError('index 1 is out of bounds for axis 1 with size 1')]) +def test_draft_holdout_evaluator_failure_is_not_an_accuracy_pass(monkeypatch, error): + from linkerhand_calibration.runtime.artifacts import raw_joint_draft as draft + parameters = np.array([.01, .02]) + parameters.flags.writeable = False + def fail(bundle, candidate, holdout): + assert candidate.parameters is parameters + raise error + monkeypatch.setattr(draft, 'validate_raw_joint_candidate', fail) + result = draft.evaluate_draft_holdout(object(), parameters, ()) + assert result['passed'] is False and result['status'] == 'evaluation_failed' + assert result['exception_type'] == type(error).__name__ + assert str(error) in result['traceback'] + assert np.array_equal(parameters, [.01, .02]) + + +def test_draft_stops_after_four_converged_candidates_and_reports_partial_search(): + calls = [] + def fit(seed): + calls.append(seed) + return SimpleNamespace(success=True, x=np.array([.02,.1]), fun=np.ones(8), + nfev=1, initialization_evaluations=0) + bundle = SimpleNamespace(tags={str(i): object() for i in range(6)}, scale=np.ones(2), + zero_columns={'joint': 0}, fit=fit, output_mappings=lambda _: {}, + zero_offsets=lambda x: {'joint': x[0]}, jacobian=lambda _: np.tile(np.eye(2),(4,1))) + _, audit, candidates = fit_draft(bundle) + assert len(calls) == len(candidates) == audit['initializations_evaluated'] == 4 + assert audit['available_initializations'] == 64 + assert not audit['candidate_search_exhaustive'] and not audit['precision_certified'] + assert not audit['validation_used_for_selection'] + + +def test_training_initialization_requires_same_capture_and_never_uses_holdout(inputs, tmp_path, monkeypatch): + from linkerhand_calibration.runtime import runner_support + _, profile, source, _ = inputs + hashes, data = {'source': file_digest(source)}, {'capture': 'same_batch'} + config = SimpleNamespace(source_urdf=source, calibration_contract=SimpleNamespace(typed_profile=profile)) + monkeypatch.setattr(runner_support, 'protected_inputs', lambda _: hashes) + payload = payload_for(inputs) + training = dict(selected_seed=[0], selection='minimum_training_coordinate_rms', + validation_used_for_selection=False) + payload['training'] = training + state = dict(format=DRAFT_FORMAT, generated=True, input_sha256=data, solver_protected_inputs=hashes) + (tmp_path/'draft_result.json').write_text(json.dumps(state)) + (tmp_path/'draft_calibration.json').write_text(json.dumps(payload)) + (tmp_path/'draft_training.json').write_text(json.dumps(training)) + np.savez(tmp_path/'draft_candidates.npz', seeds=[[0]], parameters=[[.1,.2,.3,0.,0.,1.]]) + hint, audit = load_training_initialization(tmp_path, config, data) + assert set(hint['mappings']) == {'thumb_mcp'} + assert hint['offsets']['thumb_mcp'] == -.03 + assert audit['shared_parameters_frozen'] is False and not audit['validation_used'] + with pytest.raises(ValueError, match='source_and_training_only'): + load_training_initialization(tmp_path, config, {'capture': 'different_batch'}) + training['validation_used_for_selection'] = True + (tmp_path/'draft_training.json').write_text(json.dumps(training)) + with pytest.raises(ValueError, match='source_and_training_only'): + load_training_initialization(tmp_path, config, data) + + +@pytest.mark.parametrize('args', [['--offline-draft'], ['--offline-draft', '--offline-raw', 'unused', '--publish-offline'], + ['--draft-initialization-from', 'unused'], ['--draft-upright-splay-zero']]) +def test_draft_cli_cannot_reach_online_or_publication(args, monkeypatch): + from linkerhand_calibration.runtime import runner + monkeypatch.setattr(runner, 'load_product_config', lambda *a, **k: pytest.fail('invalid mode must be rejected first')) + monkeypatch.setattr(runner, 'run_online', lambda *a, **k: pytest.fail('draft must never move hardware')) + with pytest.raises(SystemExit) as caught: + runner.main(args) + assert caught.value.code == 2 + + +@pytest.mark.parametrize('outcome', ['complete', 'partial']) +def test_capture_closes_control_and_never_runs_solver(inputs, tmp_path, monkeypatch, outcome): + import itertools + import rclpy + from linkerhand_calibration.product import load_product_config + from linkerhand_calibration import hikrobot_camera + from linkerhand_calibration.runtime import runner, runner_support + from linkerhand_calibration.runtime import raw_completion + from linkerhand_calibration.runtime.artifacts import raw_joint_draft as draft + + package, _, _, _ = inputs + config = load_product_config(package/'config/o30_right_product.yaml', check_can=False) + session = tmp_path/'session' + session.mkdir() + events, context = [], [False] + def init(**kwargs): + context[0] = True + def shutdown(): + context[0] = False + events.append('context_closed') + monkeypatch.setattr(rclpy, 'ok', lambda: context[0]) + monkeypatch.setattr(rclpy, 'init', init) + monkeypatch.setattr(rclpy, 'shutdown', shutdown) + monkeypatch.setattr(rclpy, 'spin_once', lambda *a, **k: None) + monkeypatch.setattr(hikrobot_camera, 'configure_fastdds_large_image_transport', lambda: None) + monkeypatch.setattr(runner.time, 'monotonic', lambda: next(ticks)) + ticks = itertools.count(0., 2.) + monitor = SimpleNamespace(status_topic='/draft_test/status', count_publishers=lambda _: 0, + destroy_node=lambda: events.append('monitor_closed')) + monkeypatch.setattr(runner_support, 'CalibrationMonitor', lambda **kwargs: monitor) + monkeypatch.setattr(runner_support, 'create_session_directory', lambda _: session) + monkeypatch.setattr(runner_support, 'launch_command', lambda *a, **k: ['mock_control_stack']) + def start(*args, **kwargs): + events.append('stack_started') + return object() + monkeypatch.setattr(runner.subprocess, 'Popen', start) + monkeypatch.setattr(runner_support, 'stop_stack', lambda _: events.append('stack_closed')) + monkeypatch.setattr(runner, 'drive_session', lambda *a, **k: {'state': 'CAPTURE_COMPLETE'}) + def verify(_): + assert events == ['stack_started', 'stack_closed', 'monitor_closed', 'context_closed'] + return {'raw_capture_complete': outcome != 'partial'} + monkeypatch.setattr(raw_completion, 'verify_capture_manifest', verify) + def export(*args, **kwargs): + pytest.fail('capture must not invoke the offline solver') + monkeypatch.setattr(draft, 'replay_draft', export) + result = runner.run_online(config, allow_resume=False) + assert result == (0 if outcome == 'complete' else 3) + assert events.count('stack_started') == 1 + assert events[-1] == 'context_closed' + report = json.loads((session/'capture_result.json').read_text()) + assert file_digest(session/'capture_profile.yaml') == config.profile_config_sha256 + assert report['capture_finished'] and report['control_closed'] + assert not report['accuracy_passed'] and not report['automatic_recapture'] + assert not report['fit_started'] + + +@pytest.mark.parametrize('cycle', [0, 1]) +def test_actual_single_training_cycle_exports_only_as_uncertified_draft(inputs, cycle): + from linkerhand_calibration.core.urdf.limits import validate_range_evidence + _, profile, source, model = inputs + payload = payload_for(inputs) + row = payload['motion_range_evidence']['thumb_mcp'] + row['training_cycles'] = row['command_training_cycles'] = [cycle] + plan, _ = read_draft_plan(payload, source, profile) + evidence = plan.range_evidence['thumb_mcp'] + assert evidence.training_cycles == (cycle,) + with pytest.raises(ValueError, match='invalid_training_motion_range_evidence'): + validate_range_evidence('thumb_mcp', model.joints['thumb_mcp'], -.03, (0., .85), evidence) + row['training_cycles'] = row['command_training_cycles'] = [3] + with pytest.raises(ValueError, match='invalid_training_motion_range_evidence'): + read_draft_plan(payload, source, profile) + + +def test_confirmed_front_tags_follow_mcp_and_pip_without_rewriting_capture(inputs, tmp_path): + from dataclasses import replace + from linkerhand_calibration.profiles.observations import compile_tag_links, compile_tag_feedback_channels + package, profile, _, model = inputs + original = tmp_path/'captured.yaml' + original.write_bytes((package/'config/profiles/o30_right_18.yaml').read_bytes()) + captured_hash = file_digest(original) + links = {finger+'_mcp_roll': model.joints[finger+'_pip'].child + for finger in ('pinky', 'ring', 'middle', 'index')} + corrected = replace(profile, vision=replace(profile.vision, views=tuple( + replace(view, tags=tuple(replace(tag, link=links.get(tag.role, tag.link)) for tag in view.tags)) + for view in profile.vision.views))) + captured = capture_profile_for_draft(corrected, {'profile_config_sha256': captured_hash}, original) + assert captured == profile and file_digest(original) == captured_hash + assert corrected.command == captured.command and corrected.motion == captured.motion + channels = compile_tag_feedback_channels(corrected, model) + for role, link in links.items(): + assert compile_tag_links(corrected, model)[role] == link + pitch = role.replace('_roll', '_pitch') + assert channels[role] == {profile.command.command_index_by_joint[role], + profile.command.command_index_by_joint[pitch], + profile.command.command_index_by_joint[role.replace('_mcp_roll', '_pip')]} + + +@pytest.mark.parametrize('confidence_deg, expected', [(2., False), (.1, True)]) +def test_raw_holdout_verdict_is_json_boolean(monkeypatch, confidence_deg, expected): + from linkerhand_calibration.core.fitting import raw_joint_validation as validation + name = 'thumb_cmc_yaw' + bundle = SimpleNamespace(joints=(name,), cad_names={name: name}, + model=SimpleNamespace(joints={name: SimpleNamespace(child='thumb')}), + profile=SimpleNamespace(acquisition=SimpleNamespace(minimum_valid_samples=2), + measurement=SimpleNamespace(measurements={name: SimpleNamespace(view='top')}))) + poses = {'thumb': np.repeat(np.eye(4)[None], 2, axis=0)} + monkeypatch.setattr(validation, '_groups', lambda *_: {('top', (name,), ('tag',)): [{}, {}]}) + monkeypatch.setattr(validation, '_measure_group', lambda *_: ( + np.zeros((2, 1)), np.zeros((1, 2, 8)), np.zeros((1, 2, 8)), + np.full((2, 1), np.radians(confidence_deg)), poses, poses)) + result = validation.validate_raw_joint_candidate(bundle, SimpleNamespace(parameters=[]), []) + assert result['passed'] is expected + assert json.loads(json.dumps(result))['passed'] is expected + bundle.joints = (name, 'index_pip') + partial = validation.validate_raw_joint_candidate(bundle, SimpleNamespace(parameters=[]), [], + report_joints=(name,)) + assert set(partial['joints']) == {name} and partial['passed'] is expected + with pytest.raises(ValueError, match='holdout_measurements_missing:index_pip'): + validation.validate_raw_joint_candidate(bundle, SimpleNamespace(parameters=[]), []) + with pytest.raises(ValueError, match='report_scope_invalid'): + validation.validate_raw_joint_candidate(bundle, SimpleNamespace(parameters=[]), [], + report_joints=('unknown',)) diff --git a/src/linkerhand_calibration/test/test_raw_failure_regression.py b/src/linkerhand_calibration/test/test_raw_failure_regression.py deleted file mode 100644 index dcc8f37..0000000 --- a/src/linkerhand_calibration/test/test_raw_failure_regression.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Historical failures must remain data, not online geometry authorization.""" -import gzip -import json -from pathlib import Path - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.observation_coverage import visible_roles -from linkerhand_calibration.runtime.raw_quality import evaluate_raw_unit -from linkerhand_calibration.runtime.engine import CalibrationEngine - - -def test_actual_id5_id9_id12_ambiguity_and_id10_missing_images(): - path = Path(__file__).parent/'fixtures/o30_raw_failure_observations.json.gz' - with gzip.open(path, 'rt') as stream: - fixture = json.load(stream) - profile = load_bundled_hand_profile('o30_right_18') - declarations = {v.name: {t.role: t for t in v.tags} for v in profile.vision.views} - for tag_id, row in zip((5, 9, 12), fixture['ambiguous']): - role = next(k for k, value in row['tags'].items() if value['tag_id'] == tag_id) - assert len(row['tags'][role]['reprojection_valid_candidates']) == 2 - assert role in visible_roles(row, declarations[row['view']]) - assert fixture['missing_id10_total'] > 0 - for row in fixture['missing_id10']: - assert 'index_pip' not in visible_roles(row, declarations[row['view']]) - unit = next(u for u in CalibrationEngine(profile).scan_units() if u.task_key == 'index_mcp_pitch_side') - # Old preparation observations are not promoted to a new protocol scan. - quality = evaluate_raw_unit(profile, unit, 1, fixture['missing_id10'], first_cycle_spans={}) - assert not quality.passed diff --git a/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py b/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py deleted file mode 100644 index dcc2d3c..0000000 --- a/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import replace -from pathlib import Path - -import numpy as np - -from test_urdf_command_images import graph -from linkerhand_calibration.core.fitting.raw_joint_fit import RawJointCandidate, certify_candidate_union -from linkerhand_calibration.core.fitting.raw_joint_selection import reject_training_distinguishable_candidates - - -def test_candidate_union_rejects_physical_difference_without_demanding_unique_mount(graph): - bundle, truth = graph - original = RawJointCandidate((0,)*6, truth, np.eye(len(truth))*1e-14, 0.) - installed = truth.copy() - column = next(iter(bundle.mount_columns.values())) - installed[column:column+3] += [.1,.2,.3] - accepted, _ = certify_candidate_union(bundle,(original,replace(original,parameters=installed))) - assert accepted['passed'] - changed = truth.copy() - changed[bundle.zero_columns['thumb_cmc_yaw']] += np.radians(5) - rejected, _ = certify_candidate_union(bundle,(original,replace(original,parameters=changed))) - assert not rejected['passed'] and 'zero_rad' in rejected['failures'] - - -def test_training_loss_rejection_keeps_all_indistinguishable_seeds(graph): - bundle, _ = graph - path = Path(__file__).parent/'fixtures/o30_training_candidate_parameters.npz' - stored = np.load(path) - candidates = tuple(RawJointCandidate((i,)*6,x,np.eye(len(x))*1e-14,float(rms)) - for i,(x,rms) in enumerate(zip(stored['parameters'],stored['rms']))) - # Exact numerical duplicates are still retained, not ranked for release. - duplicate = replace(candidates[0],seed=(2,)*6) - retained, audit = reject_training_distinguishable_candidates(bundle,(*candidates,duplicate)) - assert [c.seed for c in retained] == [candidates[0].seed,duplicate.seed] - assert audit['excluded_seeds'] == [list(candidates[1].seed)] - assert audit['familywise_alpha'] == .01 - assert any(r['holm_probability'] < .01 for r in audit['comparisons']) - assert not any(row['cycle'] == 3 for row in bundle.rows) diff --git a/src/linkerhand_calibration/test/test_raw_joint_capture.py b/src/linkerhand_calibration/test/test_raw_joint_capture.py deleted file mode 100644 index 3ad70f6..0000000 --- a/src/linkerhand_calibration/test/test_raw_joint_capture.py +++ /dev/null @@ -1,227 +0,0 @@ -"""The deferred protocol captures evidence without authorizing geometry.""" -from itertools import groupby - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import ( - RAW_JOINT, CapturePlan, configure_training, select_task_training, -) -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.engine import CalibrationEngine, SweepQuality -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase - - -@pytest.fixture -def profile(): - return configure_training(load_bundled_hand_profile('o30_right_18'), RAW_JOINT) - - -def test_raw_protocol_has_separate_identity_and_three_visits(profile): - units = CalibrationEngine(profile).scan_units() - assert len(units) == 108 - assert [key for key, _ in groupby(units, key=lambda u: (u.task_key, u.cycle))] == [ - (task.key, cycle) for task in profile.motion.tasks for cycle in (0, 1, 3)] - assert CapturePlan.from_profile(profile).as_dict()['version'] == 'capture_plan_v6_raw_joint' - with pytest.raises(ValueError, match='cannot_add_training'): - select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2)) - - -def test_no_task_requires_zero_model_or_auxiliary_preparation(profile): - execution = SessionExecution(profile) - current = profile.command.baseline_values - for index, unit in enumerate(execution.session.scheduled_units): - execution.session._unit_index = index - execution.session.phase = Phase.PREPARE - effects = execution._make_effects(current) - assert not any(effect.phase in {'joint_zero', 'zero_approach', - 'observer_preflight', 'observer_probe'} for effect in effects) - assert not execution.zero_references - current = effects[-1].target if effects else current - - -def test_failed_unit_gets_one_tail_retry_and_returns_before_failure(profile): - session = CalibrationSession(profile) - first, second = session.scheduled_units[:2] - bad = SweepQuality(False, ('missing_images',), (), {}) - good = SweepQuality(True, (), (), {}) - session.phase = Phase.EVALUATE - session.evaluation_complete(bad) - assert session.current_unit == second - assert session.scheduled_units[-1] == first - assert session.status().task.cycle_count == 3 - assert session.status().resume['pending_unit_recaptures'] == [list(first.identity)] - while session.current_unit != first: - session.phase = Phase.EVALUATE - session.evaluation_complete(good) - assert session.action().retry == 1 - session.phase = Phase.EVALUATE - session.evaluation_complete(bad) - assert session.phase == Phase.RETURN_BASELINE - session.return_complete() - assert session.phase == Phase.FAILED - assert first.identity in session.incomplete_units - assert str(first.identity) in session.status().resume['incomplete_units'] - - -def test_real_coordinator_collects_raw_chain_without_any_online_fit(tmp_path, monkeypatch): - import json - import numpy as np - from runtime_host_fixture import coordinator_fixture, ready - from test_chain_capture_runtime import chain_frame - from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection - from linkerhand_calibration.runtime.resume_storage import load_passed_capture - - host, clock = coordinator_fixture(tmp_path, model='o30', - profile_transform=lambda p: configure_training(p, RAW_JOINT)) - session = host.execution.session - session._units = [u for u in session.scheduled_units if u.task_key in { - 'pinky_mcp_pitch_side', 'pinky_pip_side', 'pinky_dip_side'}] - ready(host, clock) - for view in host.profile.vision.view_names: - host.cameras.matrices[view] = np.array([[1400., 0., 320.], [0., 1400., 240.], [0., 0., 1.]]) - monkeypatch.setattr(host.branch_initialization, 'request', lambda *a, **kw: - pytest.fail('raw acquisition must not request an online model')) - monkeypatch.setattr(host.finalization, 'start', lambda *a, **kw: - pytest.fail('test stops at the raw acquisition boundary')) - # The measured optical gate is tested separately with projected board data. - monkeypatch.setattr(host, '_verify_optical_preflight', lambda: None) - assert host.start().success - try: - for _ in range(18000): - if session.phase in {Phase.FIT, Phase.FAILED, Phase.PAUSED}: - 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)) - for view in host.profile.vision.views: - if view.name == 'side': - continue - base = next(t for t in view.tags if t.fixed_reference) - host.receive_detections(DetectionInput(view.name, host.ports.clock_ns(), - (TagDetection(base.tag_id, ((280., 280.), (360., 280.), - (360., 200.), (280., 200.)), 0, 100.),))) - assert session.phase == Phase.FIT, (host.reason, session.incomplete_units) - assert len(host.execution.completed_units) == 18 - assert not host.execution.zero_references - assert tuple(clock.positions[-1]) == host.profile.command.baseline_values - for channel in (10, 14, 19): - commands = np.asarray([host.profile.command.baseline_values, *clock.positions])[:, 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.] - rows = list(load_passed_capture(host.raw_path)) - assert not any(r.get('kind') in {'joint_sample', 'joint_zero_sample', - 'motion_branch_initialization', 'diagnostic_observation_frame'} for r in rows) - assert {r['cycle'] for r in rows if r.get('kind') == 'raw_observation_frame' - and r['sample_phase'] == 'steady'} == {0, 1, 3} - from linkerhand_calibration.runtime.raw_evidence import partition_raw_capture - evidence = partition_raw_capture(host.profile, rows, require_complete=False) - assert len(evidence.completed_units) == 18 - assert {r['cycle'] for r in evidence.training} == {0, 1} - assert {r['cycle'] for r in evidence.validation} == {3} - with pytest.raises(TypeError): - evidence.training[0]['cycle'] = 3 - with pytest.raises(ValueError, match='raw_capture_incomplete'): - partition_raw_capture(host.profile, rows) - with pytest.raises(ValueError, match='mixed_authorization_protocol'): - partition_raw_capture(host.profile, [*rows, {'kind': 'joint_sample'}]) - image = next(r for r in rows if r.get('kind') == 'raw_observation_frame') - with pytest.raises(ValueError, match='image_identity_invalid'): - partition_raw_capture(host.profile, [*rows, image], require_complete=False) - # A later interrupted retry cannot resurrect the older passed attempt. - interrupted = {**image, 'attempt': 2, - 'image_stamp_ns': max(r.get('image_stamp_ns', 0) for r in rows) + 1} - partial = partition_raw_capture(host.profile, [*rows, interrupted], require_complete=False) - assert len(partial.completed_units) == 17 - assert all(r['task_name'] != image['task_name'] or r['cycle'] != image['cycle'] - or r['direction'] != image['direction'] for r in partial.training) - finally: - host.close() - - -@pytest.mark.parametrize('command_ns,state_ms', [ - (None, 0.), (0, None), (-1, 0.), (0, -1.), (50_000_001, 0.), - (0, 50.001), (True, 0.), (0, float('nan')), -]) -def test_raw_replay_excludes_unsynchronized_images(command_ns, state_ms): - from linkerhand_calibration.runtime.raw_quality import synchronized_frame - assert not synchronized_frame(dict(command_image_skew_ns=command_ns, - state_image_sync_error_ms=state_ms)) - - -@pytest.mark.parametrize('fixed_reference', [False, True]) -def test_raw_branch_conflict_preserves_only_reference_stop(tmp_path, fixed_reference, monkeypatch): - from runtime_host_fixture import coordinator_fixture, ready - from test_runtime_pose_branch_guard import branch_event - host, clock = coordinator_fixture(tmp_path, model='o30', - profile_transform=lambda p: configure_training(p, RAW_JOINT)) - try: - ready(host, clock) - monkeypatch.setattr(host, '_verify_optical_preflight', lambda: None) - assert host.start().success - session = host.execution.session - session.phase = Phase.SWEEP - task = next(t for t in host.profile.motion.tasks if t.key == session.current_unit.task_key) - spec = host.profile.measurement.measurements[task.joints[0]] - view = next(v for v in host.profile.vision.views if v.name == spec.view) - tag = next(t for t in view.tags if t.fixed_reference) if fixed_reference else next( - t for t in view.tags if t.role == spec.child_role) - row = branch_event(host.ports.clock_ns(), view=view.name, role=tag.role, tag=tag.tag_id) - assert host._accept_branch_event(row) == fixed_reference - assert (session.phase == Phase.PAUSED) == fixed_reference - finally: - host.close() - - -def test_raw_start_requires_measured_optical_check_before_any_motion(tmp_path): - from runtime_host_fixture import coordinator_fixture, ready - host, clock = coordinator_fixture(tmp_path, model='o30') - try: - ready(host, clock) - result = host.start() - assert not result.success - assert 'missing_measured_optical_observations' in host.reason - assert not clock.positions and not clock.settings - finally: - host.close() - - -def test_full_hand_motion_effects_have_three_formal_returns_and_no_preparation_lap(profile): - import numpy as np - driver = SessionExecution(profile) - session = driver.session - session.device_ready(); session.start() - current = profile.command.baseline_values - motions = [] - for _ in range(6000): - if session.phase == Phase.FIT: - break - if session.phase == Phase.REFERENCE_LOCKING: - session.reference_locked(); continue - if session.phase == Phase.EVALUATE: - session.evaluation_complete(SweepQuality(True, (), (), {})); continue - motion = driver.motion(current) - assert motion is not None, session.phase - motions.append(motion) - current = motion.target - driver.motion_complete() - assert session.phase == Phase.FIT - assert current == profile.command.baseline_values - assert not any(m.phase in {'zero_approach','joint_zero','observer_probe','mapping_probe'} for m in motions) - for task in profile.motion.tasks: - scans = [m for m in motions if m.task_key == task.key and m.phase == 'steady'] - assert {m.cycle for m in scans} == {0,1,3} - for cycle in (0,1,3): - assert {m.direction for m in scans if m.cycle == cycle} == {'increasing','decreasing'} - # Avoidance and entry commands remain visible in this same ledger. - if not task.segments: - channel = task.command_index - values = np.asarray([m.target[channel] for m in scans]) - changed = values[np.r_[True, np.diff(values) != 0]] - assert np.count_nonzero(np.diff(np.sign(np.diff(changed)))) == 5 diff --git a/src/linkerhand_calibration/test/test_raw_joint_release.py b/src/linkerhand_calibration/test/test_raw_joint_release.py deleted file mode 100644 index ffdbd1f..0000000 --- a/src/linkerhand_calibration/test/test_raw_joint_release.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Release gates use independent images and read the actual serialized files.""" -import hashlib -from dataclasses import replace -from types import SimpleNamespace - -import numpy as np -import pytest - -from test_urdf_command_images import graph -from raw_image_fixture import raw_image_rows -from linkerhand_calibration.core.fitting.raw_joint_fit import RawJointCandidate, RawJointFit -from linkerhand_calibration.core.fitting.raw_joint_validation import validate_raw_joint_candidate -from linkerhand_calibration.core.artifacts.storage import atomic_write_json -from linkerhand_calibration.runtime.artifacts.raw_joint_report import prepare_raw_report -from linkerhand_calibration.runtime.artifacts.raw_joint_validator import RawJointArtifactValidator -from linkerhand_calibration.runtime.artifacts.serializers.measured_v3 import from_report -from linkerhand_calibration.runtime.artifacts.serializers.compact_v2 import REPORT_FILENAME -from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json -from linkerhand_calibration.runtime.raw_evidence import RawCalibrationEvidence - - -@pytest.fixture(scope='module') -def frozen_validation(graph): - bundle, truth = graph - candidate = RawJointCandidate((0,)*6, truth, np.eye(len(truth))*1e-12, 0.) - rows = raw_image_rows(cycles=(3,)) - result = validate_raw_joint_candidate(bundle, candidate, rows) - return candidate, rows, result - - -def test_independent_holdout_accepts_truth_and_does_not_update_it(graph, frozen_validation): - bundle, truth = graph - candidate, rows, result = frozen_validation - assert result['passed'], result - assert result['shared_parameters_updated'] is False - np.testing.assert_array_equal(candidate.parameters, truth) - with pytest.raises(ValueError, match='not_independent'): - validate_raw_joint_candidate(bundle, candidate, (*rows, bundle.rows[0])) - - -def test_wrong_zero_fails_frozen_holdout(graph, frozen_validation): - bundle, truth = graph - candidate, rows, _ = frozen_validation - wrong = truth.copy(); wrong[bundle.zero_columns['thumb_cmc_yaw']] += np.radians(5) - with pytest.raises(ValueError, match='holdout_angle_measurement_failed'): - validate_raw_joint_candidate(bundle, replace(candidate, parameters=wrong), rows) - - -def test_report_rebuild_and_final_file_fk(graph, frozen_validation, tmp_path): - bundle, truth = graph - candidate, rows, validation = frozen_validation - fit = RawJointFit((candidate,), 0, dict(passed=True), ()) - evidence = RawCalibrationEvidence(bundle.rows, rows, (), 'a'*64) - protected = dict(source_urdf_sha256=hashlib.sha256(bundle.model.source.read_bytes()).hexdigest()) - report, plan = prepare_raw_report(bundle, fit, evidence, validation, - serial_number='OFFLINE_TRUTH', protected_inputs=protected) - json_path, urdf_path = tmp_path/'calibration.json', tmp_path/'corrected.urdf' - atomic_write_json(json_path, from_report(report)) - atomic_write_json(tmp_path/REPORT_FILENAME, report) - rebuild_urdf_from_json(json_path, bundle.model.source, urdf_path, - authorized_fields=bundle.profile.urdf_authorized_fields, expected_profile_id=bundle.profile.key.profile_id) - import xml.etree.ElementTree as ET - validator = RawJointArtifactValidator(bundle, candidate, report, plan, rows, validation, - dict(passed=True, scope='fixture_only'), ET.parse) - result = validator(json_path, urdf_path) - assert result['final_file_fk_maximum_difference'] < 1e-8 - altered = from_report(report) - altered['joints']['thumb_cmc_yaw']['angle_rad'][10] += .1 - atomic_write_json(json_path, altered) - with pytest.raises(ValueError, match='serialized_report_changed'): - validator(json_path, urdf_path) - - -def test_formal_finalizer_rejects_missing_provenance_before_fitting(graph, tmp_path, monkeypatch): - import json - from linkerhand_calibration.runtime.artifacts import raw_joint_finalization as finalization - - bundle, _ = graph - monkeypatch.setattr(finalization, 'fit_raw_joint_candidates', - lambda *args, **kwargs: pytest.fail('unverified capture reached the solver')) - protected = {key: 'a'*64 for key in bundle.profile.artifacts.protected_input_fields} - directory = tmp_path/'incomplete' - with pytest.raises(ValueError, match='requires_unique_session_and_locked_reference'): - finalization.finalize_raw_joint_session(profile=bundle.profile, session_dir=directory, - serial_number='OFFLINE_TRUTH', source_urdf=bundle.model.source, - protected_inputs=protected, records=(), camera_extrinsics_file=tmp_path/'missing.json', - standard_loader=lambda _: pytest.fail('invalid capture reached artifact loading')) - state = json.loads((directory/'raw_joint_result.json').read_text()) - assert not any(state[key] for key in ('raw_capture_complete', 'fit_complete', - 'accuracy_passed', 'publication_allowed', 'automatic_geometry_recapture')) - assert not (tmp_path/bundle.profile.artifacts.publication_pointer).exists() diff --git a/src/linkerhand_calibration/test/test_raw_joint_update.py b/src/linkerhand_calibration/test/test_raw_joint_update.py new file mode 100644 index 0000000..cf62605 --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_joint_update.py @@ -0,0 +1,64 @@ +"""Frozen references must remain immutable when only one chain is refitted.""" +from types import SimpleNamespace + +import numpy as np +import pytest +from scipy.sparse import csr_matrix + +from linkerhand_calibration.core.fitting.raw_joint_update import ( + check_update_zero_observability, fit_joint_update, transfer_parameters, +) + + +def test_update_freezes_reference_and_unobserved_travel(): + # Coupling to a frozen reference makes accidentally freeing it observable. + matrix = np.zeros((12, 10)) + matrix[:3, 6] = 1. + matrix[3:6, 8] = 1. + matrix[6:9, 9] = 1. + matrix[9:, 6] = matrix[9:, 9] = 1. + initial = np.zeros(10) + initial[7], initial[9] = .7, .4 + target = initial.copy() + target[6], target[8] = .9, .03 + bundle = SimpleNamespace(joints=('thumb', 'finger'), tags={}, + zero_columns={'thumb': 8}, curve_columns={'thumb': np.array([[6, 7]])}, + scale=np.ones(10), solver_options={'bounds': (np.full(10, -2.), np.full(10, 2.))}, + solver_ftol=1e-8, jacobian=lambda _: matrix, + solver_jacobian=lambda _: csr_matrix(matrix), + residual=lambda p: matrix @ (p-target), original_residual=lambda p: matrix @ (p-target)) + result, audit = fit_joint_update(bundle, initial, ('thumb',), (), fit_palm=False) + assert audit['converged'] + assert np.allclose(result, target, atol=1e-7) + assert np.array_equal(result[[7, 9]], initial[[7, 9]]) + assert audit['unobserved_retained_columns'] == [7] + with pytest.raises(ValueError, match='scope_invalid'): + fit_joint_update(bundle, initial, ('missing',), ()) + + +def test_transfer_uses_names_and_rejects_different_mount_link(): + source = SimpleNamespace(knots={'joint': np.array([0, 255])}, + curve_columns={'joint': np.array([[-1, 6], [-1, 7]])}, + zero_offsets=lambda p: {'joint': p[8]}, mount_columns={'tag': 9}, + tag_links={'tag': 'middle'}) + target = SimpleNamespace(scale=np.ones(15), knots=source.knots, + curve_columns={'joint': np.array([[-1, 14], [-1, 13]])}, + zero_columns={'joint': 12}, mount_columns={'tag': 6}, tag_links={'tag': 'middle'}) + parameters = np.arange(15, dtype=float) + result = transfer_parameters(source, parameters, target) + assert np.array_equal(result[:6], parameters[:6]) + assert np.array_equal(result[6:12], parameters[9:15]) + assert np.array_equal(result[12:15], [8, 7, 6]) + target.tag_links['tag'] = 'proximal' + with pytest.raises(ValueError, match='tag_link_changed'): + transfer_parameters(source, parameters, target) + + +def test_update_rejects_zero_absorbed_by_free_palm(): + # Both columns move the image identically: a low image residual cannot + # establish whether the palm or the joint zero changed. + bundle = SimpleNamespace(jacobian=lambda _: np.ones((8, 2)), + scale=np.ones(2), zero_columns={'thumb': 1}) + with pytest.raises(ValueError, match='zero_unobservable:thumb'): + check_update_zero_observability(bundle, np.zeros(2), np.array([0, 1]), ('thumb',)) + assert check_update_zero_observability(bundle, np.zeros(2), np.array([1]), ('thumb',)) == 1 diff --git a/src/linkerhand_calibration/test/test_raw_lightweight.py b/src/linkerhand_calibration/test/test_raw_lightweight.py new file mode 100644 index 0000000..cfb8707 --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_lightweight.py @@ -0,0 +1,581 @@ +"""Focused regression for raw corners, local retries and a sealed acquisition.""" +from dataclasses import replace +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from runtime_host_fixture import coordinator_fixture, ready +from linkerhand_calibration.profiles import load_bundled_hand_profile +from linkerhand_calibration.core.domain.capture_plan import CapturePlan +from linkerhand_calibration.runtime.engine import CalibrationEngine, SweepQuality +from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase +from linkerhand_calibration.runtime.execution import SessionExecution +from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution +from linkerhand_calibration.runtime.direction_progress import DirectionProgress +from linkerhand_calibration.runtime.capture import CaptureFrame +from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection +from linkerhand_calibration.runtime.raw_resume import RawCheckpoint +from linkerhand_calibration.runtime.raw_completion import verify_capture_manifest +from linkerhand_calibration.runtime.observation_coverage import visible_roles + + +@pytest.fixture +def profile(): + return load_bundled_hand_profile('o30_right_18') + + +def test_immediate_retry_and_full_schedule_end_at_capture_boundary(profile): + session = CalibrationSession(profile) + units = session.scheduled_units + assert len(units) == 108 + assert CapturePlan.from_profile(profile).as_dict()['observation_protocol']['recapture_order'] == 'immediate' + bad, good = SweepQuality(False, ('missing_images',), (), {}), SweepQuality(True, (), (), {}) + session.phase = Phase.EVALUATE + session.evaluation_complete(bad) + assert session.current_unit == units[0] and session.action().retry == 1 + assert session.scheduled_units == units + session.phase = Phase.EVALUATE + session.evaluation_complete(bad) + assert session.current_unit == units[1] + while session.current_unit: + session.phase = Phase.EVALUATE + session.evaluation_complete(good) + assert session.phase == Phase.RETURN_BASELINE + session.return_complete() + assert session.phase == Phase.CAPTURE_COMPLETE + assert units[0].identity in session.incomplete_units + + +def test_interrupted_second_attempt_cannot_obtain_a_third(profile): + session = CalibrationSession(profile) + unit = session.current_unit + base = dict(task_name=unit.task_key, cycle=unit.cycle, direction=unit.direction) + rows = ({**base, 'kind': 'scan_unit_complete', 'attempt': 1, 'passed': False}, + {**base, 'kind': 'raw_observation_frame', 'attempt': 2}) + checkpoint = RawCheckpoint({}, {}, rows, frozenset()) + interrupted = checkpoint.restore(session) + assert interrupted[0]['attempt'] == 2 + assert session.current_unit != unit + assert unit.identity in session.incomplete_units + assert session._retry_by_unit[unit.identity] == 1 + + +def test_short_segment_encoder_quantization_keeps_native_density(profile): + from linkerhand_calibration.runtime.scan_quality import _feedback_motion_coverage + # A physical 0–72 byte traversal at native ~3-byte steps is dense for an + # 0–80 command segment, but not for a claimed full-range traversal. + values = tuple(value for value in range(0, 73, 3) for _ in range(3)) + full, _ = _feedback_motion_coverage(profile, values) + short, metrics = _feedback_motion_coverage(profile, values, command_span_fraction=80/255) + assert not full.passed and short.passed + assert metrics['observed_span'] == 72 and metrics['required_bins'] == 11 + for stationary in ([0.]*80, [0., 1.]*40, [0., 4.]*40): + invalid, _ = _feedback_motion_coverage(profile, stationary, command_span_fraction=80/255) + assert not invalid.passed + + +def test_raw_frame_tracks_only_fixed_tag_and_keeps_moving_corners(tmp_path, monkeypatch): + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + view = host.profile.vision.views[0] + fixed = next(t for t in view.tags if t.fixed_reference) + calls = [] + tracker = host.trackers[view.name] + original = tracker.estimate + def estimate(role, *args, **kwargs): + assert role == fixed.role + calls.append(role) + return original(role, *args, **kwargs) + monkeypatch.setattr(tracker, 'estimate', estimate) + monkeypatch.setattr(host.capture, '_motion_model_schedule', lambda *a: pytest.fail('online model')) + points = np.array([[280.,280.],[360.,280.],[360.,200.],[280.,200.]]) + frame = CaptureFrame(view.name, host.ports.clock_ns(), np.array([[1400.,0.,320.],[0.,1400.,240.],[0.,0.,1.]]), + {t.role: points for t in view.tags}, host.profile.command.baseline_values, + host.profile.command.baseline_values, command_skew_ns=0, + detection_quality={t.role: {'decision_margin': 100.} for t in view.tags}) + unit = host.execution.session.current_unit + motion = MotionCommand('steady', tuple(frame.command), 200., task_key=unit.task_key, + command_index=0, cycle=0, direction='increasing', steady_index=0) + events, _ = host.capture.consume(frame, motion, raw_joint=True) + row = next(r for r in events if r['kind'] == 'raw_observation_frame') + assert visible_roles(row, {t.role:t for t in view.tags}) == {t.role for t in view.tags} + assert all('candidate_diagnostics' not in item and 'selected_pose' not in item for item in row['tags'].values()) + assert row['observation_protocol'] == 'raw_joint_images_v2' + assert not any(r.get('kind') == 'joint_sample' for r in events) + assert host.capture.consume(frame, motion, raw_joint=True) == ((), None) + finally: + host.close() + + +def run_direction(profile, feedback_fn): + driver = SessionExecution(profile) + driver.session.phase = Phase.SWEEP + unit = driver.session.current_unit + from linkerhand_calibration.runtime.trajectory import build_calibration_motion_command + task = next(t for t in profile.motion.tasks if t.key == unit.task_key) + current = build_calibration_motion_command(task, unit.start, profile=profile) + feedback = current.copy() + feedback[0] = feedback_fn(current[0]) + guard = DirectionProgress(profile, unit, 1, feedback, 0.) + effects = driver._make_effects(current) + now, stamp = 0., 0 + for motion in effects: + execution = MotionExecution(profile, motion, initial_command=current, + initial_feedback=feedback, now=now, identity=str(stamp), raw_scan=True) + for _ in range(200): + now += .05; stamp += 1 + current = execution.sample(now) + feedback = list(current) + feedback[0] = feedback_fn(current[0]) + execution.observe(feedback, stamp=stamp, now=now) + result = guard.observe(motion, execution, current, feedback, stamp, now) + if not result.safe: + return result, guard + if execution.arrived(now): + break + else: + pytest.fail('unbounded raw waypoint') + return guard.finish(), guard + + +def test_initial_dead_zone_and_endpoint_plateau_complete(profile): + result, guard = run_direction(profile, lambda x: min(195., max(0., 1.25*(x-40.)))) + assert result.safe, result + assert guard.travel[0] >= 190. + + +@pytest.mark.parametrize('feedback_fn', [lambda x: 0., lambda x: min(x, 60.), lambda x: 255.-x, + lambda x: .4 if int(x)%2 else 0., + lambda x: 4. if int(x)%2 else 0., + lambda x: x if x < 60. else 60.+(4. if int(x)%2 else 0.)]) +def test_stationary_blocked_reverse_or_noise_still_stop(profile, feedback_fn): + result, _ = run_direction(profile, feedback_fn) + assert not result.safe and result.code == 'mechanical_stall' + + +def test_known_return_is_not_a_raw_sample(profile): + baseline = profile.command.baseline_values + start = list(baseline); start[0] = 255. + motion = MotionCommand('task_exit', baseline, 200., feedback_targets=((0,3.),)) + execution = MotionExecution(profile, motion, initial_command=start, initial_feedback=start, + now=0., identity='return', raw_scan=True) + assert not execution.raw_scan + for stamp in range(400): + now = stamp*.05 + execution.sample(now) + execution.observe(start, stamp=stamp, now=now) + assert not execution.arrived(now) + + +def test_all_motion_effects_preserve_three_rounds_and_return(profile): + execution = SessionExecution(profile) + session = execution.session + session.device_ready(); session.start() + current = profile.command.baseline_values + scans = [] + for _ in range(6000): + if session.phase == Phase.CAPTURE_COMPLETE: + break + if session.phase == Phase.REFERENCE_LOCKING: + session.reference_locked(); continue + if session.phase == Phase.EVALUATE: + session.evaluation_complete(SweepQuality(True, (), (), {})); continue + motion = execution.motion(current) + assert motion is not None + assert motion.phase not in {'joint_zero', 'observer_probe', 'mapping_probe'} + if motion.phase == 'steady': + scans.append((motion.task_key, motion.cycle, motion.direction)) + current = motion.target + execution.motion_complete() + assert session.phase == Phase.CAPTURE_COMPLETE + assert tuple(current) == profile.command.baseline_values + for task in profile.motion.tasks: + assert {(cycle, direction) for key, cycle, direction in scans if key == task.key} == { + (cycle, direction) for cycle in (0,1,3) for direction in ('increasing','decreasing')} + + +def heartbeat(host, clock, feedback): + 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, feedback, host.ports.clock_ns()) + + +@pytest.mark.parametrize('recover', [False, True]) +def test_real_coordinator_finishes_missing_data_without_online_fit(tmp_path, monkeypatch, recover): + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + ready(host, clock) + host.parameters = replace(host.parameters, reuse_camera_calibration=True) + session = host.execution.session + # One complete direction and its immediate retry, with no moving tags. + session._units = session._units[:1] + from linkerhand_calibration.storage import append_jsonl + for view in host.profile.vision.views: + matrix = np.array([[1400.,0.,320.],[0.,1400.,240.],[0.,0.,1.]]) + host.cameras.matrices[view.name] = matrix + append_jsonl(host.raw_path, dict(kind='rectified_camera_model', view=view.name, + matrix_source='CameraInfo.P[:3,:3]', input_is_rectified=True, camera_matrix=matrix.tolist())) + monkeypatch.setattr(host.finalization, 'start', lambda *a, **kw: pytest.fail('online fit')) + assert host.start().success + for _ in range(2200): + clock.now += .1 + feedback = clock.positions[-1] if clock.positions else host.profile.command.baseline_values + heartbeat(host, clock, feedback) + host.tick() + for view in host.profile.vision.views: + base = next(t for t in view.tags if t.fixed_reference) + host.cameras.matrices[view.name] = np.array([[1400.,0.,320.],[0.,1400.,240.],[0.,0.,1.]]) + tags = (view.tags if recover and host._motion is not None + and host._motion.attempt == 2 and view.name == 'front' else (base,)) + host.receive_detections(DetectionInput(view.name, host.ports.clock_ns(), tuple( + TagDetection(t.tag_id, ((280.,280.),(360.,280.),(360.,200.),(280.,200.)),0,100.) for t in tags))) + if host.state in {'CAPTURE_COMPLETE', 'PAUSED', 'FAILED'}: + break + assert host.state == 'CAPTURE_COMPLETE', host.reason + assert bool(session.incomplete_units) != recover + assert len(host.execution.completed_units) == int(recover) + assert list(session._retry_by_unit.values()) == [1] + assert tuple(clock.positions[-1]) == host.profile.command.baseline_values + manifest = verify_capture_manifest(host.raw_path) + assert manifest['capture_finished'] and not manifest['raw_capture_complete'] + from linkerhand_calibration.runtime.acquisition import load_capture + rows = load_capture(host.raw_path) + assert not any(r.get('kind') == 'image_observation_frame' for r in rows) + assert any(r.get('kind') == 'raw_observation_frame' and r.get('sample_phase') == 'steady' + and not r.get('accepted_sample_window_open') for r in rows) + completions = [r for r in rows if r.get('kind') == 'scan_unit_complete'] + assert [r['attempt'] for r in completions] == [1,2] + assert [r['passed'] for r in completions] == [False,recover] + if recover: + from linkerhand_calibration.runtime.raw_evidence import partition_raw_capture + evidence = partition_raw_capture(host.profile, rows, require_complete=False) + assert len(evidence.completed_units) == 1 + assert {r['attempt'] for r in evidence.training} == {2} + sent = len(clock.positions) + for _ in range(3): + clock.now += .1 + heartbeat(host, clock, feedback) + host.tick() + host._publish_command(list(feedback)) + host._write_position(list(feedback)) + assert len(clock.positions) == sent + host.close() + verify_capture_manifest(host.raw_path) + if not recover: + from pathlib import Path + from linkerhand_calibration.product import load_product_config + from linkerhand_calibration.runtime.artifacts.replay import replay_capture + from linkerhand_calibration.runtime.raw_completion import file_digest + package = Path(__file__).resolve().parents[1] + config = load_product_config(package/'config/o30_right_product.yaml', + workspace=package.parents[1], check_can=False) + config = replace(config, serial_number='OFFLINE_COORDINATOR') + before = file_digest(host.raw_path) + output = tmp_path/'offline_missing' + with pytest.raises(ValueError, match='raw_joint_capture_incomplete'): + replay_capture(config, host.raw_path, output=output, publish=False) + assert file_digest(host.raw_path) == before + verify_capture_manifest(host.raw_path) + assert len(clock.positions) == sent + run = json.loads((output/'offline_run.json').read_text()) + assert run['commands_enabled'] is False + assert len(run['implementation_sha256']) == 64 + finally: + host.close() + + +def test_unstable_raw_endpoint_moves_into_bounded_hold(profile): + start = profile.command.baseline_values + target = list(start); target[0] = 32. + motion = MotionCommand('sweep', tuple(target), 200., direction='increasing') + segment = MotionExecution(profile, motion, initial_command=start, initial_feedback=start, + now=0., identity='endpoint', raw_scan=True) + for i in range(200): + now = i*.05 + segment.sample(now) + values = list(target); values[0] += 8. if i%2 else 0. + segment.observe(values, stamp=i, now=now) + assert segment.arrived(now) + hold = MotionExecution(profile, replace(motion, phase='steady', command_index=0), + initial_command=target, initial_feedback=target, now=now, identity='hold', raw_scan=True) + began = now + for i in range(60): + now = began+i*.05 + hold.sample(now) + values = list(target); values[0] += 8. if i%2 else 0. + hold.observe(values, stamp=i, now=now) + assert not hold.steady_ready(now) + assert hold.arrived(now) + + +def test_camera_latency_preserves_validation_endpoint_coverage(tmp_path): + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + ready(host, clock) + host.parameters = replace(host.parameters, reuse_camera_calibration=True) + session = host.execution.session + # Camera processing plus callback scheduling can cross a phase boundary. + # Keep the original validation nodes and endpoint coverage gates. + session._units = session._units[4:6] + for view in host.profile.vision.views: + host.cameras.matrices[view.name] = np.array([[1400.,0.,320.],[0.,1400.,240.],[0.,0.,1.]]) + assert host.start().success + for _ in range(6500): + clock.now += .05 + feedback = clock.positions[-1] if clock.positions else host.profile.command.baseline_values + feedback = list(feedback) + if host._motion is not None and host._motion.direction: + feedback[0] = min(240., max(10., feedback[0])) + heartbeat(host, clock, feedback) + host.tick() + for view in host.profile.vision.views: + tags = [t for t in view.tags if t.fixed_reference or view.name == 'front'] + host.receive_detections(DetectionInput(view.name, host.ports.clock_ns()-300_000_000, tuple( + TagDetection(t.tag_id, ((280.,280.),(360.,280.),(360.,200.),(280.,200.)),0,100.) + for t in tags))) + if host.state in {'CAPTURE_COMPLETE', 'PAUSED', 'FAILED'}: + break + assert host.state == 'CAPTURE_COMPLETE', host.reason + assert len(host.execution.completed_units) == 2, session.incomplete_units + assert not session._retry_by_unit + from linkerhand_calibration.runtime.acquisition import load_capture + from linkerhand_calibration.runtime.raw_evidence import partition_raw_capture + rows = load_capture(host.raw_path) + frames = [r for r in rows if r.get('kind') == 'raw_observation_frame'] + assert any(r['command_vector'][0] == 255 and r['feedback_vector'][0] == 240 for r in frames) + completions = [r for r in rows if r.get('kind') == 'scan_unit_complete'] + # Historical false negatives can be rechecked offline, with an audit; + # a recorded interruption still cannot become a completed direction. + for row in completions: + row.update(passed=False, failures=['endpoint_observation']) + conservative = partition_raw_capture(host.profile, rows, require_complete=False) + assert not conservative.completed_units + reassessed = partition_raw_capture(host.profile, rows, require_complete=False, reassess_quality=True) + assert len(reassessed.completed_units) == len(reassessed.reassessed_units) == 2 + completions[0]['failures'] = ['interrupted_attempt'] + interrupted = partition_raw_capture(host.profile, rows, require_complete=False, reassess_quality=True) + assert len(interrupted.completed_units) == 1 + # A configured target cannot stand in for the actual sent command. + for row in frames: + if row['command_vector'][0] == 255: + row['command_vector'][0] = 252. + missing = partition_raw_capture(host.profile, rows, require_complete=False, reassess_quality=True) + assert not missing.completed_units + finally: + host.close() + + +def test_legacy_protocol_is_readable_but_not_resumable(profile): + from linkerhand_calibration.runtime.raw_evidence import partition_raw_capture + legacy = CapturePlan.from_profile(profile).as_dict(legacy_raw=True) + rows = [{'kind': 'session_start', 'capture_plan': legacy}] + result = partition_raw_capture(profile, rows, require_complete=False) + assert result.capture_plan == legacy and len(result.missing_units) == 108 + unit = CalibrationEngine(profile).scan_units()[0] + rows.append(dict(kind='raw_observation_frame', observation_protocol='raw_joint_images_v2', + task_name=unit.task_key, cycle=unit.cycle, direction=unit.direction, attempt=1)) + with pytest.raises(ValueError, match='mixed_observation_protocol'): + partition_raw_capture(profile, rows, require_complete=False) + + +def test_completed_file_changes_are_rejected(tmp_path, profile): + from linkerhand_calibration.runtime.raw_completion import save_capture_manifest + raw = tmp_path/'raw_samples.jsonl'; raw.write_text('{}\n') + save_capture_manifest(raw, profile=profile, serial_number='TEST', protected_inputs={}, + completed_units=set(), incomplete_units={}, retries={}) + verify_capture_manifest(raw) + raw.write_text('{"changed":true}\n') + with pytest.raises(ValueError, match='sealed_file_changed'): + verify_capture_manifest(raw) + + +@pytest.mark.parametrize('fault', ['feedback_stale', 'duplicate_controller', 'sdk_identity']) +def test_raw_scan_keeps_non_motion_protection(tmp_path, fault): + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + ready(host, clock) + host.parameters = replace(host.parameters, reuse_camera_calibration=True) + assert host.start().success + assert host.stall_retry.maximum_failures == 2 + if fault == 'feedback_stale': + clock.now += 1.1 + elif fault == 'sdk_identity': + heartbeat(host, clock, host.profile.command.baseline_values) + clock.health_receiver(json.dumps(dict(hand_type='left', model='O30', side='LEFT', + uid='WRONG_DEVICE', joint_names=list(host.profile.command.names), online=True))) + else: + clock.publishers = 2 + heartbeat(host, clock, host.profile.command.baseline_values) + host.tick() + expected = 'o30_sdk_identity_mismatch' if fault == 'sdk_identity' else fault + assert host.state == 'PAUSED' and expected in host.reason + if fault == 'sdk_identity': + assert 'WRONG_DEVICE' in host.reason + from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh + assert reason_zh({'reason':host.reason}, model_name='O30')[0] == 'DEVICE-IDENTITY-203' + finally: + host.close() + + +def test_full_hand_coordinator_single_view_capture_is_sealed(tmp_path, monkeypatch): + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + ready(host, clock) + host.parameters = replace(host.parameters, reuse_camera_calibration=True) + for view in host.profile.vision.views: + host.cameras.matrices[view.name] = np.array([[1400.,0.,320.],[0.,1400.,240.],[0.,0.,1.]]) + monkeypatch.setattr(host.branch_initialization, 'request', lambda *a, **kw: pytest.fail('online geometry')) + monkeypatch.setattr(host.finalization, 'start', lambda *a, **kw: pytest.fail('online fit')) + assert host.start().success + tasks = {task.key: task for task in host.profile.motion.tasks} + for _ in range(65000): + clock.now += .1 + feedback = clock.positions[-1] if clock.positions else host.profile.command.baseline_values + heartbeat(host, clock, feedback) + host.tick() + motion = host._motion + task = tasks.get(motion.task_key) if motion is not None else None + primary = set() if task is None else {host.profile.measurement.measurements[j].view for j in task.joints} + for view in host.profile.vision.views: + tags = [t for t in view.tags if t.fixed_reference or view.name in primary] + host.receive_detections(DetectionInput(view.name, host.ports.clock_ns(), tuple( + TagDetection(t.tag_id, ((280.,280.),(360.,280.),(360.,200.),(280.,200.)), 0, 100.) for t in tags))) + if host.state in {'CAPTURE_COMPLETE', 'PAUSED', 'FAILED'}: + break + assert host.state == 'CAPTURE_COMPLETE', host.reason + assert len(host.execution.completed_units) == 108, host.execution.session.incomplete_units + assert not host.execution.session._retry_by_unit + assert tuple(clock.positions[-1]) == host.profile.command.baseline_values + manifest = verify_capture_manifest(host.raw_path) + assert manifest['raw_capture_complete'] and not manifest['accuracy_passed'] + from linkerhand_calibration.runtime.acquisition import load_capture + from linkerhand_calibration.runtime.raw_evidence import partition_raw_capture + evidence = partition_raw_capture(host.profile, load_capture(host.raw_path)) + assert len(evidence.completed_units) == 108 + assert {r['cycle'] for r in evidence.training} == {0,1} + assert {r['cycle'] for r in evidence.validation} == {3} + finally: + host.close() + + +@pytest.mark.parametrize('legacy', [True, False]) +def test_existing_synthetic_report_rebuilds_same_urdf(tmp_path, profile, legacy): + from pathlib import Path + import xml.etree.ElementTree as ET + from linkerhand_calibration.runtime.artifacts.serializers.measured_v3 import from_report + from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json + root = Path(__file__).resolve().parents[3] + fixture = root/'calibration_output/software_review_20260922_raw_joint/known_truth' + report_path = fixture/'calibration_report.json' + if not report_path.exists(): + pytest.skip('local immutable synthetic artifact is unavailable') + report = json.loads(report_path.read_text()) + if not legacy: + report['capture_plan'] = CapturePlan.from_profile(profile).as_dict() + report['raw_joint_audit']['protocol'] = 'raw_joint_images_v2' + payload = from_report(report) + destination = tmp_path/'synthetic.json'; destination.write_text(json.dumps(payload)) + source = root/'src/linkerhand_calibration/urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf' + output = tmp_path/'synthetic.urdf' + rebuild_urdf_from_json(destination, source, output, + authorized_fields=profile.urdf_authorized_fields, expected_profile_id=profile.key.profile_id) + expected = next(fixture.glob('*.urdf')) + def joints(path): + return {j.get('name'): {key: None if j.find(key) is None else j.find(key).attrib + for key in ('origin','axis','limit','parent','child','mimic')} for j in ET.parse(path).findall('joint')} + assert joints(output) == joints(expected) + report['raw_joint_audit']['candidate_uncertainty']['passed'] = False + with pytest.raises(ValueError, match='not_certified'): + from_report(report) + + +def test_runner_recognizes_capture_complete_without_starting_anything(): + from linkerhand_calibration.runtime.runner import drive_session + status = {'state':'CAPTURE_COMPLETE'} + monitor = SimpleNamespace(status=status) + result = drive_session(monitor, None, spin=lambda:None, ok=lambda:True, + request_start=lambda:pytest.fail('must not restart capture')) + assert result == status + + +def test_hold_freezes_budget_and_next_waypoint_cannot_reset_it(profile): + unit = CalibrationEngine(profile).scan_units()[0] + start = profile.command.baseline_values + guard = DirectionProgress(profile, unit, 1, start, 0.) + command = list(start); command[0] = 32. + segment = SimpleNamespace(phase=.5, deltas=[32.]+[0.]*19) + sweep = SimpleNamespace(phase='sweep') + hold = SimpleNamespace(phase='steady') + assert guard.observe(sweep, segment, command, start, 1, 0.).safe + assert guard.observe(hold, segment, command, start, 2, 3.).safe + before = guard.elapsed[0] + assert guard.observe(hold, segment, command, start, 3, 5.).safe + assert guard.elapsed[0] == before == 3. + command[0] = 64. + assert guard.observe(sweep, segment, command, start, 4, 5.).safe + result = guard.observe(sweep, segment, command, start, 5, 6.1) + assert not result.safe and guard.elapsed[0] == pytest.approx(4.1) + + +@pytest.mark.parametrize('outcome', ['still_stuck', 'recovered', 'feedback_stale', 'duplicate_controller']) +def test_raw_hardware_stall_has_one_delayed_retry(tmp_path, outcome): + from linkerhand_calibration.runtime.trajectory import build_calibration_motion_command + host, clock = coordinator_fixture(tmp_path, model='o30') + try: + ready(host, clock) + host.parameters = replace(host.parameters, reuse_camera_calibration=True) + assert host.start().success + session = host.execution.session + session._unit_index = 1 # First thumb roll return, no geometry needed. + session.phase = Phase.SWEEP + unit = session.current_unit + task = host.profile.motion.tasks[0] + start = build_calibration_motion_command(task, unit.start, profile=host.profile) + host.last_command = tuple(start) + feedback = list(start) + fault_sent = False + for _ in range(1000): + if host.state == 'PAUSED': + break + waiting = host.stall_retry.waiting + sent = len(clock.positions) + remaining = host.stall_retry.remaining(clock.now) + clock.now += .05 + if outcome in {'feedback_stale', 'duplicate_controller'} and waiting and not fault_sent: + fault_sent = True + if outcome == 'feedback_stale': + clock.now += 1.1 + host.tick() + continue + clock.publishers = 2 + if outcome == 'recovered' and host.stall_retry.failures and not waiting: + feedback = list(host.last_command) + heartbeat(host, clock, feedback) + host.tick() + if waiting and remaining > .1 and host.state != 'PAUSED': + assert len(clock.positions) == sent + if outcome == 'recovered' and host.stall_retry.failures and not host.stall_retry.waiting: + if host._direction_progress.travel[0] > 20.: + break + from linkerhand_calibration.runtime.acquisition import load_capture + rows = load_capture(host.raw_path) + failures = [r for r in rows if r.get('kind') == 'motion_stall_retry'] + resumes = [r for r in rows if r.get('kind') == 'motion_stall_retry_resumed'] + assert all(r['maximum_failures'] == 2 for r in failures) + if resumes: + assert resumes[0]['motion_id'] == failures[0]['motion_id'] + if outcome == 'still_stuck': + assert host.state == 'PAUSED' and 'consecutive_failures=2' in host.reason + assert [r['failure_count'] for r in failures] == [1,2] + assert len(resumes) == 1 and resumes[0]['waited_seconds'] >= 3. + elif outcome == 'recovered': + assert host.state != 'PAUSED', host.reason + assert len(failures) == len(resumes) == 1 + else: + assert host.state == 'PAUSED' and outcome in host.reason + assert len(failures) == 1 and not resumes + finally: + host.close() diff --git a/src/linkerhand_calibration/test/test_raw_partial_scope.py b/src/linkerhand_calibration/test/test_raw_partial_scope.py new file mode 100644 index 0000000..e153414 --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_partial_scope.py @@ -0,0 +1,68 @@ +"""Partial O30 capture keeps the lightweight path and the original motion contract.""" +from dataclasses import replace +from pathlib import Path + +from linkerhand_calibration.core.domain.capture_plan import configure_training, raw_joint_capture +from linkerhand_calibration.core.domain.profile import RetainedCadScope +from linkerhand_calibration.profiles import load_bundled_hand_profile, load_hand_profile +from linkerhand_calibration.profiles.export import dump_hand_profile +from linkerhand_calibration.profiles.scope import compile_capture_scope +from linkerhand_calibration.profiles.validator import validate_executable_profile, require_release_geometry +from linkerhand_calibration.runtime.engine import CalibrationEngine, SweepQuality +from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase + + +def thumb_profile(): + full = load_bundled_hand_profile('o30_right_18') + selected = frozenset(n for n in full.zero.active_joints if n.startswith('thumb_')) + retained = full.zero.active_joints-selected + scope = replace(full.scope, default_scope='thumb_only', + calibrate_joints={**full.scope.calibrate_joints, 'thumb_only': selected}, + frozen_joints={**full.scope.frozen_joints, 'thumb_only': retained}, + retained_cad_scopes={**full.scope.retained_cad_scopes, 'thumb_only': RetainedCadScope( + 'Only thumb is measured; other CAD joints remain uncalibrated.', + ('thumb_cmc_roll',), 'thumb_cmc_yaw', ('thumb_cmc_roll', 'thumb_cmc_yaw'))}) + return full, compile_capture_scope(replace(full, scope=scope)) + + +def test_partial_raw_schedule_preserves_routes_partitions_and_roundtrip(tmp_path): + full, partial = thumb_profile() + assert raw_joint_capture(partial) + assert configure_training(partial, 'raw_joint_2_plus_1') == partial + assert partial.motion.tasks == tuple(t for t in full.motion.tasks if t.key.startswith('thumb_')) + assert partial.command == full.command + assert partial.motion.return_groups == full.motion.return_groups + assert partial.acquisition == full.acquisition + assert partial.quality == full.quality + units = CalibrationEngine(partial).scan_units() + assert len(units) == 24 + assert units == tuple(u for u in CalibrationEngine(full).scan_units() if u.task_key.startswith('thumb_')) + assert set(u.cycle for u in units) == {0, 1, 3} + assert not partial.retained_joints & set(partial.zero.cad_zero_assumptions) + assert not partial.retained_joints & set(partial.urdf_authorized_fields) + path = tmp_path/'thumb.yaml' + path.write_text(dump_hand_profile(partial)) + assert load_hand_profile(path) == partial + source = Path(__file__).resolve().parents[1]/'urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf' + report = validate_executable_profile(partial, source) + require_release_geometry(report) + assert len(report['independent_measurement_coverage']['retained_uncalibrated_joints']) == 16 + + +def test_partial_raw_retry_is_immediate_bounded_and_finishes_after_return(): + _, partial = thumb_profile() + session = CalibrationSession(partial) + first, second = session.scheduled_units[:2] + bad = SweepQuality(False, ('missing_images',), (), {}) + for _ in range(2): + session.phase = Phase.EVALUATE + session.evaluation_complete(bad) + assert first.identity in session.incomplete_units + assert session.current_unit == second + assert session._retry_by_unit[first.identity] == 1 + while session.current_unit: + session.phase = Phase.EVALUATE + session.evaluation_complete(SweepQuality(True, (), (), {})) + assert session.phase == Phase.RETURN_BASELINE + session.return_complete() + assert session.phase == Phase.CAPTURE_COMPLETE diff --git a/src/linkerhand_calibration/test/test_raw_remount.py b/src/linkerhand_calibration/test/test_raw_remount.py new file mode 100644 index 0000000..9124cb2 --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_remount.py @@ -0,0 +1,195 @@ +"""A declared remount preserves good scans and cannot certify mixed mounts.""" +from copy import deepcopy +from pathlib import Path + +import pytest + +from linkerhand_calibration.profiles import load_bundled_hand_profile +from linkerhand_calibration.runtime.engine import CalibrationEngine +from linkerhand_calibration.runtime.raw_resume import RawCheckpoint +from linkerhand_calibration.runtime.raw_remount import REMOUNT_RECORD, remount_prefix +from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase +from linkerhand_calibration.runtime.artifacts.capture_validation import validate_capture_provenance +from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel + + +@pytest.fixture +def model(): + return UrdfKinematicModel(Path(__file__).resolve().parents[1]/'urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf') + + +@pytest.fixture +def checkpoint(): + profile = load_bundled_hand_profile('o30_right_18') + units = CalibrationEngine(profile).scan_units() + rows = [dict(kind='session_start', resume_checkpoint_requested=False)] + for i, unit in enumerate(units[:78]): + base = dict(task_name=unit.task_key, cycle=unit.cycle, direction=unit.direction) + if i == 31: + rows.append(dict(kind='scan_unit_complete', **base, attempt=1, passed=False, + failures=['missing_images'])) + attempt = 2 if i == 31 else 1 + rows.append(dict(kind='raw_observation_frame', **base, attempt=attempt, + view='side', image_stamp_ns=i+1, tags={'index_pip': {'corners_xy': [[i, i]]}, + 'pinky_pip': {'corners_xy': [[1., 2.]]}})) + rows.append(dict(kind='scan_unit_complete', **base, attempt=attempt, passed=True)) + unit = units[78] + for attempt in (1, 2): + rows.append(dict(kind='scan_unit_complete', task_name=unit.task_key, + cycle=unit.cycle, direction=unit.direction, attempt=attempt, passed=False, + failures=['missing_images'])) + return profile, RawCheckpoint({}, {}, tuple(rows), frozenset(u.identity for u in units[:78])) + + +def test_remount_preserves_prefix_pixels_and_attempts_and_excludes_old_tag(checkpoint, model): + profile, source = checkpoint + original = deepcopy(source.rows) + rows, report = remount_prefix(profile, source, [10], model) + assert source.rows == original + assert len(report['retained_units']) == 78 and report['remaining_units'] == 30 + assert report['restart_unit'] == ['index_mcp_pitch_side', 0, 'increasing'] + assert report['excluded_old_tag_observations'] == 78 + assert report['excluded_old_roles'] == ['index_dip', 'index_mcp_roll', 'index_pip'] + assert [row['attempt'] for row in report['replaced_attempts']] == [1, 2] + for row in rows: + if row['kind'] == 'raw_observation_frame': + assert set(row['tags']) == {'pinky_pip'} + old = next(r for r in source.rows if r.get('image_stamp_ns') == row['image_stamp_ns']) + assert row['tags']['pinky_pip'] == old['tags']['pinky_pip'] + session = CalibrationSession(profile) + restored = RawCheckpoint({}, {}, (*rows, report), source.completed_units) + assert restored.restore(session) == () + assert list(session.current_unit.identity) == report['restart_unit'] + assert session.action().retry == 0 + unit = session.scheduled_units[31] + assert session._retry_by_unit[unit.identity] == 1 + assert session.phase == CalibrationPhase.PREPARE + with pytest.raises(ValueError, match='already_declared'): + remount_prefix(profile, restored, [10], model) + + +@pytest.mark.parametrize('ids', [[3], [999], [], [1]]) +def test_remount_does_not_accept_fixed_unknown_or_nonprefix_changes(checkpoint, model, ids): + profile, source = checkpoint + with pytest.raises(ValueError): + remount_prefix(profile, source, ids, model) + + +def test_scope_refinement_is_allowed_only_before_new_scanning(checkpoint, model): + profile, source = checkpoint + rows, report = remount_prefix(profile, source, [10], model) + old_report = {**report, 'excluded_old_roles': ['index_pip']} + prepared = RawCheckpoint({}, {}, (*rows, old_report), source.completed_units) + refined, _ = remount_prefix(profile, prepared, [10], model) + assert refined == rows + unit = CalibrationEngine(profile).scan_units()[78] + pending = dict(kind='raw_observation_frame', task_name=unit.task_key, + cycle=unit.cycle, direction=unit.direction, attempt=1) + started = RawCheckpoint({}, {}, (*prepared.rows, pending), source.completed_units) + with pytest.raises(ValueError, match='already_declared'): + remount_prefix(profile, started, [10], model) + + +def reference(profile): + from linkerhand_calibration.runtime.resume import ACQUISITION_POLICY_VERSION + pose = dict(rotation_xyzw=[0., 0., 0., 1.], translation_xyz_m=[0., 0., 1.]) + points = [[0., 0.], [10., 0.], [10., 10.], [0., 10.]] + return dict(profile_id=profile.key.profile_id, acquisition_policy_version=ACQUISITION_POLICY_VERSION, + protected_hashes={'source_urdf_sha256': 'a'*64}, + fixed_corners_by_view={view: deepcopy(points) for view in profile.vision.view_names}, + fixed_poses={view: pose for view in profile.vision.view_names}, moving_tag_poses={}, + raw_installation_reference={'side:pinky_pip': dict(corners_xy=deepcopy(points), + feedback=[0.]*profile.command.command_count)}) + + +def test_unseen_installation_is_draft_only_but_known_drift_still_rejected(checkpoint): + profile, source = checkpoint + old = reference(profile) + row = dict(kind='raw_observation_frame', view='side', tags={'pinky_pip': {}, 'ring_pip': {}}) + strict = RawCheckpoint({}, old, (row,), frozenset()) + with pytest.raises(ValueError, match='observation_missing'): + strict.verify_reference(profile, deepcopy(old)) + draft = RawCheckpoint({}, old, (row, dict(kind=REMOUNT_RECORD)), frozenset()) + result = draft.verify_reference(profile, deepcopy(old)) + assert result['unavailable_roles'] == ['side:ring_pip'] and result['draft_only'] + moved = deepcopy(old) + moved['raw_installation_reference']['side:pinky_pip']['corners_xy'][0][0] += 20 + with pytest.raises(ValueError, match='installation_changed'): + draft.verify_reference(profile, moved) + moved = deepcopy(old) + moved['fixed_corners_by_view']['front'][0][0] += 20 + with pytest.raises(ValueError, match='reference_changed'): + draft.verify_reference(profile, moved) + with pytest.raises(ValueError, match='separate_installation_certification'): + validate_capture_provenance(profile, 'serial', {}, draft.rows) + + +def test_remount_cli_rejects_missing_declaration_before_reading_configuration(): + from linkerhand_calibration.runtime.runner import main + with pytest.raises(SystemExit) as error: + main(['--config', '/nonexistent', '--prepare-remount-from', '/old']) + assert error.value.code == 2 + + +def test_raw_resume_defers_moving_baseline_difference_but_release_remains_strict(checkpoint): + profile, _ = checkpoint + old = reference(profile) + row = dict(kind='raw_observation_frame', view='side', tags={'pinky_pip': {}}) + source = RawCheckpoint({}, old, (row,), frozenset()) + current = deepcopy(old) + current['raw_installation_reference']['side:pinky_pip']['corners_xy'][0][0] += 12 + current['raw_installation_reference']['side:pinky_pip']['feedback'][5] = 7. + before = deepcopy(current) + audit = source.verify_reference(profile, current, require_installation=False) + assert current == before + assert audit['changed_roles'] == ['side:pinky_pip'] + assert audit['checked_roles'] == [] and audit['fixed_reference_verified'] + assert audit['requires_offline_review'] and not audit['installation_verified'] + assert audit['baseline_diagnostics']['side:pinky_pip'] == dict( + maximum_corner_difference_px=12., maximum_feedback_difference=7.) + with pytest.raises(ValueError, match='installation_changed'): + source.verify_reference(profile, current) + records = [dict(kind='raw_resume_verified', installation_verification=audit)] + with pytest.raises(ValueError, match='resume_installation_requires_offline_review'): + validate_capture_provenance(profile, 'serial', {}, records) + # Draft permission only defers installation certification, not provenance. + with pytest.raises(ValueError, match='requires_unique_session'): + validate_capture_provenance(profile, 'serial', {}, records, allow_installation_draft=True) + current['fixed_corners_by_view']['front'][0][0] += 20 + with pytest.raises(ValueError, match='reference_changed'): + source.verify_reference(profile, current, require_installation=False) + + +def test_missing_moving_reference_is_audited_and_invalid_feedback_still_rejected(checkpoint): + profile, _ = checkpoint + old = reference(profile) + row = dict(kind='raw_observation_frame', view='side', tags={'pinky_pip': {}, 'ring_pip': {}}) + source = RawCheckpoint({}, old, (row,), frozenset()) + audit = source.verify_reference(profile, deepcopy(old), require_installation=False) + assert audit['unavailable_roles'] == ['side:ring_pip'] and audit['requires_offline_review'] + invalid = deepcopy(old) + invalid['raw_installation_reference']['side:pinky_pip']['feedback'][0] = float('nan') + with pytest.raises(ValueError, match='installation_invalid'): + source.verify_reference(profile, invalid, require_installation=False) + + +def test_coordinator_records_unverified_moving_baseline_without_pausing(tmp_path): + from runtime_host_fixture import coordinator_fixture + from linkerhand_calibration.runtime.acquisition import load_capture + host, _ = coordinator_fixture(tmp_path, model='o30') + try: + old = reference(host.profile) + current = deepcopy(old) + current['raw_installation_reference']['side:pinky_pip']['corners_xy'][0][0] += 12 + row = dict(kind='raw_observation_frame', view='side', tags={'pinky_pip': {}}) + host._resume_checkpoint = RawCheckpoint({}, old, (row,), frozenset()) + host._current_fingerprint = current + host._verify_resume() + assert host._raw_resume_import is not None + assert host.execution.session.phase != CalibrationPhase.PAUSED + records = load_capture(host.raw_path) + audit = next(r['installation_verification'] for r in records if r.get('kind') == 'raw_resume_verified') + assert audit['requires_offline_review'] and not audit['installation_verified'] + assert '待离线复核' in host._resume_message + finally: + host.close() diff --git a/src/linkerhand_calibration/test/test_raw_resume.py b/src/linkerhand_calibration/test/test_raw_resume.py deleted file mode 100644 index ad5c41d..0000000 --- a/src/linkerhand_calibration/test/test_raw_resume.py +++ /dev/null @@ -1,112 +0,0 @@ -from dataclasses import replace - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import RAW_JOINT, configure_training -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.raw_resume import RawCheckpoint -from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase - - -def profile(): - return configure_training(load_bundled_hand_profile('o30_right_18'), RAW_JOINT) - - -def completion(unit, *, passed=True, attempt=1): - return dict(kind='scan_unit_complete', task_name=unit.task_key, - cycle=unit.cycle, direction=unit.direction, segment_key=unit.segment_key, - attempt=attempt, passed=passed, failures=[] if passed else ['missing_images']) - - -def test_restore_keeps_failed_unit_at_tail_and_does_not_reset_budget(): - session = CalibrationSession(profile()) - first, second, third = session.scheduled_units[:3] - rows = (completion(first, passed=False), completion(second)) - checkpoint = RawCheckpoint({}, {}, rows, frozenset((second.identity,))) - assert checkpoint.restore(session) == () - assert session.current_unit == third - assert session.scheduled_units[-1] == first - assert session._retry_by_unit[first.identity] == 1 - assert session.phase == Phase.PREPARE - - -def test_interrupted_second_attempt_is_missing_not_a_third_capture(): - session = CalibrationSession(profile()) - first, *others = session.scheduled_units - rows = [completion(first, passed=False), *(completion(u) for u in others)] - rows.append({**completion(first, attempt=2), 'kind': 'raw_observation_frame'}) - checkpoint = RawCheckpoint({}, {}, tuple(rows), frozenset(u.identity for u in others)) - interrupted = checkpoint.restore(session) - assert interrupted[0]['attempt'] == 2 - assert interrupted[0]['failures'] == ['interrupted_attempt'] - assert session.phase == Phase.RETURN_BASELINE - session.return_complete() - assert session.phase == Phase.FAILED - assert session.incomplete_units[first.identity] - - -def test_interrupted_first_attempt_moves_to_tail_once_across_restart(): - original = CalibrationSession(profile()) - unit = original.current_unit - row = {**completion(unit), 'kind': 'raw_observation_frame'} - checkpoint = RawCheckpoint({}, {}, (row,), frozenset()) - interrupted = checkpoint.restore(original) - restored = CalibrationSession(profile()) - checkpoint = replace(checkpoint, rows=(row, *interrupted)) - assert checkpoint.restore(restored) == () - assert restored.scheduled_units == original.scheduled_units - assert restored._retry_by_unit == original._retry_by_unit - - -def test_reordered_checkpoint_cannot_skip_work(): - session = CalibrationSession(profile()) - second = session.scheduled_units[1] - checkpoint = RawCheckpoint({}, {}, (completion(second),), frozenset((second.identity,))) - with pytest.raises(ValueError, match='schedule_order_changed'): - checkpoint.restore(session) - - -def test_reference_uses_raw_installation_pixels_not_moving_pnp_branch(): - from copy import deepcopy - from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION - selected = profile() - corners = [[0., 0.], [10., 0.], [10., 10.], [0., 10.]] - pose = dict(rotation_xyzw=[0., 0., 0., 1.], translation_xyz_m=[0., 0., 1.]) - reference = dict(profile_id=selected.key.profile_id, - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - protected_hashes={'source_urdf_sha256': 'a'*64}, - fixed_corners_by_view={v: corners for v in selected.vision.view_names}, - fixed_poses={v: pose for v in selected.vision.view_names}, - moving_tag_poses={'front:thumb_mcp': pose}, - raw_installation_reference={'front:thumb_mcp': dict(corners_xy=deepcopy(corners), - feedback=list(selected.command.baseline_values))}) - current = deepcopy(reference) - current['moving_tag_poses'] = {} - checkpoint = RawCheckpoint({}, reference, (dict(kind='raw_observation_frame', - view='front', tags={'thumb_mcp': {}}),), frozenset()) - checkpoint.verify_reference(selected, current) - current['raw_installation_reference']['front:thumb_mcp']['corners_xy'][0][0] += 20. - with pytest.raises(ValueError, match='raw_resume_installation_changed'): - checkpoint.verify_reference(selected, current) - - -def test_discovery_preserves_interrupted_first_attempt_without_any_passed_unit(tmp_path): - import json - from linkerhand_calibration.core.domain.capture_plan import CapturePlan - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - from linkerhand_calibration.runtime.engine import CalibrationEngine, ACQUISITION_POLICY_VERSION - from linkerhand_calibration.runtime.resume import discover_resume_candidate - selected = profile() - hashes = {key:'a'*64 for key in selected.artifacts.protected_input_fields} - directory = tmp_path/'20260922_first_attempt'; directory.mkdir() - header = dict(kind='session_start', profile_id=selected.key.profile_id, serial_number='RAW_TEST', - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, - capture_schedule_version=CalibrationEngine(selected).capture_schedule_version, - capture_plan=CapturePlan.from_profile(selected).as_dict(), resume_checkpoint_requested=False, **hashes) - row = {**completion(CalibrationEngine(selected).scan_units()[0]),'kind':'raw_observation_frame'} - records = [header,dict(kind='fixed_base_reference_locked'),row] - (directory/'raw_samples.jsonl').write_text('\n'.join(json.dumps(r) for r in records)+'\n') - found = discover_resume_candidate(tmp_path,profile_id=selected.key.profile_id,serial_number='RAW_TEST', - protected_hashes=hashes,training_policy=RAW_JOINT) - assert found == directory diff --git a/src/linkerhand_calibration/test/test_recorded_diagnostic_replay.py b/src/linkerhand_calibration/test/test_recorded_diagnostic_replay.py deleted file mode 100644 index 0cf42e6..0000000 --- a/src/linkerhand_calibration/test/test_recorded_diagnostic_replay.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Real capture segments test evidence, never a fabricated complete hand run. - -175815 has old sweep images but no new pre-zero motion record. 192637 has the -actual pre-zero motion but stopped before its IP sweeps. They are replayed as -separate sessions; neither is promoted to a fourth-round accuracy certificate. -""" - -from copy import deepcopy -import gzip -import hashlib -import json -from pathlib import Path - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.pnp import solve_square_tag_ippe -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization -from linkerhand_calibration.runtime.diagnostic_capture import reject_diagnostic_capture -from linkerhand_calibration.runtime.diagnostic_quality import evaluate_diagnostic_unit -from linkerhand_calibration.runtime.diagnostic_report import motion_initialization_summary -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 - - -SOURCE_HASHES = { - "175815": "8bdbf216bd4135f1bac16dee0535a8b72ea0e6b0f31ab2b9a2e8cc4a83be9c0d", - "192637": "757ba1d7cccae4bdb93553b2735e94862bc74f57f153198e1a9d3eff76d45c8d", -} - - -def recorded_fixture(stamp): - path = Path(__file__).with_name("fixtures") / f"o6_front_20260914_{stamp}_raw_replay.json.gz" - value = json.loads(gzip.decompress(path.read_bytes())) - assert value["source_raw_sha256"] == SOURCE_HASHES[stamp] - assert value["publication_allowed"] is value["is_accuracy_certificate"] is False - return value - - -def replay_actual_front_initialization(): - fixture = recorded_fixture("192637") - profile = load_bundled_hand_profile("o6_right_8") - recorded = fixture["recorded_initialization"] - joints = tuple(recorded["zero_joints"]) - task = next(task for task in profile.motion.tasks if task.key == recorded["task_name"]) - target = profile.motion.joint_zero_references[joints[0]].command - common = dict(task_key=task.key, command_index=task.command_index) - # Reproduce this archived v4 decision with its original 3D candidate - # policy. The new image solver has separate original-corner regressions; - # it cannot change the meaning of this historical unresolved session. - owner = BranchInitialization(profile, image_geometry=False) - owner.begin(MotionCommand("zero_approach", target, 40., reference_joints=joints, **common), - session_epoch=recorded["session_epoch"], motion_version=recorded["motion_version"]) - for row in fixture["records"]: - owner.observe(row) - zero = MotionCommand("joint_zero", target, 40., zero_joints=joints, **common) - owner.begin(zero, session_epoch=recorded["session_epoch"], motion_version=recorded["motion_version"]+1) - request = owner.request("front", zero, session_epoch=recorded["session_epoch"], - motion_version=recorded["motion_version"]+1) - result = solve_initialization(request) - model, report = owner.accept(request, result) - return fixture, owner, zero, model, report - - -def test_recorded_pre_zero_frames_reach_runtime_decision_without_forged_continuation(): - fixture, owner, zero, model, report = replay_actual_front_initialization() - recorded = fixture["recorded_initialization"] - assert len(fixture["records"]) == 120 - assert all(row["sample_phase"] == "zero_approach" for row in fixture["records"]) - assert report["source_image_stamps"] == recorded["source_image_stamps"] - assert report["source_frame_hashes"] == recorded["source_frame_hashes"] - for row in fixture["records"]: - assert source_frame_sha256(row) == report["source_frame_hashes"][str(row["image_stamp_ns"])] - # This is the actual unresolved runtime evidence, not a test claiming - # that rejecting it repaired physical accuracy or completed its IP sweep. - assert model is None and not owner.ready(zero) - assert report["status"] == recorded["status"] == "unresolved" - assert report["reason"] == recorded["reason"] == "motion_geometry_unresolved" - assert not report["evidence_ids"] and not report["constraints"] - for actual, baseline in zip(report["hypotheses"], recorded["hypotheses"], strict=True): - assert actual["accepted"] == baseline["accepted"] - for current, previous in zip(actual["relations"], baseline["relations"], strict=True): - assert current["reason"] == previous["reason"] - for key, value in previous.items(): - if isinstance(value, (int, float)): - assert current[key] == pytest.approx(value, rel=1e-9, abs=1e-10) - - -def test_report_identifies_the_ip_relation_without_promoting_local_pitch_fit(): - fixture = recorded_fixture("192637") - summaries = motion_initialization_summary([fixture["recorded_initialization"]]) - report, = summaries - assert report["status"] == "unresolved" and report["source_image_count"] == 120 - pitch = report["joint_hypotheses"]["rh_thumb_cmc_pitch"] - ip = report["joint_hypotheses"]["rh_thumb_ip"] - assert any(not item["reason"] for item in pitch) - assert not any(item["chain_accepted"] for item in (*pitch, *ip)) - supported_parent = [item for item in ip if item["other_relations_passed"]] - assert len(supported_parent) == 2 - assert all(item["reason"] == "motion_geometry_residual" for item in supported_parent) - assert min(item["pivot_rms_m"] for item in supported_parent) == pytest.approx(.005102, abs=1e-6) - - -def test_legacy_real_sweep_corners_replay_without_becoming_new_zero_or_accepted_angles(): - fixture = recorded_fixture("175815") - records = fixture["records"] - before = deepcopy(records) - profile = load_bundled_hand_profile("o6_right_8") - for direction, count in (("decreasing", 198), ("increasing", 197)): - rows = [row for row in records if row["direction"] == direction] - assert len(rows) == count - for row in rows: - for role in ("thumb_pitch", "thumb_ip"): - tag = row["tags"][role] - candidates = solve_square_tag_ippe(tag["corners_xy"], - tag_size_m=tag["tag_size_m"], camera_matrix=row["camera_matrix"]) - assert candidates - for recorded in tag["reprojection_valid_candidates"]: - assert any(np.allclose(candidate.translation_xyz_m, - recorded["translation_xyz_m"], rtol=1e-10, atol=1e-12) - for candidate in candidates) - unit = next(unit for unit in CalibrationEngine(profile).scan_units() - if unit.task_key == "thumb_pitch_ip_front" and unit.cycle == 0 and unit.direction == direction) - quality = evaluate_diagnostic_unit(profile, unit, 1, rows) - assert quality.observations.passed, quality.observations.failures - assert not quality.accepted_poses.passed - assert all(item["valid_frames"] == 0 for item in quality.accepted_poses.metrics.values()) - with pytest.raises(ValueError, match="diagnostic_capture_not_for_publication_or_resume"): - reject_diagnostic_capture([fixture["session_start"], *records]) - assert records == before - assert not any(row.get("kind") in {"joint_zero_reference", "joint_sample"} for row in records) - - -@pytest.mark.parametrize("stamp,expected", SOURCE_HASHES.items()) -def test_fixture_sources_remain_bound_when_full_local_journals_are_available(stamp, expected): - workspace = Path(__file__).resolve().parents[3] - path = workspace / "calibration_output/O6_RIGHT_001" / f"20260914_{stamp}" / "raw_samples.jsonl" - if not path.exists(): - pytest.skip("完整本地实测日志不可用;已有证据切片的回放另行执行") - assert hashlib.sha256(path.read_bytes()).hexdigest() == expected - fixture = recorded_fixture(stamp) - recorded = {(row.get("kind"), row.get("view"), row.get("image_stamp_ns")): row - for row in (json.loads(line) for line in path.read_text().splitlines())} - assert all(recorded[row["kind"], row["view"], row["image_stamp_ns"]] == row - for row in fixture["records"]) diff --git a/src/linkerhand_calibration/test/test_recorded_side_preparation.py b/src/linkerhand_calibration/test/test_recorded_side_preparation.py deleted file mode 100644 index 57d0a25..0000000 --- a/src/linkerhand_calibration/test/test_recorded_side_preparation.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Use the available preparation arc without reusing validation images.""" - -import gzip -import json -from dataclasses import replace -from pathlib import Path - -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ( - ImageMotionParameters, resolve_image_motion, -) - - -def test_recorded_side_preserves_holdout_and_uses_source_geometry(): - path = Path(__file__).parent / "fixtures/o6_side_preparation_132102.json.gz" - payload = json.loads(gzip.decompress(path.read_bytes())) - rows = payload["records"] - config = load_product_config(Path(__file__).resolve().parents[1]/"config/o6_right_product.yaml", - check_can=False) - profile = config.calibration_contract.typed_profile - first = rows[0] - owner = BranchInitialization(profile, source_urdf=config.source_urdf) - motion = MotionCommand("zero_approach", profile.command.baseline_values, 40., task_key=first["task_name"], - reference_joints=tuple(first["zero_joints"])) - version, epoch = first["motion_version"], first["session_epoch"] - owner.begin(motion, session_epoch=epoch, motion_version=version) - for row in rows: - owner.observe(row) - motion = replace(motion, phase="joint_zero", zero_joints=motion.reference_joints) - request = owner.request("side", motion, session_epoch=epoch, motion_version=version + 1) - assert len(request.image_frames) == 121 - old = resolve_image_motion(request.image_frames, request.relations, - parameters=ImageMotionParameters(maximum_training_frames=36)) - assert not old.resolved and old.reason == "image_motion_families_not_distinguishable" - # Current production uses the same source geometry as the final URDF. - # Adding frames to the old free-geometry fit is not sufficient evidence - # that its competing three-dimensional reconstructions are distinguishable. - assert request.geometry_constraints and request.source_urdf_sha256 - result = solve_initialization(request) - assert result.resolved, result.reason - assert result.model.source_urdf_sha256 == request.source_urdf_sha256 - assert len(result.model.training_stamps_ns) == 61 - assert len(result.model.validation_stamps_ns) == 60 - assert set(result.model.training_stamps_ns).isdisjoint(result.model.validation_stamps_ns) - probabilities = [p for h in result.hypotheses for _, p in h.comparison_p_values] - assert len(probabilities) == 3 - assert max(probabilities) <= .01 / 3 diff --git a/src/linkerhand_calibration/test/test_rectified_camera_contract.py b/src/linkerhand_calibration/test/test_rectified_camera_contract.py deleted file mode 100644 index 16d4654..0000000 --- a/src/linkerhand_calibration/test/test_rectified_camera_contract.py +++ /dev/null @@ -1,73 +0,0 @@ -from types import SimpleNamespace -import numpy as np -import pytest -import cv2 -from scipy.spatial.transform import Rotation as R - -from linkerhand_calibration.core.geometry.camera import rectified_camera_matrix -from linkerhand_calibration.pnp import solve_square_tag_ippe, square_object_points - - -def test_rectified_corners_require_projection_not_raw_intrinsics(): - k=np.array([[3816.108989,0,620.129591],[0,3796.2534,570.26288],[0,0,1.]]) - p=np.array([[3792.732422,0,606.014017,0],[0,3825.369629,568.524418,0],[0,0,1,0.]]) - expected=R.from_euler('xyz',[.2,-.5,.3]) - t=np.array([.12,.05,1.]) - corners=cv2.projectPoints(square_object_points(.016),expected.as_rotvec(),t,p[:,:3],np.zeros(4))[0].reshape(4,2) - candidates=solve_square_tag_ippe(corners,tag_size_m=.016,camera_matrix=rectified_camera_matrix(p)) - errors=[(expected.inv()*R.from_quat(c.quaternion_xyzw)).magnitude() for c in candidates] - assert min(errors)<1e-6 - wrong=solve_square_tag_ippe(corners,tag_size_m=.016,camera_matrix=k) - assert min((expected.inv()*R.from_quat(c.quaternion_xyzw)).magnitude() for c in wrong)>.001 - - -@pytest.mark.parametrize('bad',[[],[0.]*12,[float('nan')]*12]) -def test_invalid_p_cannot_silently_fall_back_to_k(bad): - with pytest.raises(ValueError):rectified_camera_matrix(bad) - - -def test_shared_camera_callback_uses_p_saves_provenance_and_invalidates(tmp_path): - import json - p=[500.,0,320,0,0,510,240,0,0,0,1,0] - msg=SimpleNamespace(p=p,k=[600.,0,300,0,620,220,0,0,1],d=[.1]*5,r=np.eye(3).ravel().tolist(),width=640,height=480) - node = camera_host(tmp_path, msg) - callback=accept_camera - callback(node,'front',msg); callback(node,'front',msg) - np.testing.assert_allclose(node.matrices['front'],np.array(p).reshape(3,4)[:,:3]) - records=[json.loads(l) for l in node.raw_path.read_text().splitlines()] - assert len(records)==1 and records[0]['raw_k']==msg.k - assert records[0]['matrix_source']=='CameraInfo.P[:3,:3]' - msg.p=[0.]*12 - callback(node,'front',msg) - assert 'front' not in node.matrices - - -def camera_host(tmp_path, message): - from linkerhand_calibration.runtime.cameras import CameraObservations - from linkerhand_calibration.core.geometry.extrinsics import camera_info_fingerprint - identity = SimpleNamespace(width=message.width, height=message.height, - intrinsics_sha256=camera_info_fingerprint(width=message.width, height=message.height, - camera_matrix=message.k, distortion=message.d, rectification=message.r, projection=message.p)) - return CameraObservations(SimpleNamespace(cameras={'front': identity}), tmp_path/'raw.jsonl', lambda: 10.) - - -def accept_camera(host, view, message, *, started=False, pause=lambda _: None): - from linkerhand_calibration.runtime.ros.io import camera_input - host.accept(view, camera_input(message), started=started, pause=pause, trackers={}) - - -@pytest.mark.parametrize('field', ['k', 'd', 'r', 'p']) -def test_live_camera_model_must_match_protected_extrinsics(tmp_path, field): - msg = SimpleNamespace(p=[500.,0,320,0,0,510,240,0,0,0,1,0], - k=[600.,0,300,0,620,220,0,0,1], d=[.1]*5, - r=np.eye(3).ravel().tolist(), width=640, height=480) - node = camera_host(tmp_path, msg) - callback = accept_camera - callback(node, 'front', msg) - assert 'front' in node.matrices - pauses = [] - getattr(msg, field)[0] += .01 - callback(node, 'front', msg, started=True, pause=pauses.append) - assert 'front' not in node.matrices - assert 'front' not in node.info_received_at - assert pauses == ['protected_camera_model_changed:front:camera_info_does_not_match_extrinsics'] diff --git a/src/linkerhand_calibration/test/test_reference_lock.py b/src/linkerhand_calibration/test/test_reference_lock.py deleted file mode 100644 index 5494277..0000000 --- a/src/linkerhand_calibration/test/test_reference_lock.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import numpy as np - -from linkerhand_calibration.runtime import ReferenceLock - - -def _corners(offset: float = 0.0): - return np.asarray( - [[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]] - ) + offset - - -def test_preview_and_baseline_motion_are_discarded_before_lock() -> None: - lock = ReferenceLock({"camera": 7}) - for _ in range(20): - lock.observe("camera", 7, _corners(100.0)) - lock.begin_session() - for _ in range(20): - lock.observe("camera", 7, _corners(50.0)) - assert not lock.locked - - lock.start_locking() - for _ in range(9): - lock.observe("camera", 7, _corners()) - assert not lock.locked - lock.observe("camera", 7, _corners()) - assert lock.locked - assert np.allclose(lock.references["camera"], _corners()) - - -def test_locked_reference_is_not_rewritten_and_needs_ten_bad_frames() -> None: - lock = ReferenceLock({"camera": 7}) - lock.begin_session() - lock.start_locking() - for _ in range(10): - lock.observe("camera", 7, _corners()) - original = lock.fingerprint() - - for _ in range(9): - assert lock.observe("camera", 7, _corners(6.0)) is None - movement = lock.observe("camera", 7, _corners(6.0)) - assert movement is not None - assert movement.confirmation_frames == 10 - assert lock.fingerprint() == original diff --git a/src/linkerhand_calibration/test/test_reference_waypoints.py b/src/linkerhand_calibration/test/test_reference_waypoints.py deleted file mode 100644 index 445a149..0000000 --- a/src/linkerhand_calibration/test/test_reference_waypoints.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import replace -import pytest - -from linkerhand_calibration.core.domain.profile import ReferenceWaypoint -from linkerhand_calibration.profiles import load_bundled_hand_profile, dump_hand_profile, load_hand_profile -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.resume import select_installation_evidence, ResumeVerifier -from test_resume_v2 import _fingerprint - - -def test_reference_waypoint_cannot_drive_disabled_channel(): - from linkerhand_calibration.core.domain.profile import validate_profile - profile = load_bundled_hand_profile("g20_right_19") - channel = next(iter(profile.command.disabled_indices)) - pose = list(profile.command.baseline_values) - pose[channel] -= 1 - view = profile.vision.views[0] - tag = next(tag for tag in view.tags if not tag.fixed_reference) - waypoint = ReferenceWaypoint("show", tuple(pose), {view.name: (tag.tag_id,)}) - profile = replace(profile, motion=replace(profile.motion, resume_verification_waypoints=(waypoint,))) - with pytest.raises(ValueError, match="disabled channels"): - validate_profile(profile) - - -def test_reference_waypoints_share_executor_and_return_before_resume(tmp_path): - profile = load_bundled_hand_profile("l6_right_8") - # Legacy v2 sessions retain their all-at-start verification waypoint API. - profile = replace(profile, artifacts=replace(profile.artifacts, output_schema_version=2, - directional_command_joints=frozenset())) - pose = (230., 255., 255., 255., 255., 255.) - waypoint = ReferenceWaypoint("show_tip", pose, {"front": (2,)}) - profile = replace(profile, motion=replace(profile.motion, resume_verification_waypoints=(waypoint,))) - path = tmp_path/"virtual.yaml" - path.write_text(dump_hand_profile(profile)) - assert load_hand_profile(path) == profile - engine = SessionExecution(profile) - engine.session.device_ready() - engine.session.start(resume_requested=True) - current = profile.command.baseline_values - while engine.session.phase == Phase.BASELINE: - motion = engine.motion(current); current = motion.target; engine.motion_complete() - engine.session.reference_locked() - assert engine.session.phase == Phase.REFERENCE_POSES - motion = engine.motion(current) - assert motion.phase == "reference_pose" and motion.target == pose - while engine.session.phase == Phase.REFERENCE_POSES: - motion = engine.motion(current); current = motion.target; engine.motion_complete() - assert current == profile.command.baseline_values - assert engine.session.phase == Phase.RESUME_VERIFY - - -def test_resume_never_compares_mounts_from_different_waypoint_poses(): - fingerprint = _fingerprint() - pose = fingerprint.moving_tag_poses["finger"] - old = replace(fingerprint, moving_tag_poses={"show/finger": pose}) - new = replace(fingerprint, moving_tag_poses={"other/finger": pose}) - checker = ResumeVerifier(required_moving_poses=("finger",)) - assert not checker.compare(*select_installation_evidence(old, new, {"finger"})).reuse - assert checker.compare(*select_installation_evidence(old, old, {"finger"})).reuse - missing = replace(fingerprint, moving_tag_poses={}) - assert not checker.compare(*select_installation_evidence(old, missing, {"finger"})).reuse diff --git a/src/linkerhand_calibration/test/test_relative_standard_mimic.py b/src/linkerhand_calibration/test/test_relative_standard_mimic.py deleted file mode 100644 index 14caefa..0000000 --- a/src/linkerhand_calibration/test/test_relative_standard_mimic.py +++ /dev/null @@ -1,53 +0,0 @@ -import math -from dataclasses import replace - -import numpy as np -import pytest - -from linkerhand_calibration.core.fitting.coupling import ( - PairedJointObservation, fit_relative_standard_mimic, -) - - -def observations(*, nonlinear=False): - rows = [] - for cycle in range(4): - for direction in ("increasing", "decreasing"): - for index, q in enumerate(np.linspace(0, 1.4, 65)): - # Independently generated fixed mounting phases; neither is a - # physical zero. Backlash in the driver affects both joints. - q += .003 if direction == "decreasing" else 0 - child = .87*q + (.22*q*q if nonlinear else 0) - rows.append(PairedJointObservation(f"camera:{cycle}:{direction}:{index}", - cycle, direction, q+.24, child-.31)) - return rows - - -def test_unknown_mounting_phase_is_not_exported_as_passive_zero(): - result = fit_relative_standard_mimic("driver", "follower", observations(), cad_offset_rad=.012) - assert result.fit.multiplier == pytest.approx(.87, abs=1e-10) - assert result.fit.offset_rad == .012 - assert not result.fit.offset_observed - assert result.installation_phase_rad == pytest.approx(-.31-.87*.24) - assert max(abs(v) for v in result.holdout_errors_rad) < 1e-10 - - -def test_nonlinear_motion_cannot_hide_behind_json_or_endpoint_ratio(): - with pytest.raises(ValueError, match="standard_urdf_mimic_not_expressive"): - fit_relative_standard_mimic("driver", "follower", observations(nonlinear=True), cad_offset_rad=0) - - -def test_holdout_installation_slip_cannot_be_recentred(): - rows = [replace(row, target_rad=row.target_rad + (math.radians(5) if row.cycle == 3 else 0)) - for row in observations()] - with pytest.raises(ValueError, match="phase=holdout"): - fit_relative_standard_mimic("driver", "follower", rows, cad_offset_rad=0) - - -def test_both_directions_require_independent_paired_images(): - rows = observations() - with pytest.raises(ValueError, match="duplicated"): - fit_relative_standard_mimic("driver", "follower", rows + [rows[0]], cad_offset_rad=0) - with pytest.raises(ValueError, match="missing paired"): - fit_relative_standard_mimic("driver", "follower", - [row for row in rows if not (row.cycle == 3 and row.direction == "decreasing")], cad_offset_rad=0) diff --git a/src/linkerhand_calibration/test/test_resume_storage.py b/src/linkerhand_calibration/test/test_resume_storage.py deleted file mode 100644 index 636c7e7..0000000 --- a/src/linkerhand_calibration/test/test_resume_storage.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Passed resume keeps scan payloads on disk until their durable import batch.""" - -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.runtime.resume_storage import ( - DeferredCaptureRecord, load_passed_capture, -) -from linkerhand_calibration.runtime.joint_resume import ResumeJournalImport, _index_motion_provenance -from linkerhand_calibration.runtime.staged_resume import _essential_indexed_provenance -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from linkerhand_calibration.core.domain.motion_path import record_scan_identity -from linkerhand_calibration.runtime.diagnostic_capture import reject_diagnostic_capture -from linkerhand_calibration.core.artifacts.evidence_journal import ( - REFERENCE_KEY, archive_path, compact_existing_journal, - initialize_evidence_journal, manifest_path, -) -from linkerhand_calibration.storage import append_jsonl, append_jsonl_many -from linkerhand_calibration.runtime.acquisition import load_capture -from linkerhand_calibration.runtime.capture_index import CaptureRecordIndex -from linkerhand_calibration.runtime.resume import discover_resume_candidate - - -def capture(tmp_path, rows): - path = tmp_path / "capture.jsonl" - path.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows)+"\n") - return path - - -def test_scalar_index_never_reads_image_payload_and_import_is_bounded(tmp_path, monkeypatch): - original = [dict(kind="joint_sample", task_name="中指", joint="middle_pip", - cycle=1, direction="increasing", image_stamp_ns=i+1, - pnp_observation_evidence={"pixels": list(range(100))}) for i in range(130)] - rows = load_passed_capture(capture(tmp_path, original)) - assert all(isinstance(r, DeferredCaptureRecord) for r in rows) - calls = [] - materialize = DeferredCaptureRecord.materialize - def observed(row): - calls.append(row.offset) - return materialize(row) - monkeypatch.setattr(DeferredCaptureRecord, "materialize", observed) - assert all(record_scan_identity(r) == ("中指", 1, "increasing") for r in rows) - reject_diagnostic_capture(rows) - assert _index_motion_provenance(rows) == {} and not calls - pending = ResumeJournalImport(tuple(rows)) - imported = [] - for size in (64, 64, 2): - before = len(calls) - batch = pending.take_batch() - assert len(batch) == size and len(calls)-before == size - assert all(type(r) is dict for r in batch) - imported.extend(batch) - assert pending.transfer_complete and not pending.complete and imported == original - assert all("pnp_observation_evidence" not in r.metadata for r in rows) - - -def test_changed_checkpoint_is_rejected_before_advancing_import_offset(tmp_path): - path = capture(tmp_path, [dict(kind="pnp_candidate_frame", image_stamp_ns=1, roles={"moving": [1, 2]})]) - pending = ResumeJournalImport(tuple(load_passed_capture(path))) - path.write_text(path.read_text().replace('[1, 2]', '[3, 4]')) - with pytest.raises(ValueError, match="resume_source_record_changed"): - pending.take_batch() - assert pending.offset == 0 - - -def test_zero_pixel_provenance_is_identical_to_eager_reading(tmp_path): - fixture = Path(__file__).parent / "fixtures/o30_shared_tag_middle_pip.json.gz" - original = json.loads(gzip.decompress(fixture.read_bytes()))["records"] - rows = load_passed_capture(capture(tmp_path, original)) - original_index = _index_motion_provenance(original) - lazy_index = _index_motion_provenance(rows) - assert set(lazy_index) == set(original_index) - for identity, row in lazy_index.items(): - assert json.loads(json.dumps(row)) == original_index[identity] - assert any(r["kind"] == "pnp_candidate_frame" for r in lazy_index.values()) - - -@pytest.mark.parametrize("text", ['{"kind":', '[]']) -def test_invalid_journal_is_not_hidden_by_deferred_storage(tmp_path, text): - path = tmp_path / "bad.jsonl" - path.write_text(text) - with pytest.raises(ValueError, match="JSONL"): - load_passed_capture(path) - - -def test_indexed_journal_keeps_resume_fields_inline_and_restores_full_evidence(tmp_path): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=256) - header = {"kind": "session_start", "profile_id": "O30/right/layout/rev"} - samples = [dict(kind="joint_sample", task_name="食指", joint="index_dip", - cycle=cycle, direction="increasing", image_stamp_ns=cycle + 1, - relative_quaternion_xyzw=[0., 0., 0., 1.], - pnp_observation_evidence={"pixels": list(range(1000))}) for cycle in range(2)] - append_jsonl(path, header) - append_jsonl_many(path, samples) - assert load_capture(path) == [header, *samples] - stored = [json.loads(line) for line in path.read_text().splitlines()] - assert REFERENCE_KEY not in stored[0] - assert all(row["joint"] == "index_dip" and REFERENCE_KEY in row for row in stored[1:]) - assert archive_path(path).stat().st_size < sum(len(json.dumps(row)) for row in samples) - - deferred = load_passed_capture(path) - assert all(isinstance(row, DeferredCaptureRecord) for row in deferred[1:]) - assert deferred[1]["relative_quaternion_xyzw"] == [0., 0., 0., 1.] - assert "relative_quaternion_xyzw" not in deferred[1].metadata - assert deferred[1].materialize() == samples[0] - - -def test_indexed_journal_rejects_changed_compressed_evidence(tmp_path): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=1) - append_jsonl(path, {"kind": "joint_sample", "pixels": list(range(100))}) - archive = archive_path(path) - changed = bytearray(archive.read_bytes()) - changed[len(changed)//2] ^= 1 - archive.write_bytes(changed) - with pytest.raises(ValueError, match="compressed_evidence_block_changed"): - load_capture(path) - - -def test_legacy_journal_migration_is_verified_before_atomic_replacement(tmp_path): - path = capture(tmp_path, [ - {"kind": "session_start", "serial_number": "O30_TEST"}, - {"kind": "joint_sample", "joint": "index_dip", "pixels": list(range(5000))}, - ]) - original = load_capture(path) - report = compact_existing_journal(path, replace=True) - assert report["semantic_verification"] == "passed" and report["installed"] is True - assert report["source_bytes"] > report["compact_journal_bytes"] - assert load_capture(path) == original - assert manifest_path(path).is_file() and archive_path(path).is_file() - assert not path.with_name(path.name + ".uncompressed-backup").exists() - second = compact_existing_journal(path, replace=True) - assert second["installed"] is True and load_capture(path) == original - assert not archive_path(path).with_name(archive_path(path).name + ".backup").exists() - - -def test_detailed_frames_are_externalized_even_when_smaller_than_size_threshold(tmp_path): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path) - row = {"kind": "motion_branch_observation", "view": "front", "image_stamp_ns": 1, - "tags": {"index_distal": [[1., 2.], [3., 4.]]}} - append_jsonl(path, row) - stored = json.loads(path.read_text()) - assert REFERENCE_KEY in stored - assert load_capture(path) == [row] - - -def test_resume_import_hard_links_compressed_evidence_without_copying_records(tmp_path): - source_dir, target_dir = tmp_path / "source", tmp_path / "target" - source_dir.mkdir(); target_dir.mkdir() - source, target = source_dir / "raw_samples.jsonl", target_dir / "raw_samples.jsonl" - initialize_evidence_journal(source, threshold_bytes=1) - initialize_evidence_journal(target, threshold_bytes=1) - original = [{"kind": "motion_branch_observation", "view": "side", - "image_stamp_ns": stamp, "pixels": list(range(100))} for stamp in range(3)] - append_jsonl_many(source, original) - pending = ResumeJournalImport(tuple(load_passed_capture(source))) - batch = pending.take_batch() - assert all(isinstance(row, DeferredCaptureRecord) for row in batch) - append_jsonl_many(target, batch) - linked = list(target_dir.glob("imported_evidence_*.zlib")) - assert len(linked) == 1 and linked[0].samefile(archive_path(source)) - archive_path(source).unlink() - source.unlink() - assert load_capture(target) == original - - -def test_linked_joint_samples_use_bounded_batches_and_concrete_training_digest(tmp_path): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=1) - leading = [dict(kind="image_observation_frame", task_name="index_dip_side", - cycle=0, direction="increasing", image_stamp_ns=-stamp-1, - pixels=list(range(100))) for stamp in range(10)] - samples = [dict(kind="joint_sample", task_name="index_dip_side", joint="index_dip", - cycle=0, direction="increasing", image_stamp_ns=stamp, - command_u8=stamp, feedback_u8=stamp, relative_quaternion_xyzw=[0., 0., 0., 1.]) - for stamp in range(100)] - append_jsonl_many(path, (*leading, *samples)) - pending = ResumeJournalImport(tuple(load_passed_capture(path))) - - assert len(pending.take_batch()) == len(leading) - first = pending.take_batch() - assert len(first) == len(samples) and all(row.get("kind") == "joint_sample" for row in first) - index = CaptureRecordIndex(retain_complete=False, retain_training_geometry=True) - index.extend(first) - assert len(index) == len(samples) - assert all(len(row["source_record_sha256"]) == 64 for row in index) - - -def test_training_index_materializes_each_deferred_sample_once(tmp_path, monkeypatch): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=1) - sample = dict(kind="joint_sample", task_name="index_dip_side", joint="index_dip", - cycle=0, direction="increasing", image_stamp_ns=1, command_u8=20, - feedback_u8=19, relative_quaternion_xyzw=[0., 0., 0., 1.], - relative_translation_xyz_m=[0., 0., .01]) - append_jsonl(path, sample) - row = load_passed_capture(path)[0] - original = DeferredCaptureRecord.materialize_plain - calls = [] - - def observed(record): - calls.append(record.reference.record_sha256) - return original(record) - - monkeypatch.setattr(DeferredCaptureRecord, "materialize_plain", observed) - index = CaptureRecordIndex(retain_complete=False, retain_training_geometry=True) - index.append(row) - - assert len(calls) == 1 - assert index[0]["relative_translation_xyz_m"] == [0., 0., .01] - - -def test_compact_passed_resume_keeps_only_digest_referenced_motion_frames(tmp_path): - path = tmp_path / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=1) - used = {"kind": "motion_branch_observation", "task_name": "index_dip_side", - "view": "side", "image_stamp_ns": 10, "pixels": list(range(100))} - unused = {**used, "image_stamp_ns": 20, "pixels": list(range(101))} - accepted = {"kind": "motion_branch_initialization", "status": "resolved", - "task_name": "index_dip_side", "view": "side", - "source_frame_hashes": {"10": source_frame_sha256(used)}} - append_jsonl_many(path, (used, unused, accepted)) - - indexed = tuple(_index_motion_provenance(load_passed_capture(path)).values()) - essential = _essential_indexed_provenance(indexed) - - observations = [row for row in essential if row.get("kind") == "motion_branch_observation"] - assert len(observations) == 1 - assert observations[0].canonical_sha256 == source_frame_sha256(used) - - -def test_discovery_reads_large_compressed_session_header(tmp_path): - from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - session = tmp_path / "SERIAL" / "20260101_000000" - session.mkdir(parents=True) - path = session / "raw_samples.jsonl" - initialize_evidence_journal(path, threshold_bytes=128) - hashes = {"profile_config_sha256": "a" * 64} - contract = {"large": list(range(100))} - append_jsonl_many(path, [{"kind": "session_start", "profile_id": "O30/right/layout/v1", - "serial_number": "SERIAL", "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "capture_schedule_version": "unified_schedule_v13_staged_training", - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION, "protected_hashes": hashes, - "capture_plan": {"policy": "staged_2_to_3"}, "preparation_contract": contract}, - {"kind": "fixed_base_reference_locked"}, - {"kind": "scan_unit_complete", "task_name": "index_dip_side", "cycle": 0, - "direction": "increasing", "passed": True}]) - assert discover_resume_candidate(tmp_path / "SERIAL", profile_id="O30/right/layout/v1", - serial_number="SERIAL", protected_hashes=hashes, training_policy="staged_2_to_3", - preparation_contract=contract) == session diff --git a/src/linkerhand_calibration/test/test_resume_v2.py b/src/linkerhand_calibration/test/test_resume_v2.py deleted file mode 100644 index 830ca56..0000000 --- a/src/linkerhand_calibration/test/test_resume_v2.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import math - -from linkerhand_calibration.runtime import ( - ACQUISITION_POLICY_VERSION, - ResumeFingerprint, - ResumeVerifier, - TagPoseFingerprint, -) - - -def _fingerprint(*, corner_offset=0.0, hash_value="a", angle=0.0): - return ResumeFingerprint( - profile_id="TEST/right/layout/v1", - protected_hashes={"source": hash_value * 64}, - fixed_corners_by_view={ - "view": tuple( - (x + corner_offset, y + corner_offset) - for x, y in ((0, 0), (10, 0), (10, 10), (0, 10)) - ) - }, - fixed_poses={ - "base": TagPoseFingerprint( - (0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)), - (0.0, 0.0, 0.4), - ) - }, - moving_tag_poses={ - "finger": TagPoseFingerprint((0.0, 0.0, 0.0, 1.0), (0.1, 0, 0.4)) - }, - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - ) - - -def test_matching_v2_reference_reuses_checkpoint() -> None: - decision = ResumeVerifier().compare(_fingerprint(), _fingerprint()) - assert decision.reuse - - -def test_reference_or_protected_input_change_starts_fresh_without_pause() -> None: - changed = ResumeVerifier().compare( - _fingerprint(), _fingerprint(corner_offset=6.0, hash_value="b") - ) - assert not changed.reuse - assert "重新采集" in changed.reason - assert "protected_hashes" in changed.incompatible_fields - assert "fixed_corners[view]" in changed.incompatible_fields diff --git a/src/linkerhand_calibration/test/test_resumed_image_reference.py b/src/linkerhand_calibration/test/test_resumed_image_reference.py deleted file mode 100644 index 776aaeb..0000000 --- a/src/linkerhand_calibration/test/test_resumed_image_reference.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Recorded O30 DIP resume: retain old geometry and check only current pixels.""" - -from dataclasses import replace -import gzip -import json -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest - -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.geometry.extrinsics import transform_matrix -from linkerhand_calibration.core.geometry.image_motion_replay import replay_image_models, validate_image_sample -from linkerhand_calibration.core.geometry.tag_pose.image_reference import image_reference_from_dict -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import image_motion_model_from_dict -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.observations import compile_tag_feedback_channels -from linkerhand_calibration.runtime.branch_initialization import MotionBranchModel -from linkerhand_calibration.runtime.capture import CaptureFrame, ObservationCapture -from linkerhand_calibration.runtime.image_reference import ImageReferenceTracker -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.parent_reference import check_parent_pose -from linkerhand_calibration.runtime.pose_evidence import reference_branch_revisions -from linkerhand_calibration.runtime.reference_lock import ReferenceLock -from o30_capture_fixture import SOURCE - - -@pytest.fixture -def resumed(): - payload = json.loads(gzip.decompress((Path(__file__).parent / "fixtures" / - "o30_resumed_parent_reference.json.gz").read_bytes())) - profile = load_bundled_hand_profile("o30_right_18") - task = next(t for t in profile.motion.tasks if t.key == "pinky_dip_side") - report = payload["model_report"] - model = MotionBranchModel(report["task_name"], report["view"], (), - tuple(report["evidence_ids"].items()), image_motion_model_from_dict(report["frozen_image_model"])) - current = image_reference_from_dict(payload["current_reference"]) - assert model.image_model.reference_frames[0] != current - lock = ReferenceLock({"side": 3}) - lock.begin_session() - lock.start_locking() - for image in current.source_images: - lock.observe("side", 3, image.corners_xy) - capture = ObservationCapture(profile, reference_lock=lock, - extrinsics=SimpleNamespace(transform=lambda _: transform_matrix(**payload["reference_from_side"]))) - capture._image_references["side"] = ImageReferenceTracker(10, reference=current) - capture.locked_poses["side"] = current.pose - capture.locked_revisions["side"] = 1 - capture.activate_reference_model(model) - motion = MotionCommand("joint_zero", profile.command.baseline_values, 200, - task_key=task.key, zero_joints=(task.parent_reference.pose_joint,), - reference_only=True, reference_reuse=True) - frames = tuple(CaptureFrame("side", r["image_stamp_ns"], np.asarray(r["camera_matrix"]), - {role: np.asarray(tag["corners_xy"]) for role, tag in r["tags"].items()}, - tuple(r["feedback_vector"]), tuple(r["command_vector"]), - command_directions=tuple(r["command_direction_by_index"])) for r in payload["failed_frames"]) - return SimpleNamespace(payload=payload, profile=profile, task=task, model=model, - current=current, capture=capture, motion=motion, frames=frames) - - -def test_original_session_root_reproduces_recorded_binding_failure(resumed, monkeypatch): - rig = resumed - monkeypatch.setattr(rig.capture, "_scheduled_image_reference", - lambda view, models: rig.capture._image_references[view]) - rows, _ = rig.capture.consume(rig.frames[0], rig.motion) - assert rig.payload["original_rejection"] in {row.get("reason") for row in rows} - assert not any(row["kind"] == "joint_zero_sample" for row in rows) - - -def test_recorded_resume_captures_parent_with_original_model_and_replayable_evidence(resumed): - rig = resumed - root = rig.model.image_model.reference_frames[0] - provider = rig.capture._model_image_references[("side", root.identity)] - assert provider.motion_snapshot(root.role, rig.frames[0].stamp_ns) is None - assert not provider.reference_confirmed(root.role, 1) - samples = [] - for frame in rig.frames: - rows, movement = rig.capture.consume(frame, rig.motion) - assert movement is None - sample = next(row for row in rows if row["kind"] == "joint_zero_sample") - candidates = next(row for row in rows if row["kind"] == "pnp_candidate_frame") - persisted = json.loads(json.dumps((sample, candidates))) - validate_image_sample(persisted[0], persisted[1], replay_image_models(persisted[1])) - evidence = sample["pnp_observation_evidence"] - assert evidence["parent"]["reference_frame_sha256"] == root.identity - assert evidence["child"]["image_model_sha256"] == rig.payload["model_report"]["image_model_sha256"] - samples.append(sample) - revisions = reference_branch_revisions(samples, profile=rig.profile, require_motion_evidence=True) - assert rig.capture.freeze_reference_branches(revisions) - channels = compile_tag_feedback_channels(rig.profile, UrdfKinematicModel(SOURCE)) - reference = JointZeroReference.from_record(rig.payload["source_zero"]) - check_parent_pose(rig.profile, rig.task, reference, samples, channels["pinky_pip"]) - assert rig.capture._image_references["side"].reference == rig.current - assert rig.capture._image_references["side"].motion_snapshot(root.role, rig.frames[-1].stamp_ns) - assert provider.reference == root - - -@pytest.mark.parametrize("change", ["moved", "missing", "intrinsics"]) -def test_restored_reference_still_requires_unchanged_current_image(resumed, change): - rig = resumed - frame = rig.frames[0] - if change == "moved": - frame = replace(frame, corners={**frame.corners, "side_base": frame.corners["side_base"] + (8, 0)}) - elif change == "missing": - frame = replace(frame, corners={k: v for k, v in frame.corners.items() if k != "side_base"}) - else: - matrix = frame.camera_matrix.copy() - matrix[0, 0] += 1 - frame = replace(frame, camera_matrix=matrix) - rows, _ = rig.capture.consume(frame, rig.motion) - assert not any(row["kind"] in {"joint_zero_sample", "pnp_candidate_frame"} for row in rows) - assert not rig.capture.freeze_reference_branches({("side", "side_base"): 1}) - if change == "moved": - for i in range(1, 10): - _, movement = rig.capture.consume(replace(frame, stamp_ns=frame.stamp_ns+i), rig.motion) - assert movement is not None # New-session drift witness remains independent. - - -def test_reset_revokes_restored_tracker_snapshots(resumed): - rig = resumed - rig.capture.consume(rig.frames[0], rig.motion) - root = rig.model.image_model.reference_frames[0] - provider = rig.capture._model_image_references[("side", root.identity)] - snapshot = provider.motion_snapshot(root.role, rig.frames[0].stamp_ns) - assert provider.snapshot_current(snapshot) - rig.capture.reset() - assert not provider.snapshot_current(snapshot) - - -def test_model_restore_does_not_solve_or_replay_historical_reference_images(resumed, monkeypatch): - from linkerhand_calibration.runtime import image_reference as module - - rig = resumed - rig.capture.reset() - def historical_solve_forbidden(*args, **kwargs): - pytest.fail("Restoring passed geometry must not replay its old images") - monkeypatch.setattr(module, "solve_square_tag_ippe", historical_solve_forbidden) - monkeypatch.setattr(module, "coordinate_origin", historical_solve_forbidden) - rig.capture.activate_reference_model(rig.model) - root = rig.model.image_model.reference_frames[0] - provider = rig.capture._model_image_references[("side", root.identity)] - assert provider.reference is root - assert not provider.reference_confirmed(root.role, 1) - - -def test_switching_tasks_selects_each_models_own_reference(resumed): - rig = resumed - old = rig.model.image_model.reference_frames[0] - image_model = rig.model.image_model - offset = rig.current.source_images[-1].stamp_ns - min(image_model.source_stamps_ns) + 1 - other = replace(rig.model, task_name="another_epoch", image_model=replace( - image_model, reference_frames=(rig.current,), - source_stamps_ns=tuple(stamp + offset for stamp in image_model.source_stamps_ns), - training_stamps_ns=tuple(stamp + offset for stamp in image_model.training_stamps_ns), - validation_stamps_ns=tuple(stamp + offset for stamp in image_model.validation_stamps_ns))) - rig.capture.activate_reference_model(other) - assert rig.capture._scheduled_image_reference("side", (other,)).reference == rig.current - rig.capture.activate_reference_model(rig.model) - assert rig.capture._scheduled_image_reference("side", (rig.model,)).reference == old - assert rig.capture._scheduled_image_reference("side", (rig.model, other)) is None - - -def test_parent_timeout_reports_actual_reference_joint_and_current_branch(resumed): - from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator - - rig = resumed - host = SimpleNamespace(profile=rig.profile, _steady_rows=[], _zero_reference_error="old_pip_error", - branch_initialization=SimpleNamespace(), - _branch_observation_reasons={("side", "pinky_pip"): (0, rig.payload["original_rejection"])}, - _view=lambda name: next(v for v in rig.profile.vision.views if v.name == name)) - host._task_branch_details = lambda *a, **kw: CalibrationCoordinator._task_branch_details(host, *a, **kw) - assert not CalibrationCoordinator._confirm_model_reference(host, rig.motion) - assert host._zero_reference_error == "parent_reference_samples_missing:pinky_mcp_pitch:side:0/10" - details = CalibrationCoordinator._joint_zero_branch_details(host, rig.motion) - assert f"pinky_mcp_pitch:side:tag=4:{rig.payload['original_rejection']}" in details diff --git a/src/linkerhand_calibration/test/test_rigid_pair_observer.py b/src/linkerhand_calibration/test/test_rigid_pair_observer.py deleted file mode 100644 index 4302d8d..0000000 --- a/src/linkerhand_calibration/test/test_rigid_pair_observer.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Recorded rigid-pair evidence: accept resolved motion, reject ambiguous views.""" -from copy import deepcopy -from dataclasses import replace -import gzip,json -from pathlib import Path - -import pytest - -from linkerhand_calibration.profiles import load_hand_profile -from linkerhand_calibration.profiles.export import write_hand_profile -from linkerhand_calibration.profiles.validator import validate_executable_profile -from linkerhand_calibration.profiles.preparation import preparation_contract -from linkerhand_calibration.profiles.observer_policy import TRAJECTORY_OBSERVER -from linkerhand_calibration.runtime.observer_reference import ObserverBridgeInput,build_observer_bridge,validate_observer_references -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.core.geometry.tag_pose.image_motion_model import image_motion_model_from_dict -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation -from linkerhand_calibration.core.geometry.tag_pose.source_hinge import SourceHinge -from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import image_parameters_for_sampling -from o30_capture_fixture import SOURCE -from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint - - -def recorded_source(tmp_path_factory, finger): - joint, parent_role = f'{finger}_dip', f'{finger}_pip' - data=json.loads(gzip.decompress((Path(__file__).parent/f'fixtures/o30_{finger}_rigid_pair.json.gz').read_bytes())) - path=tmp_path_factory.mktemp('rigid_pair')/'profile.yaml';path.write_text(data['profile_yaml']) - old=load_hand_profile(path) - measurements=dict(old.measurement.measurements) - measurements[joint]=replace(measurements[joint],observer_pose_policy='rigid_pair_v1') - profile=replace(old,measurement=replace(old.measurement,measurements=measurements)) - write_hand_profile(profile,path);assert load_hand_profile(path)==profile - validate_executable_profile(profile,SOURCE) - parent=next(r for r in data['parents'] if r['task_name']==f'{finger}_pip_side') - rows={r['image_stamp_ns']:r for r in data['rows']} - model=image_motion_model_from_dict(parent['frozen_image_model']) - source=ObserverBridgeInput(joint,model,parent, - tuple(rows[s] for s in parent['source_image_stamps'] if s in rows and joint in rows[s]['tags']), - tuple(rows[s] for s in data['endpoint_stamps']),(channel_for_joint(profile,joint),)) - return old, profile, source, data, rows, model, parent - - -@pytest.mark.replay -def test_independent_parent_branches_converge_on_real_bounded_probe(tmp_path): - from linkerhand_calibration.runtime.observer_preparation import ObserverProbeEvidence - root=Path(__file__).parent/'fixtures' - data=json.loads(gzip.decompress((root/'o30_index_independent_probe.json.gz').read_bytes())) - path=tmp_path/'profile.yaml';path.write_text(data['profile_yaml']) - profile=load_hand_profile(path) - old=json.loads(gzip.decompress((root/'o30_index_rigid_pair.json.gz').read_bytes())) - parent=next(r for r in old['parents'] if r['task_name']=='index_pip_side') - model=image_motion_model_from_dict(parent['frozen_image_model']) - rows=data['rows'] - decision=next(r for r in rows if r['kind']=='observer_preparation_decision') - evidence=ObserverProbeEvidence(decision, - tuple(r for r in rows if r['kind']=='image_observation_frame'), - tuple(r for r in rows if r['kind']=='observer_preparation_segment_complete'), - next(r for r in rows if r['kind']=='observer_preparation_excursion_started')) - failure=next(r for r in rows if r['kind']=='motion_branch_initialization') - target=profile.motion.joint_zero_references['index_dip'].command - endpoints=tuple(sorted((r for r in rows if r['kind']=='motion_branch_observation' - and tuple(r.get('command_vector') or ())==target - and r['image_stamp_ns'] not in failure['source_image_stamps'] - and r['image_stamp_ns']<=max(failure['source_image_stamps'])), - key=lambda r:r['image_stamp_ns']))[-10:] - bridge,record=build_observer_bridge(profile,ObserverBridgeInput( - 'index_dip',model,parent,(),endpoints,(16,),evidence)) - assert record['version']=='observer_pose_bridge_v3' - assert bridge.rotation_uncertainty_rad<.017453293 - assert bridge.translation_uncertainty_m<.001 - assert all(p==0 for _,p in record['pose']['comparison_adjusted_p_values']) - assert set(record['pose']['training_stamps']).isdisjoint(record['pose']['validation_stamps']) - measurements=dict(profile.measurement.measurements) - measurements['index_dip']=replace(measurements['index_dip'], - observer_pose_policy=TRAJECTORY_OBSERVER) - trajectory_profile=replace(profile,measurement=replace(profile.measurement, - measurements=measurements)) - trajectory_bridge,trajectory_record=build_observer_bridge(trajectory_profile, - ObserverBridgeInput('index_dip',model,parent,(),endpoints,(16,),evidence)) - assert trajectory_record['version']=='observer_pose_bridge_v4' - assert trajectory_record['trajectory_branch_decision']['holdout_role']=='veto_only' - assert trajectory_record['trajectory_branch_decision']['corner_evidence_sha256'] - assert trajectory_bridge.rotation_uncertainty_rad<.017453293 - - -@pytest.fixture(scope='module') -def rigid_source(tmp_path_factory): - finger = 'middle' - joint, parent_role = f'{finger}_dip', f'{finger}_pip' - old, profile, source, data, rows, model, parent = recorded_source(tmp_path_factory, finger) - failure = data['failure'] - bridge,record=build_observer_bridge(profile,source) - relation=MotionRelation(joint,parent_role,joint) - frames=bridge_frames(tuple(rows[s] for s in failure['source_image_stamps']),relation) - hinge=SourceHinge(model.geometry[0],parent['evidence_ids'][parent_role],parent['image_model_sha256'],*parent['geometry_uncertainty'][0][1:]) - request=BranchInitializationRequest(task_name=failure['task_name'],view='side',zero_joints=(joint,), - session_epoch=failure['session_epoch'],motion_version=failure['motion_version'],generation=1, - frames=tuple(f.evidence for f in frames),relations=(relation,),existing_evidence_ids=tuple(parent['evidence_ids'].items()), - moving_roles=(parent_role,joint),source_frame_hashes=tuple(failure['source_frame_hashes'].items()), - image_frames=frames,image_geometry=True,geometry_constraints=tuple(ParallelAxisGeometry(**v) for v in failure['image_geometry_constraints']), - source_urdf_sha256=failure['source_urdf_sha256'],source_hinges=(hinge,),parent_reference_sha256=failure['parent_reference_sha256'], - observer_sources=(source,),observer_profile=profile,image_parameters=image_parameters_for_sampling(failure.get('image_sampling_policy'))) - result=solve_initialization(request) - assert result.resolved,result.reason - _,formal=initialization_artifacts(request,result) - records=json.loads(json.dumps([data['header'],*data['rows'],*data['parents'],formal])) - return old,profile,source,bridge,record,request,result,records - - -@pytest.mark.replay -def test_recorded_index_ambiguity_cannot_be_accepted_by_switching_policy(tmp_path_factory): - _, profile, source, *_ = recorded_source(tmp_path_factory, 'index') - with pytest.raises(ValueError, match='^rigid_pair_candidates_unresolved$'): - build_observer_bridge(profile, source) - - -@pytest.mark.parametrize('complete', [False, True]) -def test_raw_parent_candidates_are_versioned_and_never_silently_discarded(tmp_path_factory, monkeypatch, complete): - from linkerhand_calibration.profiles.observer_policy import RIGID_OBSERVER, LEGACY_RIGID_OBSERVER - from linkerhand_calibration.core.geometry.tag_pose import rigid_tag_pair - _, profile, source, *_ = recorded_source(tmp_path_factory, 'middle') - specs = dict(profile.measurement.measurements) - specs[source.joint] = replace(specs[source.joint], - observer_pose_policy=RIGID_OBSERVER if complete else LEGACY_RIGID_OBSERVER) - profile = replace(profile, measurement=replace(profile.measurement, measurements=specs)) - role = specs[source.joint].parent_role - def inspect(*args, **kwargs): - if complete: - candidates = kwargs['parent_candidates'] - assert len(candidates) == len(source.rows) - assert all(len(actual) == len(row['tags'][role]['reprojection_valid_candidates']) - for actual,row in zip(candidates,source.rows)) - assert any(len(actual) == 2 for actual in candidates) - else: - assert 'parent_candidates' not in kwargs - raise ValueError('candidate_boundary_checked') - monkeypatch.setattr(rigid_tag_pair, 'fit_rigid_tag_pair', inspect) - with pytest.raises(ValueError, match='candidate_boundary_checked'): - build_observer_bridge(profile, source) - - -@pytest.mark.replay -def test_real_pair_passes_original_precision_and_dip_acceptance(rigid_source): - old,profile,source,bridge,record,_,result,_=rigid_source - with pytest.raises(ValueError,match='observer_pose_uncertain'):build_observer_bridge(old,source) - assert bridge.rotation_uncertainty_rad<.017453293 - assert bridge.translation_uncertainty_m<.001 - assert set(record['pose']['training_stamps']).isdisjoint(record['pose']['validation_stamps']) - assert all(h.pose_bridge_checks[0].status=='consistent' for h in result.hypotheses) - assert all(c.consistent for h in result.hypotheses for c in h.source_geometry_checks) - assert preparation_contract(profile)!=preparation_contract(old) - assert result.model.maximum_reprojection_error_px==1.5 - - -@pytest.mark.replay -def test_rigid_pair_and_constrained_recipient_replay(rigid_source): - _,profile,_,_,_,_,_,records=rigid_source - evidence=_initializations(records) - validate_observer_references(profile,records,evidence) - - -@pytest.mark.parametrize('fault',['held_feedback','source_pixel','current_pixel']) -def test_changed_physical_support_is_rejected(rigid_source,fault): - _,profile,source,_,_,request,_,_=rigid_source - rows,endpoints=deepcopy(source.rows),deepcopy(source.endpoint_rows) - joint = source.joint - if fault=='held_feedback':rows[10]['feedback_vector'][source.held_channels[0]]+=5 - elif fault=='source_pixel': - for r in rows[1::2]:r['tags'][joint]['corners_xy']=[[x+10,y+8] for x,y in r['tags'][joint]['corners_xy']] - else: - for r in endpoints:r['tags'][joint]['corners_xy']=[[x+10,y+8] for x,y in r['tags'][joint]['corners_xy']] - changed=replace(source,rows=tuple(rows),endpoint_rows=tuple(endpoints)) - result=solve_initialization(replace(request,observer_sources=(changed,))) - assert not result.resolved and result.model is None - - -def test_missing_rigid_evidence_cannot_fall_back(rigid_source): - _,profile,_,_,_,request,_,records=rigid_source - result=solve_initialization(replace(request,observer_sources=())) - assert not result.resolved and result.reason=='rigid_pair_source_missing' - evidence=_initializations(records) - for payload in evidence.values():payload.pop('observer_pose_bridges',None) - with pytest.raises(ValueError,match='required_evidence_missing'):validate_observer_references(profile,records,evidence) diff --git a/src/linkerhand_calibration/test/test_rotation_holdout_diagnostics.py b/src/linkerhand_calibration/test/test_rotation_holdout_diagnostics.py deleted file mode 100644 index 469890e..0000000 --- a/src/linkerhand_calibration/test/test_rotation_holdout_diagnostics.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Rejected visual fits remain rejected and preserve useful raw-sample evidence.""" - -from copy import deepcopy -from dataclasses import asdict -import json -import math - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.fitting import observed_motion -from linkerhand_calibration.core.fitting.rotation_diagnostics import RotationHoldoutFailure -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session - - -def rotation_rows(*, slip_axis=None, slip_one=False): - mount = Rotation.from_euler("xyz", (.24, -.17, .31)) - rows = [] - for cycle in range(4): - for direction in ("increasing", "decreasing"): - for index, value in enumerate(np.linspace(0, 1, 65)): - rotation = mount * Rotation.from_euler("z", .8 * value) - if cycle == 3 and slip_axis and (not slip_one or - (direction == "decreasing" and index == 12)): - rotation *= Rotation.from_euler(slip_axis, math.radians(5)) - rows.append({"sample_id": f"camera_a:{cycle}:{direction}:{index}", - "view": "camera_a", "feedback_rad": float(value), - "relative_quaternion_xyzw": rotation.as_quat().tolist(), - "cycle": cycle, "direction": direction}) - return rows, mount.as_quat() - - -def fit(rows, reference, **context): - return observed_motion.fit_native_observed_rotation(rows, input_domain="feedback_rad", - input_to_joint_direction=1, reference_xyzw=reference, **context) - - -def test_optional_diagnostic_context_preserves_existing_success_outputs(): - rows, reference = rotation_rows() - original = deepcopy(rows) - curve, errors = fit(rows, reference) - contextual_curve, contextual_errors = fit(rows, reference, joint="hinge", view="camera_a") - assert asdict(curve) == asdict(contextual_curve) - assert errors == contextual_errors - assert max(errors) < 1e-12 - assert curve.angle_rad == pytest.approx(np.linspace(0, .8, 65), abs=1e-12) - assert rows == original - - -@pytest.mark.parametrize("axis", ["x", "z"]) -def test_diagnostics_distinguish_off_axis_slip_from_angle_error_without_refitting(axis, monkeypatch): - rows, reference = rotation_rows(slip_axis=axis) - original = deepcopy(rows) - training_calls = [] - primitive = observed_motion.fit_rotation_curve - - def record_training(observations, **kwargs): - training_calls.append(tuple(observations)) - return primitive(observations, **kwargs) - - monkeypatch.setattr(observed_motion, "fit_rotation_curve", record_training) - with pytest.raises(RotationHoldoutFailure) as caught: - fit(rows, reference, joint="hinge", view="camera_a") - error = caught.value - diagnostic = error.diagnostics - assert str(error).startswith("independent_rotation_holdout_failed:joint=hinge:view=camera_a:") - assert "max_deg=5.000000" in str(error) - assert diagnostic.full_so3.count == 130 - assert diagnostic.full_so3.mae_deg == pytest.approx(5, abs=1e-10) - assert diagnostic.full_so3.p95_deg == pytest.approx(5, abs=1e-10) - assert diagnostic.full_so3.maximum_deg == pytest.approx(5, abs=1e-10) - assert diagnostic.full_so3.passed is False - if axis == "z": - assert diagnostic.axial.mae_deg == pytest.approx(5, abs=1e-10) - assert diagnostic.off_axis.maximum_deg < 1e-10 - else: - assert diagnostic.off_axis.mae_deg > 4.9 - assert diagnostic.axial.maximum_deg < .05 - assert len(training_calls) == 1 - assert {row.cycle for row in training_calls[0]} == {0, 1, 2} - assert rows == original - - -def test_one_bad_holdout_image_is_reported_and_not_discarded(): - rows, reference = rotation_rows(slip_axis="z", slip_one=True) - with pytest.raises(RotationHoldoutFailure) as caught: - fit(rows, reference) - diagnostic = caught.value.diagnostics - assert diagnostic.full_so3.mae_deg < 1 - assert diagnostic.full_so3.p95_deg < 2 - assert diagnostic.full_so3.maximum_deg == pytest.approx(5, abs=1e-10) - assert diagnostic.worst_sample.sample_id == "camera_a:3:decreasing:12" - assert diagnostic.worst_sample.cycle == 3 - assert diagnostic.worst_sample.direction == "decreasing" - assert diagnostic.worst_sample.input_value == pytest.approx(12 / 64) - assert diagnostic.worst_sample.axial_error_deg == pytest.approx(5, abs=1e-10) - - -def test_finalizer_persists_joint_and_camera_evidence_before_any_spatial_solve(tmp_path, monkeypatch): - from measured_capture_fixture import measured_capture - from linkerhand_calibration.core.fitting import session - - profile, source, records, _ = measured_capture(tmp_path, layout="o6_right_8") - failed_joint = "rh_pinky_dip" - for row in records: - if row.get("joint") == failed_joint and row.get("cycle") == 3: - slipped = Rotation.from_quat(row["relative_quaternion_xyzw"]) * Rotation.from_euler("x", .1) - row["relative_quaternion_xyzw"] = slipped.as_quat().tolist() - parent = Rotation.from_quat(row["parent_pose_common"]["quaternion_xyzw"]) - row["child_pose_common"]["quaternion_xyzw"] = (parent * slipped).as_quat().tolist() - - def forbidden_spatial_solve(*args, **kwargs): - pytest.fail("a failed visual holdout must stop before spatial fitting") - - monkeypatch.setattr(session, "solve_urdf_zero_offsets", forbidden_spatial_solve) - previous = tmp_path / "previous" - previous.mkdir() - pointer = tmp_path / profile.artifacts.publication_pointer - pointer.symlink_to(previous, target_is_directory=True) - directory = tmp_path / "failed_session" - with pytest.raises(RotationHoldoutFailure) as caught: - finalize_profile_session(profile=profile, session_dir=directory, - serial_number="SYNTHETIC_DIAGNOSTIC", source_urdf=source, - protected_inputs={}, records=records) - - report = json.loads((directory / "fit_diagnostics.json").read_text()) - assert report["passed"] is False - assert report["publication_allowed"] is False - assert report["stage"] == "relative_rotation_holdout" - assert report["reason"] == str(caught.value) - detail = report["rotation_holdout"] - assert detail["joint"] == failed_joint - assert detail["view"] == "side" - assert detail["training_cycles"] == [0, 1, 2] - assert detail["holdout_cycle"] == 3 - assert detail["full_so3"]["maximum_deg"] > 3 - assert detail["worst_sample"]["sample_id"] in { - row["sample_id"] for row in records if row.get("joint") == failed_joint - and row.get("cycle") == 3 and row.get("sample_phase", "sweep") == "sweep"} - assert pointer.resolve() == previous - assert not list(directory.glob("*.urdf")) - assert not (directory / "release_manifest.json").exists() diff --git a/src/linkerhand_calibration/test/test_runtime_gc_lifecycle.py b/src/linkerhand_calibration/test/test_runtime_gc_lifecycle.py deleted file mode 100644 index 25dc494..0000000 --- a/src/linkerhand_calibration/test/test_runtime_gc_lifecycle.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Large prepared checkpoints must not be revisited by live cyclic GC.""" - -import gc -import weakref - -import pytest - -from linkerhand_calibration.runtime.ros import calibration_node - - -class Cycle: - def __init__(self): - self.back = self - - -@pytest.mark.parametrize("interrupted", [False, True]) -def test_startup_graph_is_excluded_but_new_cycles_are_collected(interrupted): - checkpoint = {"rows": [Cycle() for _ in range(128)]} - startup_ids = {id(row) for row in checkpoint["rows"]} - enabled, thresholds = gc.isenabled(), gc.get_threshold() - try: - with calibration_node._frozen_startup_graph(): - assert startup_ids.isdisjoint(id(row) for row in gc.get_objects()) - live = Cycle() - reference = weakref.ref(live) - del live - gc.collect(2) - assert reference() is None - assert gc.isenabled() == enabled - assert gc.get_threshold() == thresholds - if interrupted: - raise KeyboardInterrupt - except KeyboardInterrupt: - assert interrupted - assert startup_ids <= {id(row) for row in gc.get_objects()} - assert gc.isenabled() == enabled - assert gc.get_threshold() == thresholds - - -@pytest.mark.parametrize("failure", [None, "spin", "executor_shutdown", "node_shutdown"]) -def test_node_freezes_only_after_preparation_and_unfreezes_after_shutdown(monkeypatch, failure): - import rclpy - - events = [] - def event(name): - events.append(name) - if name == failure: - raise RuntimeError(name) - - class Node: - def __init__(self, profile): - event("prepared_checkpoint") - def destroy_node(self): - event("node_shutdown") - - class Executor: - def add_node(self, node): - event("add_node") - def spin(self): - event("spin") - def shutdown(self): - event("executor_shutdown") - - monkeypatch.setattr(rclpy, "init", lambda **kw: event("ros_init")) - monkeypatch.setattr(rclpy, "shutdown", lambda: event("ros_shutdown")) - monkeypatch.setattr(calibration_node, "UnifiedCalibrationNode", Node) - monkeypatch.setattr(calibration_node, "SingleThreadedExecutor", Executor) - monkeypatch.setattr(calibration_node.gc, "collect", lambda: event("collect")) - monkeypatch.setattr(calibration_node.gc, "freeze", lambda: event("freeze")) - monkeypatch.setattr(calibration_node.gc, "unfreeze", lambda: event("unfreeze")) - if failure: - with pytest.raises(RuntimeError, match=failure): - calibration_node.run_profile_node(object()) - else: - calibration_node.run_profile_node(object()) - assert events == ["ros_init", "prepared_checkpoint", "add_node", "collect", "freeze", - "spin", "executor_shutdown", "node_shutdown", "ros_shutdown", "unfreeze"] diff --git a/src/linkerhand_calibration/test/test_runtime_pose_branch_guard.py b/src/linkerhand_calibration/test/test_runtime_pose_branch_guard.py deleted file mode 100644 index 16ee9f0..0000000 --- a/src/linkerhand_calibration/test/test_runtime_pose_branch_guard.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Frozen Tag branches guard capture, zero references and coordinator races.""" - -from concurrent.futures import ThreadPoolExecutor -from copy import deepcopy -from dataclasses import replace -from threading import Event - -import pytest - -from linkerhand_calibration.core.geometry.pnp import SquareTagPose -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 linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh -from pose_branch_fixture import IdealPoseTracker, confirm_reference_rows -from runtime_host_fixture import coordinator_fixture, ready -from test_joint_zero_runtime import reference_rows -from test_observation_capture import make_capture, frame - - -def branch_event(stamp, *, view="top", role="thumb_yaw", tag=7, reason="pose_branch_conflict"): - return {"kind": "pnp_branch_event", "view": view, "image_stamp_ns": stamp, - "tag_role": role, "tag_id": tag, "reason": reason, - "pnp_observation_evidence": {"candidate_diagnostics": { - "observation_stamp_ns": stamp, "branch_revision": 0, - "branch_status": "conflict", "branch_reason": reason, - "branch_confirmation_count": 5, "branch_frozen": True}}} - - -def begin_yaw(host, clock): - ready(host, clock) - assert host.start().success - host.execution.session.phase = Phase.SWEEP - host._motion = MotionCommand("sweep", host.profile.command.baseline_values, 40., - task_key="thumb_yaw_top", command_index=1, cycle=0, direction="decreasing") - - -@pytest.mark.parametrize("kind,paused", [ - ("required", True), ("fixed", True), ("fixed_projection", True), ("unstarted", False), - ("pending", False), ("ambiguous", False), ("fixed_unverified", False), ("recovering", False), ("stale_metadata", False), -]) -def test_branch_events_pause_only_current_required_roles_after_version_checks(tmp_path, monkeypatch, kind, paused): - host, clock = coordinator_fixture(tmp_path) - try: - begin_yaw(host, clock) - row = branch_event(host.ports.clock_ns()) - if kind in {"fixed", "fixed_projection"}: - row = branch_event(host.ports.clock_ns(), view="side", role="side_base", tag=3) - if kind == "fixed_projection": - row["pnp_observation_evidence"]["candidate_diagnostics"].update( - pose_constraint="fixed_reference", fixed_reference_verified=False, - fixed_reference_reprojection_error_px=2.1) - elif kind == "unstarted": - row = branch_event(host.ports.clock_ns(), view="front", role="thumb_pitch", tag=1) - elif kind == "pending": - row = branch_event(host.ports.clock_ns(), reason="pose_branch_unconfirmed") - elif kind == "ambiguous": - row = branch_event(host.ports.clock_ns(), reason="pose_branch_ambiguous") - elif kind == "fixed_unverified": - row = branch_event(host.ports.clock_ns(), view="front", role="front_base", tag=0, - reason="fixed_reference_pose_unverified") - row["pnp_observation_evidence"]["candidate_diagnostics"].update( - branch_status="fixed_reference_unavailable", pose_constraint="fixed_reference", - fixed_reference_verified=False, fixed_reference_reprojection_error_px=1.6233) - elif kind == "recovering": - row = branch_event(host.ports.clock_ns(), reason="pose_branch_recovery_required") - elif kind == "stale_metadata": - row["pnp_observation_evidence"]["candidate_diagnostics"]["observation_stamp_ns"] -= 1 - monkeypatch.setattr(host.capture, "consume", lambda *a, **kw: ((deepcopy(row),), None)) - host.receive_detections(DetectionInput(row["view"], host.ports.clock_ns(), ())) - assert host.raw_records[0]["kind"] == "session_start" - assert [record for record in host.raw_records[1:] if record["kind"] != "image_observation_frame"] == [row] - assert not [record for record in host._unit_rows if record["kind"] != "image_observation_frame"] - assert host._steady_rows == [] - assert (host.execution.session.phase == Phase.PAUSED) is paused - if kind == "fixed_unverified": - assert ("front", "front_base") in host._branch_observation_reasons - yaw = next(t for t in host.profile.motion.tasks if t.key == "thumb_yaw_top") - front = next(t for t in host.profile.motion.tasks if t.view == "front") - assert host._task_branch_details(yaw) == "" - assert "fixed_reference_pose_unverified" in host._task_branch_details(front) - if paused: - assert f"view={row['view']}:tag={row['tag_id']}" in host.reason - expected_code = "OBS-BASE-POSE-110" if kind == "fixed_projection" else "OBS-POSE-BRANCH-109" - assert reason_zh({"reason": host.reason}, model_name="O6")[0] == expected_code - count = len(clock.positions) - host.tick() - assert len(clock.positions) == count - assert not host.finalization.started - finally: - host.close() - - -def test_late_branch_conflict_cannot_pause_or_append_after_abort(tmp_path, monkeypatch): - host, clock = coordinator_fixture(tmp_path) - entered, release = Event(), Event() - try: - begin_yaw(host, clock) - def consume(frame, *a, **kw): - entered.set() - assert release.wait(2) - return (branch_event(frame.stamp_ns),), None - monkeypatch.setattr(host.capture, "consume", consume) - with ThreadPoolExecutor(1) as pool: - future = pool.submit(host.receive_detections, DetectionInput("top", host.ports.clock_ns(), ())) - assert entered.wait(2) - assert host.abort().success - release.set() - future.result(timeout=2) - assert host.execution.session.phase == Phase.ABORTED - assert [row["kind"] for row in host.raw_records] == ["session_start"] - assert not host._branch_observation_reasons - finally: - release.set() - host.close() - - -@pytest.mark.parametrize("invalid", ["mixed_revision", "missing_evidence", "stale_stamp", "wrong_role", "tracker_pending"]) -def test_joint_zero_cannot_freeze_unconfirmed_or_mixed_branch_evidence(tmp_path, invalid): - host, _ = coordinator_fixture(tmp_path) - try: - name = host.profile.motion.tasks[0].joints[0] - rows = reference_rows(host.profile, name, stamp=100) - confirm_reference_rows(host, rows) - if invalid == "mixed_revision": - rows[-1]["pnp_observation_evidence"]["child"]["candidate_diagnostics"]["branch_revision"] += 1 - elif invalid == "missing_evidence": - rows[-1].pop("pnp_observation_evidence") - elif invalid == "stale_stamp": - rows[-1]["pnp_observation_evidence"]["child"]["candidate_diagnostics"]["observation_stamp_ns"] -= 1 - elif invalid == "wrong_role": - rows[-1]["pnp_observation_evidence"]["child"]["tag_role"] = "top_base" - else: - host.trackers["top"]._branches.states["thumb_yaw"].status = "pending" - host._steady_rows = [dict(row, kind="joint_zero_sample", joint=name) for row in rows] - host._observation_epoch = host._segment_number = 1 - assert not host._lock_joint_zeros(MotionCommand('joint_zero', - host.profile.command.baseline_values, 40., - task_key=host.profile.motion.joint_zero_references[name].task_key, zero_joints=(name,))) - expected = {"mixed_revision": "joint_zero_motion_evidence_changed", - "missing_evidence": "joint_zero_motion_evidence_incomplete", - "stale_stamp": "joint_zero_motion_evidence_unconfirmed", - "wrong_role": "joint_zero_motion_evidence_unconfirmed", - "tracker_pending": "joint_zero_motion_branch_not_confirmed"}[invalid] - assert expected in host._zero_reference_error - assert not host.execution.zero_references - assert all(not state.frozen for state in host.trackers["top"]._branches.states.values()) - finally: - host.close() - - -def test_joint_zero_freezes_both_physical_roles_with_one_revision(tmp_path): - host, _ = coordinator_fixture(tmp_path) - try: - name = host.profile.motion.tasks[0].joints[0] - rows = reference_rows(host.profile, name, stamp=100) - confirm_reference_rows(host, rows) - host._steady_rows = [dict(row, kind="joint_zero_sample", joint=name) for row in rows] - host._observation_epoch = host._segment_number = 1 - assert host._lock_joint_zeros(MotionCommand('joint_zero', - host.profile.command.baseline_values, 40., - task_key=host.profile.motion.joint_zero_references[name].task_key, zero_joints=(name,))) - assert set(host.trackers["top"]._branches.states) == {"top_base", "thumb_yaw"} - assert all(state.frozen for state in host.trackers["top"]._branches.states.values()) - assert len(host.execution.zero_references[name].samples) == 10 - finally: - host.close() - - -def test_fixed_pose_buffer_discards_old_revision_before_atomic_freeze(): - profile, lock, capture = make_capture() - class Sensor(IdealPoseTracker): - revision = 0 - def estimate(self, role, corners, *, stamp_ns, **kwargs): - pose = SquareTagPose((0., 0., 0., 1.), (0., 0., .45 + self.revision*.01), 0.) - self.accept_ideal_pose(role, pose, stamp_ns) - self._branches.states[role].revision = self.revision - self.last_candidate_diagnostics_by_role[role]["branch_revision"] = self.revision - return pose, "" - sensor = capture.trackers["front"] = Sensor() - lock.begin_session() - lock.start_locking() - for stamp in range(1, 10): - capture.consume(frame(profile, stamp, omit=("thumb_pitch", "thumb_dip"))) - sensor.revision = 1 - capture.consume(frame(profile, 10, omit=("thumb_pitch", "thumb_dip"))) - assert "front" not in capture.locked_poses - for stamp in range(11, 20): - capture.consume(frame(profile, stamp, omit=("thumb_pitch", "thumb_dip"))) - assert capture.locked_revisions == {"front": 1} - assert capture.locked_poses["front"].translation_xyz_m[2] == pytest.approx(.46) - assert sensor._branches.states["front_base"].frozen - - -def test_visible_but_quality_rejected_base_cannot_be_replaced_by_cache(): - profile, lock, capture = make_capture() - lock.begin_session() - lock.start_locking() - for stamp in range(1, 15): - capture.consume(frame(profile, stamp)) - observation = replace(frame(profile, 20, omit=("front_base",)), observed_roles=frozenset({"front_base"})) - motion = MotionCommand("sweep", profile.command.baseline_values, 40., - "thumb_pitch_dip_front", 0, 0, "decreasing") - rows, _ = capture.consume(observation, motion) - assert not any(row.get("joint") == "rh_thumb_cmc_pitch" for row in rows) - - -@pytest.mark.parametrize("recovery_evidence", ["event", "sample"]) -def test_recovered_observation_clears_pending_error_and_final_quality_details(tmp_path, monkeypatch, recovery_evidence): - host, clock = coordinator_fixture(tmp_path) - try: - begin_yaw(host, clock) - row = branch_event(host.ports.clock_ns(), reason="pose_branch_recovery_required") - monkeypatch.setattr(host.capture, "consume", lambda *a, **kw: ((deepcopy(row),), None)) - host.receive_detections(DetectionInput("top", host.ports.clock_ns(), ())) - task = host.profile.motion.tasks[0] - assert "top:tag=7:pose_branch_recovery_required" in host._task_branch_details(task) - assert host.execution.session.phase == Phase.SWEEP - clock.now += .04 - stamp = host.ports.clock_ns() - diagnostics = {"observation_stamp_ns": stamp, "branch_revision": 0, "branch_status": "tracking"} - if recovery_evidence == "event": - row = branch_event(stamp, reason="pose_branch_recovered") - row["pnp_observation_evidence"]["candidate_diagnostics"] = diagnostics - else: - row = {"kind": "joint_sample", "view": "top", "image_stamp_ns": stamp, - "pnp_observation_evidence": {"child": {"tag_role": "thumb_yaw", "candidate_diagnostics": diagnostics}}} - host.receive_detections(DetectionInput("top", stamp, ())) - assert not host._branch_observation_reasons - assert host._task_branch_details(task) == "" - assert host.execution.session.phase == Phase.SWEEP - finally: - host.close() - - -def test_pending_fixed_branch_cannot_use_cache_when_next_image_is_missing(): - profile, lock, capture = make_capture() - lock.begin_session() - lock.start_locking() - for stamp in range(1, 15): - capture.consume(frame(profile, stamp)) - tracker = capture.trackers["front"] - tracker._branches.states["front_base"].status = "pending" - motion = MotionCommand("sweep", profile.command.baseline_values, 40., - "thumb_pitch_dip_front", 0, 0, "decreasing") - rows, _ = capture.consume(frame(profile, 20, omit=("front_base",)), motion) - assert not any(row.get("joint") == "rh_thumb_cmc_pitch" for row in rows) - - -def test_occluded_fixed_corners_discard_dependent_samples_until_stable_recovery(): - profile, lock, capture = make_capture() - capture.profile = profile = replace(profile, measurement=replace(profile.measurement, - candidate_selection_tasks=frozenset({"thumb_pitch_dip_front"}))) - lock.begin_session() - lock.start_locking() - for stamp in range(1, 15): - capture.consume(frame(profile, stamp)) - original = capture.locked_reference("front") - motion = MotionCommand("sweep", profile.command.baseline_values, 40., - "thumb_pitch_dip_front", 0, 0, "decreasing") - for stamp in range(20, 30): - observation = frame(profile, stamp) - observation.corners["front_base"][0] += [0., 4.] - rows, movement = capture.consume(observation, motion) - assert movement is None # Local corner damage, below the drift gate. - assert len(rows) == 1 and rows[0]["kind"] == "pnp_branch_event" - assert rows[0]["reason"] == "fixed_reference_pose_unverified" - assert rows[0]["motion_phase"] == "sweep" - assert rows[0]["command_vector"] == list(observation.command) - assert rows[0]["feedback_vector"] == list(observation.feedback) - assert capture.locked_reference("front") == original - # Missing detection must not masquerade as a recovered visible reference. - rows, _ = capture.consume(frame(profile, 30, omit=("front_base",)), motion) - assert not any(row.get("joint") == "rh_thumb_cmc_pitch" for row in rows) - for stamp in range(31, 36): - rows, movement = capture.consume(frame(profile, stamp), motion) - samples = [r for r in rows if r.get("joint") == "rh_thumb_cmc_pitch"] - assert movement is None - assert bool(samples) is (stamp == 35) - if samples: - assert samples[0]["pnp_observation_evidence"]["parent"]["pose_source"] == "verified_fixed_reference" - assert capture.locked_reference("front") == original - - -def test_true_fixed_drift_in_unused_view_still_pauses_current_task(tmp_path, monkeypatch): - from linkerhand_calibration.runtime.reference_lock import ReferenceMovement - host, clock = coordinator_fixture(tmp_path) - try: - begin_yaw(host, clock) - movement = ReferenceMovement("front", 0, 6.0, 10) - monkeypatch.setattr(host.capture, "consume", lambda *a, **kw: ((), movement)) - host.receive_detections(DetectionInput("front", host.ports.clock_ns(), ())) - assert host.execution.session.phase == Phase.PAUSED - assert host.reason == "fixed_reference_moved:front:6.0" - assert not host.finalization.started - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_runtime_readiness.py b/src/linkerhand_calibration/test/test_runtime_readiness.py deleted file mode 100644 index 9a94bfd..0000000 --- a/src/linkerhand_calibration/test/test_runtime_readiness.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Device readiness and progress must reflect evidence, for any view layout.""" - -from dataclasses import asdict, replace -from types import MethodType, SimpleNamespace -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.adapters import HardwareHealth -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator -from linkerhand_calibration.runtime.status import normalize_status, render_status_zh - - -def test_missing_stale_and_recovered_camera_pipeline_are_named_without_tag_gate(): - profile = load_bundled_hand_profile("o6_right_8") - host = SimpleNamespace(profile=profile, command_count=6, latest_feedback=(0,)*6, - required_observation_views=profile.vision.view_names, - ports=SimpleNamespace(command_publisher_count=lambda: 1), - sdk_adapter=SimpleNamespace(health=lambda: HardwareHealth(True, True)), - cameras=SimpleNamespace(matrices={"front": True, "side": True, "top": True}, - info_received_at={"front": 10., "side": 10., "top": 5.}, - detections_received_at={"side": 10.}, errors={})) - status = asdict(CalibrationCoordinator._device_status(host, 10.)) - assert not status["ready"] - assert not status["cameras"]["front"]["detections_ready"] - assert status["cameras"]["side"]["detections_ready"] - assert not status["cameras"]["top"]["camera_info_ready"] - text = render_status_zh(normalize_status({"state": "WAIT_DEVICES", "devices": status}, - profile=profile, serial_number="12345")) - assert "O6 / right / 12345" in text - assert "front 未收到" in text and "top 未收到" in text - # Readiness requires messages, not recognized Tag IDs or a rate floor. - host.cameras.info_received_at = dict.fromkeys(profile.vision.view_names, 10.) - host.cameras.detections_received_at = dict(host.cameras.info_received_at) - assert CalibrationCoordinator._device_status(host, 10.).ready - host.ports.command_publisher_count = lambda: 2 - assert "存在其他机械手指令发布者" in CalibrationCoordinator._device_status(host, 10.).waiting_for - - -def test_steady_checkpoint_does_not_erase_direction_statistics(): - from linkerhand_calibration.runtime.branch_initialization import BranchInitialization - host = SimpleNamespace(_steady_rows=[], _unit_rows=[], _capture_unit_key=None, - capture=SimpleNamespace(begin_motion=lambda motion: None), - branch_initialization=BranchInitialization(load_bundled_hand_profile("o6_right_8")), - _observation_epoch=1, _segment_number=1) - motion = MotionCommand("sweep", (0.,), 1., task_key="test", command_index=0, - cycle=0, direction="decreasing") - begin = MethodType(CalibrationCoordinator._begin_motion_capture, host) - begin(motion) - host._unit_rows.append({"sample_phase": "sweep"}) - begin(replace(motion, phase="steady", steady_index=0)) - host._steady_rows.append({"sample_phase": "steady"}) - host._unit_rows.extend(host._steady_rows) - begin(replace(motion, phase="steady", steady_index=1)) - assert len(host._unit_rows) == 2 - assert host._steady_rows == [] - begin(replace(motion, attempt=2)) - assert host._unit_rows == [] - host._unit_rows.append({"sample_phase": "sweep"}) - begin(replace(motion, attempt=2, segment_key="next_segment")) - assert host._unit_rows == [] - - -def test_ros_io_loads_profile_views_instead_of_three_camera_compatibility(tmp_path): - import json - from linkerhand_calibration.core.geometry.extrinsics import camera_info_fingerprint - from linkerhand_calibration.runtime.ros.parameters import parameter_defaults, load_runtime_parameters - profile = load_bundled_hand_profile("o6_right_8") - vision = replace(profile.vision, - views=(replace(profile.vision.views[0], name="inspection"),), - extrinsic_reference_view="inspection", extrinsics_quality_limits={}, minimum_capture_counts={}) - profile = replace(profile, vision=vision) - extrinsics = tmp_path / "extrinsics.json" - extrinsics.write_text(json.dumps({"schema_version": 1, "reference_view": "inspection", - "quality": {"passed": True}, - "cameras": {"inspection": {"serial_number": "test", "width": 640, "height": 480, - "intrinsics_sha256": camera_info_fingerprint(width=640, height=480, - camera_matrix=[500,0,320,0,500,240,0,0,1])}}, - "inspection_from_view": {"inspection": {"translation_xyz_m": [0,0,0], - "quaternion_xyzw": [0,0,0,1]}}})) - params = parameter_defaults(profile) - params.update(serial_number="test", session_dir=str(tmp_path), source_urdf_path=str(extrinsics), - camera_extrinsics_file=str(extrinsics)) - for key in profile.artifacts.protected_input_fields: - params[key.replace("_sha256", "_expected_sha256")] = "a"*64 - resolved = load_runtime_parameters(profile, params.__getitem__) - assert set(resolved.extrinsics.cameras) == {"inspection"} - assert set(resolved.detection_topics) == {"inspection"} - - -@pytest.mark.parametrize("schema", [1, 4, 6, 7]) -def test_production_cannot_fall_back_to_feedback_only_legacy_output(tmp_path, schema): - from pathlib import Path - from linkerhand_calibration.profiles.validator import validate_executable_profile - from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session - profile = load_bundled_hand_profile("o6_right_8") - profile = replace(profile, artifacts=replace(profile.artifacts, output_schema_version=schema)) - source = Path(__file__).resolve().parents[1] / "urdf/o6_right/linkerhand_o6_right.urdf" - with pytest.raises(ValueError, match="unified output_schema_version"): - validate_executable_profile(profile, source) - with pytest.raises(ValueError, match="unified output_schema_version"): - finalize_profile_session(profile=profile, session_dir=tmp_path / "session", serial_number="test", - source_urdf=source, protected_inputs={}, records=()) - assert not (tmp_path / "session").exists() diff --git a/src/linkerhand_calibration/test/test_safety_policy.py b/src/linkerhand_calibration/test/test_safety_policy.py deleted file mode 100644 index b50387f..0000000 --- a/src/linkerhand_calibration/test/test_safety_policy.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from dataclasses import replace - -import pytest - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry -from linkerhand_calibration.runtime import SafetyPolicy, SafetySample -from linkerhand_calibration.runtime.adapters import HardwareHealth -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.reporting.reasons_zh import reason_zh -from linkerhand_calibration.runtime.safety import MotionGoal - - -@pytest.fixture(params=["g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16"]) -def profile(request): - return next(item.profile for item in get_default_registry() - if item.profile.key.layout == request.param) - - -def test_normal_lag_coupling_and_low_visual_rate_are_not_safety_inputs(profile) -> None: - decision = SafetyPolicy(profile).evaluate( - SafetySample( - now_seconds=10.0, - feedback_timestamp_seconds=9.5, - command=profile.command.baseline_values, - feedback=tuple(value + (0.01 if profile.command.unit == "rad" else -1) - for value in profile.command.baseline_values), - health=HardwareHealth(True, True), - ) - ) - assert decision.safe - - -def test_stale_feedback_hardware_fault_and_reference_motion_stop(profile) -> None: - policy = SafetyPolicy(profile) - common = dict( - now_seconds=10.0, - command=profile.command.baseline_values, - feedback=profile.command.baseline_values, - health=HardwareHealth(True, True), - ) - assert policy.evaluate( - SafetySample(feedback_timestamp_seconds=8.9, **common) - ).code == "feedback_stale" - assert policy.evaluate( - SafetySample( - feedback_timestamp_seconds=9.9, - **{**common, "health": HardwareHealth(True, True, ("overheat",))}, - ) - ).code == "hardware_fault" - assert policy.evaluate( - SafetySample( - feedback_timestamp_seconds=9.9, - **common, - reference_moved=True, - ) - ).code == "fixed_reference_moved" - - -def test_two_second_no_progress_stops_but_ordinary_lag_does_not(profile) -> None: - policy = SafetyPolicy(profile) - - def sample(now: float, feedback: float) -> SafetySample: - values = list(profile.command.baseline_values) - values[0] = feedback if profile.command.unit == "rad" else 255 - 100 * feedback - command = list(profile.command.baseline_values) - command[0] = 0.8 if profile.command.unit == "rad" else 0 - return SafetySample( - now_seconds=now, - feedback_timestamp_seconds=now, - command=tuple(command), - feedback=tuple(values), - health=HardwareHealth(True, True), - motion_expected=True, - target_channel=0, - target_value=command[0], - ) - - assert policy.evaluate(sample(0.0, 0.0)).safe - assert policy.evaluate(sample(1.0, 0.1)).safe - assert policy.evaluate(sample(2.5, 0.1)).safe - stopped = policy.evaluate(sample(3.1, 0.1)) - assert not stopped.safe - assert stopped.code == "mechanical_stall" - - -@pytest.mark.parametrize("timeout", [2., 4.]) -def test_configured_timeout_preserves_no_progress_stop_and_reports_actual_duration(timeout): - profile = load_bundled_hand_profile("o30_right_18", scope="full") - profile = replace(profile, acquisition=replace(profile.acquisition, stall_timeout_seconds=timeout)) - policy = SafetyPolicy(profile) - - def sample(now, command_value=223., feedback_value=253.): - command = list(profile.command.baseline_values) - feedback = command.copy() - command[0], feedback[0] = command_value, feedback_value - return SafetySample(now, now, tuple(command), tuple(feedback), HardwareHealth(True, True), - motion_expected=True, motion_id="endpoint_return", - motion_goals=(MotionGoal(0, 253., 3., .5, .5),)) - - assert policy.evaluate(sample(0.)).safe - # More than 30 units of tracking error does not cause the stop, and new - # streaming commands must not restart the no-progress timer. - assert policy.evaluate(sample(timeout-.01, command_value=190.)).safe - stopped = policy.evaluate(sample(timeout, command_value=180.)) - assert stopped.code == "mechanical_stall" - assert f"timeout_seconds={timeout:g}" in stopped.details - assert "command=180,feedback=253,feedback_progress_goal=3," in stopped.details - explanation = reason_zh({"reason": f"{stopped.code}:{stopped.reason}:{stopped.details}"}, model_name="O30") - assert f"连续 {timeout:g} 秒" in explanation[1] - - policy = SafetyPolicy(profile) - assert policy.evaluate(sample(0.)).safe - # Genuine late feedback progress earns a new interval; command updates do not. - assert policy.evaluate(sample(timeout-.1, feedback_value=252.)).safe - assert policy.evaluate(sample(timeout+.1, feedback_value=251.)).safe diff --git a/src/linkerhand_calibration/test/test_sample_schema.py b/src/linkerhand_calibration/test/test_sample_schema.py deleted file mode 100644 index fd26fb0..0000000 --- a/src/linkerhand_calibration/test/test_sample_schema.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for the explicit calibration sample command domains.""" - -from linkerhand_calibration.sample_schema import ( - SampleDataContractError, - canonical_sample_record, - fitting_sample_record, - fitting_sample_records, -) - -import pytest - - -def _sample(kind: str = 'sample') -> dict[str, object]: - return { - 'kind': kind, - 'joint': 'thumb_cmc_roll', - 'cycle': 0, - 'direction': 'decreasing', - 'requested_command_u8': 0, - 'feedback_u8': 17.6, - } - - -def test_online_and_serialized_replay_use_identical_fit_projection() -> None: - """Serialization must not change the curve fitter's input record.""" - durable = canonical_sample_record(_sample()) - online = fitting_sample_record(durable) - serialized = canonical_sample_record(online) - replay = fitting_sample_record(serialized) - - assert 'command_u8' not in durable - assert serialized == durable - assert replay == online - assert online['command_u8'] == 18 - - -def test_default_domain_is_requested_only_for_steady_checkpoints() -> None: - """Settled checkpoints and dense trajectories use declared domains.""" - steady = _sample('steady_command_sample') - - assert fitting_sample_record(steady)['command_u8'] == 0 - assert fitting_sample_record(steady, domain='feedback')['command_u8'] == 18 - - -def test_feedback_projection_can_snap_proven_requested_endpoints() -> None: - """A settled endpoint remains exact after feedback-domain projection.""" - records = fitting_sample_records( - [_sample()], - domain='feedback', - snap_requested_endpoints=True, - ) - - assert records[0]['command_u8'] == 0 - assert records[0]['feedback_u8'] == pytest.approx(17.6) - - -def test_new_sample_rejects_missing_explicit_feedback_domain() -> None: - """A new observation cannot rely on the compatibility command key.""" - ambiguous = { - 'kind': 'sample', - 'requested_command_u8': 0, - 'command_u8': 0, - } - - with pytest.raises( - SampleDataContractError, - match='DATA-CONTRACT-701.*feedback_u8', - ): - canonical_sample_record(ambiguous) diff --git a/src/linkerhand_calibration/test/test_separate_mapping_capture.py b/src/linkerhand_calibration/test/test_separate_mapping_capture.py deleted file mode 100644 index d1644e0..0000000 --- a/src/linkerhand_calibration/test/test_separate_mapping_capture.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Continuous four-round motion and independent move-and-hold mapping.""" - -from collections import defaultdict -from dataclasses import replace - -import numpy as np -import pytest - -from linkerhand_calibration.core.domain.sampling import command_nodes -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit -from runtime_host_fixture import coordinator_fixture, ready -from test_diagnostic_coordinator import advance, TERMINAL_PHASES -from test_single_pass_capture import CapturedFinalization - - -def separate(profile): - return replace(profile, acquisition=replace(profile.acquisition, - command_capture_mode="separate", steady_training_nodes=9, steady_extra_training_nodes={})) - - -def test_four_continuous_rounds_and_separate_mapping_reach_finalization(tmp_path, monkeypatch): - original = CalibrationEngine.scan_units - monkeypatch.setattr(CalibrationEngine, "scan_units", lambda engine: - tuple(unit for unit in original(engine) if unit.task_key == "thumb_yaw_top")) - finalizer = CapturedFinalization() - host, clock = coordinator_fixture(tmp_path, finalization=finalizer, profile_transform=separate) - 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 - entries, seen = defaultdict(list), set() - try: - for _ in range(9000): - if finalizer.started: - break - assert host.execution.session.phase not in TERMINAL_PHASES, host.reason - advance(host, clock) - motion = host._motion - if motion is not None and motion.phase in {"sweep", "steady"} and host._segment_number not in seen: - seen.add(host._segment_number) - entries[(motion.cycle, motion.direction)].append(motion) - assert finalizer.started and host.execution.session.phase == Phase.FIT, host.reason - assert len(host.execution.completed_units) == 12 - units = host.execution.session.engine.scan_units() - records = finalizer.inputs[0].records - spans = {} - for unit in units: - motions = entries[(unit.cycle, unit.direction)] - if unit.sample_phase == "sweep": - assert len(motions) == 1 and motions[0].phase == "sweep" - assert motions[0].target[motions[0].command_index] == unit.end - else: - assert all(m.phase == "steady" for m in motions) - plan = host.execution.sampling_plans[unit.task_key] - assert len(motions) == (len(plan["training_nodes"]) if unit.cycle == 4 else 10) - quality = evaluate_capture_unit(host.profile, unit, 1, records, first_cycle_spans=spans) - assert quality.passed, quality.failures - samples = [r for r in records if r.get("kind") == "joint_sample"] - moving = [r for r in samples if r["sample_phase"] == "sweep"] - steady = [r for r in samples if r["sample_phase"] == "steady"] - assert {r["cycle"] for r in moving} == {0, 1, 2, 3} - assert {r["cycle"] for r in steady} == {4, 5} - assert {r["image_stamp_ns"] for r in moving}.isdisjoint(r["image_stamp_ns"] for r in steady) - assert all(r["command_u8"] == r["steady_target"] for r in steady) - assert len(host.execution.sampling_plans["thumb_yaw_top"]["training_nodes"]) < 9 - from linkerhand_calibration.core.fitting.command_sampling import validate_sampling_plans - validate_sampling_plans(host.profile, records) - assert any(r.get("kind") == "preparation_reused_settled_pose" for r in records) - incomplete = [r for r in records if not (r.get("sample_phase") == "steady" - and r.get("cycle") == 5 and r.get("steady_index") == 1)] - assert not evaluate_capture_unit(host.profile, units[-1], 1, incomplete, first_cycle_spans=spans).passed - assert not list(tmp_path.rglob("release_manifest.json")) - monkeypatch.setattr(host.capture, "_motion_model_schedule", - lambda *_: pytest.fail("finalization must not continue solving moving joints")) - advance(host, clock) - assert host.execution.session.phase == Phase.FIT - from test_diagnostic_coordinator import projected_frame - for _ in range(host.profile.acquisition.fixed_reference_confirmation_frames): - clock.now += .04 - frame = projected_frame(host, "top", host.last_command) - moved = tuple(replace(tag, corners=tuple((x + 10, y) for x, y in tag.corners)) - if tag.tag_id == 6 else tag for tag in frame.detections) - host.receive_detections(replace(frame, detections=moved)) - # Acquisition has closed. These later images are outside the frozen - # dataset and must not interrupt its numerical/file validation. - assert host.execution.session.phase == Phase.FIT - assert not any(row.get("kind") == "paused" for row in host.raw_records) - finally: - host.close() - - -def test_extra_endpoint_node_keeps_validation_independent(): - from linkerhand_calibration.profiles import load_bundled_hand_profile - profile = separate(load_bundled_hand_profile("o6_right_8")) - profile = replace(profile, acquisition=replace(profile.acquisition, - steady_extra_training_nodes={"pinky_pitch_dip_side": (239.,)})) - task = next(t for t in profile.motion.tasks if t.key == "pinky_pitch_dip_side") - training = set(command_nodes(profile, task)) - validation = set(command_nodes(profile, task, holdout=True)) - assert 239 in training and 247 in validation - assert training & validation == {0., 255.} - assert len(training) == 10 and len(validation) == 11 - - -@pytest.mark.parametrize("model", ["o6", "g20"]) -@pytest.mark.parametrize("changed", ["feedback", "age", "target", "command", "phase", "missing"]) -def test_preparation_cannot_reuse_changed_or_expired_settling(tmp_path, monkeypatch, model, changed): - from linkerhand_calibration.runtime.motion_execution import MotionCommand - host, clock = coordinator_fixture(tmp_path, model=model) - ready(host, clock) - target = tuple(host.profile.command.baseline_values) - feedback = tuple(host.latest_feedback) - host.last_command = target - host._settled_pose = (target, feedback, clock.now) - motion = MotionCommand("prepare", target, 1.) - delta = .002 if host.command_unit == "rad" else 2. - altered = (target[0] + delta, *target[1:]) - if changed == "feedback": - clock.now += .01 - host.receive_feedback(host.profile.command.names, - (feedback[0] + delta, *feedback[1:]), host.ports.clock_ns()) - elif changed == "age": - clock.now += 1.01 - elif changed == "target": - motion = replace(motion, target=altered) - elif changed == "command": - host.last_command = altered - elif changed == "phase": - motion = replace(motion, phase="zero_approach") - else: - host._settled_pose = None - monkeypatch.setattr(host.execution, "motion_complete", - lambda: pytest.fail("this preparation must run its normal motion and settling checks")) - try: - assert not host._reuse_settled_preparation(motion, clock.now) - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_session_execution.py b/src/linkerhand_calibration/test/test_session_execution.py deleted file mode 100644 index c40583f..0000000 --- a/src/linkerhand_calibration/test/test_session_execution.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Virtual-clock tests use the same effect driver intended for the ROS host.""" - -import math - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.motion_execution import MotionExecution -from linkerhand_calibration.runtime.scan_quality import observation_streams -from linkerhand_calibration.runtime.steady import steady_targets - - -@pytest.mark.parametrize("layout", ["l6_right_8", "o6_right_8", "o12_right_16", "g20_right_19"]) -@pytest.mark.parametrize("capture_mode", ["interleaved", "separate"]) -def test_one_driver_executes_every_profile_and_one_same_speed_retry(layout, capture_mode): - profile = load_bundled_hand_profile(layout) - from dataclasses import replace - profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode=capture_mode)) - profile = replace(profile, artifacts=replace(profile.artifacts, output_schema_version=2, - directional_command_joints=frozenset())) - driver = SessionExecution(profile) - driver.session.device_ready() - driver.session.start() - command = profile.command.baseline_values - rows, stamp, failed_once, directions = [], 0, False, {} - for _ in range(4000): - phase = driver.session.phase - if phase == Phase.FIT: - break - if phase == Phase.REFERENCE_LOCKING: - driver.session.reference_locked() - continue - if phase == Phase.EVALUATE: - driver.evaluate(rows) - continue - motion = driver.motion(command) - assert motion is not None, phase - if phase in {Phase.SWEEP, Phase.RESCAN}: - assert motion.phase != "steady_prepare" - initial = command - command = motion.target - if motion.recording: - unit = driver.action.scan_unit - task = next(t for t in profile.motion.tasks if t.key == unit.task_key) - key = (unit.task_key, unit.cycle, unit.direction, motion.attempt) - directions.setdefault(key, []).append((unit, initial, motion)) - # The first direction fails once; no speed reduction or extra retry. - # Omit its first steady point, keeping all moving observations. - if not failed_once and (capture_mode == "separate" or motion.phase == "steady"): - failed_once = True - else: - start = initial[task.command_index] - end = command[task.command_index] - count = (max(3, math.ceil(128*abs(end-start)/abs(unit.end-unit.start))+1) - if motion.phase == "sweep" else profile.acquisition.steady_minimum_samples) - for field, joint, spec in observation_streams(profile, task): - for index in range(count): - stamp += 1 - feedback = start+(end-start)*index/(count-1) if motion.phase == "sweep" else end - rows.append({field: joint, "view": spec.view, "task_name": task.key, - "cycle": unit.cycle, "direction": unit.direction, - "attempt": motion.attempt, "image_stamp_ns": stamp, - "sample_phase": motion.phase, "steady_index": motion.steady_index, - "steady_target": motion.target[task.command_index], - f"feedback_{profile.command.unit}": feedback}) - driver.motion_complete() - assert driver.session.phase == Phase.FIT - unit_count = 12 if capture_mode == "separate" else 8 - assert len(driver.completed_units) == len(profile.motion.tasks)*unit_count - assert len(directions) == len(profile.motion.tasks)*unit_count+1 - for key, trace in directions.items(): - unit = trace[0][0] - task = next(t for t in profile.motion.tasks if t.key == unit.task_key) - points = steady_targets(profile, unit) - if capture_mode == "interleaved": - assert [motion.phase for _, _, motion in trace] == ["sweep", "steady"]*len(points) - elif unit.sample_phase == "sweep": - assert len(trace) == 1 and trace[0][2].phase == "sweep" - points = () - else: - assert all(motion.phase == "steady" for _, _, motion in trace) - held = [motion for _, _, motion in trace if motion.phase == "steady"] - if capture_mode == "interleaved": - sweeps = [motion for _, _, motion in trace if motion.phase == "sweep"] - assert all(motion.defer_settling_to_hold for motion in sweeps[1:-1]) - assert not sweeps[0].defer_settling_to_hold and not sweeps[-1].defer_settling_to_hold - assert tuple(motion.target[task.command_index] for motion in held) == points - assert [motion.steady_index for motion in held] == list(range(len(points))) - assert trace[0][1][task.command_index] == pytest.approx(unit.start) - assert trace[-1][2].target[task.command_index] == pytest.approx(unit.end) - travelled = 0.0 - sign = 1.0 if unit.direction == "increasing" else -1.0 - for _, initial, motion in trace: - delta = motion.target[task.command_index]-initial[task.command_index] - if motion.phase == "sweep": - assert sign*delta > 0 or (capture_mode == "interleaved" and (initial, motion) == trace[0][1:]) - elif capture_mode == "interleaved": - assert delta == pytest.approx(0.) - else: - assert sign*delta >= 0 - assert all(motion.target[i] == initial[i] for i in range(profile.command.command_count) - if i != task.command_index) - assert motion.speed == unit.speed - travelled += abs(delta) - assert travelled == pytest.approx(abs(unit.end-unit.start)) - for task in profile.motion.tasks: - units = [trace[0][0] for key, trace in directions.items() if key[0] == task.key and key[3] == 1] - assert len(units) == unit_count - assert len([unit for unit in units if unit.cycle < 4]) == 8 - assert all({unit.direction for unit in units if unit.cycle == cycle} - == {"increasing", "decreasing"} for cycle in (0, 1, 2, 3)) - distance = sum(abs(motion.target[task.command_index]-initial[task.command_index]) - for key, trace in directions.items() if key[0] == task.key and key[3] == 1 - for _, initial, motion in trace) - assert distance == pytest.approx(unit_count*abs(task.end_value-task.start_value)) - retried = [key for key in directions if key[3] > 1] - assert len(retried) == 1 and retried[0][3] == 2 - first_key = (*retried[0][:3], 1) - assert [motion.speed for _, _, motion in directions[first_key]] == [ - motion.speed for _, _, motion in directions[retried[0]]] - - -def test_feedback_bias_does_not_turn_completed_travel_into_a_stall(): - profile = load_bundled_hand_profile("o12_right_16") - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime.safety import SafetyPolicy, SafetySample - from linkerhand_calibration.runtime.adapters import HardwareHealth - start = list(profile.command.baseline_values) - feedback = start.copy() - feedback[1] = -0.023 - target = start.copy() - target[1] = -0.1 - segment = MotionExecution(profile, MotionCommand("prepare", tuple(target), .04), - initial_command=start, initial_feedback=feedback, now=0, identity="one-segment") - policy = SafetyPolicy(profile) - for index in range(1000): - now = index*.02 - command = segment.sample(now) - feedback[1] = -.023+command[1] - segment.observe(feedback, stamp=index, now=now) - assert policy.evaluate(SafetySample(now, now, command, tuple(feedback), HardwareHealth(True, True), - motion_expected=segment.motion_expected, motion_id=segment.identity, motion_goals=segment.goals)).safe - assert segment.arrived(now) - - -def test_duplicate_images_and_missing_secondary_view_fail_after_motion_only(): - profile = load_bundled_hand_profile("o12_right_16") - from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit - from linkerhand_calibration.runtime.engine import CalibrationEngine - unit = next(u for u in CalibrationEngine(profile).scan_units() if u.task_key == "middle_roll_front") - rows = [{"joint": "middle_mcp_roll", "view": "front", "task_name": unit.task_key, - "cycle": 0, "direction": unit.direction, "attempt": 1, "image_stamp_ns": 1, - "feedback_rad": unit.start}]*100 - quality = evaluate_capture_unit(profile, unit, 1, rows, first_cycle_spans={}) - assert not quality.passed - assert any("observation_joint" in item for item in quality.failures) - assert any("frames=1" in item for item in quality.failures) - - -def test_tiny_baseline_bias_and_mapping_jog_have_different_completion_rules(): - from linkerhand_calibration.runtime.motion_execution import MotionCommand - profile = load_bundled_hand_profile("o12_right_16") - start = list(profile.command.baseline_values) - start[0], start[1] = .021, -.023 - segment = MotionExecution(profile, MotionCommand("baseline", profile.command.baseline_values, .1), - initial_command=start, initial_feedback=start, now=0, identity="baseline") - for n in range(50): - now = n*.1 - segment.sample(now) - segment.observe(start, stamp=n, now=now) - assert not segment.motion_expected - assert segment.arrived(now) - target = list(profile.command.baseline_values) - target[1] = -profile.acquisition.mapping_probe_maximum_rad - jog = MotionExecution(profile, MotionCommand("mapping_probe", tuple(target), .06), - initial_command=profile.command.baseline_values, initial_feedback=start, now=0, identity="jog") - moved = start.copy() - moved[1] -= .02 - for n in range(50): - now = n*.1 - jog.sample(now) - jog.observe(moved, stamp=n, now=now) - assert jog.arrived(now) # Basic movement proves mapping; not an 80% accuracy gate. diff --git a/src/linkerhand_calibration/test/test_shared_pose_conditioning.py b/src/linkerhand_calibration/test/test_shared_pose_conditioning.py deleted file mode 100644 index bd9b05e..0000000 --- a/src/linkerhand_calibration/test/test_shared_pose_conditioning.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Measured shared posture prevents independently fitted monocular depth drift.""" - -from copy import deepcopy -from dataclasses import replace -import gzip -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.pose_bridge import ( - SHARED_ZERO_CONDITIONING, SHARED_TAG_POSE_CONDITIONED_POLICY, - check_source_pose, check_pose_bridge, -) -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ImageMotionParameters -from linkerhand_calibration.core.geometry.tag_pose.motion_image_diagnostics import _validate_frames -from linkerhand_calibration.core.geometry.tag_pose.shared_zero_solver import SharedZeroImageHingeBundle -from linkerhand_calibration.core.geometry.tag_pose.hinge_uncertainty import hinge_uncertainty -from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame -from linkerhand_calibration.core.geometry.tag_pose.relative import _relative_pose -from linkerhand_calibration.runtime.branch_initialization import solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.motion_provenance import _initializations, validate_source_geometry -from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references -from linkerhand_calibration.profiles import load_bundled_hand_profile -from test_shared_tag_pose_bridge import request_from_recording -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope="module", params=["middle", "ring"]) -def measured(request): - path = Path(__file__).parent / f"fixtures/o30_shared_tag_{request.param}_pip.json.gz" - data = json.loads(gzip.decompress(path.read_bytes())) - _, pending = request_from_recording(data) - assert not pending.invalid_reason - result = solve_initialization(pending) - assert result.resolved, result - return data, pending, result - - -def test_full_zero_is_validated_by_independent_current_images_and_new_arc(measured): - _, request, result = measured - bridge = request.pose_bridges[0] - source = check_source_pose(bridge) - assert source.status == "consistent" and source.maximum_translation_m < .0002 - geometry = result.model.geometry[0] - point = np.asarray(geometry.pivot_parent_xyz_m) + Rotation.from_quat( - geometry.reference_quaternion_xyzw).apply(geometry.mount_child_xyz_m) - assert np.allclose(point, bridge.translation_xyz_m, atol=1e-12, rtol=0) - # The new hinge's independently measured axis must not be inherited from MCP. - old_axis = np.asarray(bridge.source_model.geometry[0].axis_parent_xyz) - assert not np.allclose(old_axis, geometry.axis_parent_xyz) - assert len(bridge.frames) == 10 and len(request.image_frames) == 129 - assert not set(result.model.source_stamps_ns) & {f.evidence.stamp_ns for f in bridge.frames} - checked = check_pose_bridge(result.model, bridge) - assert checked.status == "consistent" - assert checked.maximum_translation_m < .0002 - assert np.rad2deg(checked.maximum_rotation_rad) < .4 - for hypothesis in result.hypotheses: - assert hypothesis.converged and not hypothesis.reason - assert hypothesis.pose_bridge_checks[0].status == "consistent" - assert hypothesis.validation_quality[0].rms_px < .4 - assert result.model.maximum_reprojection_error_px == 1.5 - assert all(select_image_motion_frame(result.model, f).resolved for f in request.image_frames[1::2]) - - -def test_recorded_middle_failure_is_reproduced_with_orientation_only_conditioning(): - data = json.loads(gzip.decompress((Path(__file__).parent / "fixtures/o30_shared_tag_middle_pip.json.gz").read_bytes())) - _, request = request_from_recording(data) - bridge = replace(request.pose_bridges[0], conditioning=SHARED_ZERO_CONDITIONING) - result = solve_initialization(replace(request, pose_bridges=(bridge,))) - assert not result.resolved and result.reason == "image_motion_shared_pose_unresolved" - for hypothesis in result.hypotheses: - check = hypothesis.pose_bridge_checks[0] - assert .0052 < check.maximum_translation_m < .0053 - assert np.rad2deg(check.maximum_rotation_rad) < .21 - # The recorded, independently fitted position error is almost all depth. - from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import _fit_hypothesis - limits = ImageMotionParameters() - paths, roles = _validate_frames(request.image_frames, request.relations, limits) - branches = tuple((role, 0) for role in roles) - _, model, _ = _fit_hypothesis(request.image_frames, request.relations, - {role: paths[role][0] for role in roles}, branches, - list(range(0, 129, 2)), list(range(1, 129, 2)), limits, pose_bridges=(bridge,)) - selections = select_image_motion_frame(model, bridge.frames[0]) - poses = {s.role: s.pose for s in selections.selections} - _, point = _relative_pose(poses[bridge.relation.parent_role], poses[bridge.relation.child_role]) - difference = Rotation.from_quat(poses[bridge.relation.parent_role].quaternion_xyzw).apply( - point-np.asarray(bridge.translation_xyz_m)) - assert abs(difference[2]) > .005 and np.linalg.norm(difference[:2]) < .0004 - - -def test_full_uncertainty_restores_orientation_and_translation(measured): - _, request, _ = measured - limits = ImageMotionParameters() - paths, roles = _validate_frames(request.image_frames, request.relations, limits) - bundle = SharedZeroImageHingeBundle(request.image_frames[::2], request.relations, - {role: paths[role][0][::2] for role in roles}, request.pose_bridges) - fit = bundle.solve(limits) - free, linearization = bundle.uncertainty_problem(fit) - assert len(linearization.x) == len(fit.x)+5 - assert np.allclose(bundle.residual(fit.x), free.residual(linearization.x), atol=1e-7) - uncertainty = hinge_uncertainty(bundle, fit) - assert uncertainty == hinge_uncertainty(free, linearization) - assert uncertainty[0][0][1] > .01 and uncertainty[1][0][1] > .0005 - - -@pytest.mark.parametrize("change", ["source_translation", "current_pixels"]) -def test_full_pose_requires_source_to_explain_fresh_independent_images(measured, change): - _, request, _ = measured - bridge = request.pose_bridges[0] - if change == "source_translation": - bridge = replace(bridge, translation_xyz_m=tuple(np.asarray(bridge.translation_xyz_m)+[0, 0, .011])) - else: - frames = tuple(replace(f, observations=tuple(replace(o, - corners_xy=tuple((x+20, y) for x, y in o.corners_xy)) - if o.role == bridge.relation.child_role else o for o in f.observations)) for f in bridge.frames) - bridge = replace(bridge, frames=frames) - result = solve_initialization(replace(request, pose_bridges=(bridge,))) - assert not result.resolved and result.reason.startswith("image_motion_shared_pose_source_unverified:") - - -def test_source_anchor_cannot_authorize_an_incompatible_new_arc(measured): - _, request, _ = measured - child_role = request.relations[0].child_role - frames = tuple(replace(f, observations=tuple(replace(o, - corners_xy=tuple((x+20, y) for x, y in o.corners_xy)) - if o.role == child_role else o for o in f.observations)) for f in request.image_frames) - assert check_source_pose(request.pose_bridges[0]).status == "consistent" - result = solve_initialization(replace(request, image_frames=frames)) - assert not result.resolved - assert any(h.reason in {"image_motion_reprojection_failed", "image_motion_geometry_uncertain"} - for h in result.hypotheses) - - -@pytest.fixture(scope="module") -def journal(measured): - data, request, result = measured - _, report = initialization_artifacts(request, result) - assert report["image_model_selection_policy"] == SHARED_TAG_POSE_CONDITIONED_POLICY - records = json.loads(json.dumps([*data["records"], report])) - return records, _initializations(records) - - -def test_full_pose_contract_survives_artifact_readback(journal): - records, evidence = journal - profile = load_bundled_hand_profile("o30_right_18") - validate_shared_tag_references(profile, records, evidence) - validate_source_geometry(profile, SOURCE, records) - - -def test_readback_checks_translation_even_after_model_hash_is_recomputed(journal): - from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 - records, evidence = deepcopy(journal) - payload = next(p for p in evidence.values() if p.get("shared_tag_pose_bridges")) - geometry = payload["frozen_image_model"]["geometry"][0] - geometry["mount_child_xyz_m"][0] += .001 - payload["image_model_sha256"] = image_model_sha256(payload["frozen_image_model"]) - with pytest.raises(ValueError, match="conditioned_translation_changed"): - validate_shared_tag_references(load_bundled_hand_profile("o30_right_18"), records, evidence) - - -def test_unknown_conditioning_cannot_be_read_as_legacy_unconditioned(journal): - records, _ = deepcopy(journal) - report = next(r for r in records if r.get("shared_tag_pose_bridges")) - report["image_model_selection_policy"] = "training_squared_loss_shared_pose_holm_v2" - report["shared_tag_pose_bridges"][0]["conditioning"] = "unknown" - with pytest.raises(ValueError, match="conditioning_policy_changed"): - _initializations(records) diff --git a/src/linkerhand_calibration/test/test_shared_pose_handoff.py b/src/linkerhand_calibration/test/test_shared_pose_handoff.py deleted file mode 100644 index f6a6319..0000000 --- a/src/linkerhand_calibration/test/test_shared_pose_handoff.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Real MCP -> PIP evidence handoff, with immutable zeros and legacy replay.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import configure_training -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.geometry.tag_pose.pose_bridge import ( - check_source_pose, check_pose_bridge, SHARED_TAG_HANDOFF_POLICY, -) -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation -from linkerhand_calibration.runtime.branch_initialization import ( - BranchInitialization, solve_initialization, initialization_artifacts, -) -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import _initializations, source_frame_sha256 -from linkerhand_calibration.runtime.passed_resume import restore_parent_models -from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references -from linkerhand_calibration.runtime.shared_pose_handoff import source_window -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def capture(): - data = json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_pinky_handoff.json.gz').read_bytes())) - # Replay the captured holding posture and printed sizes, not today's - # user-edited ring baseline or 16 mm production tags. Evidence is immutable. - profile = load_bundled_hand_profile('o30_right_18') - baseline = tuple(data['original_failure']['shared_tag_pose_bridges'][0] - ['source_reference']['samples'][0]['command']) - changed = {i: value for i, value in enumerate(baseline) - if value != profile.command.baseline_values[i]} - def historical_command(command): - return tuple(changed.get(i, value) if value == profile.command.baseline_values[i] else value - for i, value in enumerate(command)) - sizes = {(row['view'], role): tag['tag_size_m'] for row in data['records'] - for role, tag in row.get('tags', {}).items()} - profile = replace(profile, command=replace(profile.command, baseline_u8=tuple(map(int, baseline))), - motion=replace(profile.motion, joint_zero_references={name: replace(spec, - command=historical_command(spec.command), - approach_commands=tuple(historical_command(p) for p in spec.approach_commands)) - for name, spec in profile.motion.joint_zero_references.items()}), - vision=replace(profile.vision, views=tuple(replace(view, tags=tuple(replace(tag, - size_m=sizes.get((view.name, tag.role), tag.size_m)) for tag in view.tags)) - for view in profile.vision.views))) - profile = configure_preparation(configure_training(profile, - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - owner = BranchInitialization(profile, source_urdf=SOURCE) - failure = data['original_failure'] - reference = JointZeroReference.from_record(failure['shared_tag_pose_bridges'][0]['source_reference']) - source = next(r for r in data['records'] if r['kind'] == 'motion_branch_initialization') - restore_parent_models(owner.parent_references, [source], 1) - motion = MotionCommand('zero_approach', profile.motion.joint_zero_references['pinky_pip'].command, - 200., task_key='pinky_pip_side', reference_joints=('pinky_pip',)) - owner.begin(motion, session_epoch=1, motion_version=failure['motion_version']) - for row in data['records']: - if row['kind'] == 'motion_branch_observation': - owner.observe(row) - owner.parent_references.handoff.observe(row) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=('pinky_pip',)), - session_epoch=1, motion_version=failure['motion_version']+1, zero_references={reference.joint:reference}) - assert not request.invalid_reason - return profile, owner, request, data, reference - - -@pytest.fixture(scope='module') -def replay(capture): - profile, owner, request, data, reference = capture - result = solve_initialization(request) - assert result.resolved, result.reason - model, report = initialization_artifacts(request, result) - return result, model, report, [*data['records'], report] - - -@pytest.mark.replay -def test_real_handoff_preserves_gates_and_formal_readback(capture, replay): - profile, _, request, _, reference = capture - result, _, report, records = replay - bridge = request.pose_bridges[0] - assert check_source_pose(replace(bridge, source_frames=())).reason.startswith( - 'shared_pose_current_image_unresolved:image_motion_frame_reprojection_or_tilt_failed') - assert check_source_pose(bridge).status == 'consistent' - assert check_pose_bridge(result.model, bridge).status == 'consistent' - assert len(bridge.source_frames) == len(bridge.frames) == 10 - assert max(f.evidence.stamp_ns for f in bridge.source_frames) < min(f.evidence.stamp_ns for f in request.image_frames) - assert report['image_model_selection_policy'] == SHARED_TAG_HANDOFF_POLICY - assert JointZeroReference.from_record(report['shared_tag_pose_bridges'][0]['source_reference']) == reference - assert result.model.maximum_reprojection_error_px == 1.5 - winner = result.hypotheses[result.selected_hypothesis] - assert winner.validation_quality[0].maximum_frame_rms_px < result.model.maximum_reprojection_error_px - validate_shared_tag_references(profile, records, _initializations(records)) - - -@pytest.mark.parametrize('fault', ['missing', 'feedback', 'command', 'camera', 'holdout', 'epoch', 'late', 'units', 'tag_id']) -def test_source_window_never_selects_around_bad_evidence(capture, fault): - profile, owner, request, _, reference = capture - bridge = request.pose_bridges[0] - rows = deepcopy(owner.parent_references.handoff.rows(1, reference, 'side')) - before = min(f.evidence.stamp_ns for f in request.image_frames) - if fault == 'missing': rows = rows[:-1] - elif fault == 'feedback': rows[-1]['feedback_vector'][14] += 10 - elif fault == 'command': rows[-1]['command_vector'][14] += 1 - elif fault == 'camera': rows[-1]['camera_matrix'][0][0] += 1 - elif fault == 'holdout': rows[-1]['cycle'] = 3 - elif fault == 'epoch': rows[-1]['session_epoch'] += 1 - elif fault == 'units': rows[-1]['command_unit'] = 'radian' - elif fault == 'tag_id': rows[-1]['tags']['pinky_pip']['tag_id'] = 99 - else: before = rows[-1]['image_stamp_ns'] - with pytest.raises(ValueError, match='shared_pose_handoff_'): - source_window(profile, rows, reference, (5, 10, 14), bridge.source_model, epoch=1, before_stamp=before) - - -@pytest.mark.parametrize('difference,accepted', [(7, True), (10, True), (11, False)]) -def test_source_window_uses_declared_repeatability_not_stability(capture, difference, accepted): - import numpy as np - from linkerhand_calibration.runtime.revisit import REVISIT_POLICY, revisit_feedback_limits - profile, owner, request, _, reference = capture - rows = deepcopy(owner.parent_references.handoff.rows(1, reference, 'side')) - baseline = np.median([s.feedback[5] for s in reference.samples]) - for row in rows: - row['feedback_vector'][5] = baseline + difference - kwargs = dict(epoch=1, before_stamp=min(f.evidence.stamp_ns for f in request.image_frames)) - def build(**policy): - return source_window(profile, rows, reference, (5, 10, 14), - request.pose_bridges[0].source_model, **kwargs, **policy) - policy = dict(verification=REVISIT_POLICY, feedback_limits=revisit_feedback_limits(profile)) - if accepted: - assert len(build(**policy)) == 10 - else: - with pytest.raises(ValueError, match='shared_pose_handoff_feedback_changed'): - build(**policy) - with pytest.raises(ValueError, match='shared_pose_handoff_feedback_changed'): - build() - rows[-1]['feedback_vector'][5] -= 3 - with pytest.raises(ValueError, match='shared_pose_handoff_feedback_changed'): - build(**policy) - - -def test_source_and_recipient_both_veto_changed_images(capture, replay): - _, _, request, _, _ = capture - bridge = request.pose_bridges[0] - def shifted(frame): - return replace(frame, observations=tuple(replace(o, - corners_xy=tuple((x+30, y) for x,y in o.corners_xy)) if o.role == 'pinky_pip' else o - for o in frame.observations)) - bad_source = replace(bridge, source_frames=tuple(map(shifted, bridge.source_frames))) - bad_target = replace(bridge, frames=tuple(map(shifted, bridge.frames))) - assert check_source_pose(bad_source).status != 'consistent' - assert check_pose_bridge(replay[0].model, bad_target).status != 'consistent' - - -def test_source_authorization_must_precede_the_entire_recipient_arc(capture): - request = capture[2] - bridge = request.pose_bridges[0] - stamp = request.image_frames[0].evidence.stamp_ns - frames = tuple(replace(f, evidence=replace(f.evidence, stamp_ns=stamp+i)) - for i, f in enumerate(bridge.source_frames)) - result = solve_initialization(replace(request, pose_bridges=(replace(bridge, source_frames=frames),))) - assert not result.resolved and result.reason.endswith('shared_pose_handoff_order_invalid') - - -@pytest.mark.parametrize('fault', ['pixels', 'remove', 'earlier_window', 'policy', 'epoch']) -def test_readback_rejects_handoff_tampering(capture, replay, fault): - profile, _, _, _, _ = capture - records = deepcopy(replay[3]) - evidence = deepcopy(_initializations(records)) - payload = next(p for p in evidence.values() if p.get('shared_tag_pose_bridges')) - bridge = payload['shared_tag_pose_bridges'][0] - stamps = bridge['source_window_hashes'] - raw = [r for r in records if r['kind'] == 'image_observation_frame'] - if fault == 'pixels': raw[-1]['tags']['pinky_pip']['corners_xy'][0][0] += 5 - elif fault == 'remove': records.remove(raw[-1]) - elif fault == 'earlier_window': - bridge['source_window_hashes'] = {str(r['image_stamp_ns']):source_frame_sha256(r) for r in raw[-11:-1]} - elif fault == 'epoch': bridge['source_window_epoch'] += 1 - else: bridge.pop('handoff_policy') - with pytest.raises(ValueError, match='shared_pose_handoff_'): - validate_shared_tag_references(profile, records, evidence) - - -def test_resume_import_retains_both_sides(capture, replay): - from linkerhand_calibration.runtime.joint_resume import _index_motion_provenance - records = list(_index_motion_provenance(replay[3]).values()) - reference = capture[4] - records.append(reference.as_record()) - assert len([r for r in records if r['kind'] == 'image_observation_frame']) == 10 - validate_shared_tag_references(capture[0], records, _initializations(records)) - - -def test_resumed_scan_and_dependency_import_share_one_exposure(capture, replay): - records = deepcopy(replay[3]) - images = [r for r in records if r['kind'] == 'image_observation_frame'] - records.extend(deepcopy(images)) - evidence = _initializations(records) - validate_shared_tag_references(capture[0], records, evidence) - records[-1]['tags']['pinky_pip']['corners_xy'][0][0] += 1 - with pytest.raises(ValueError, match='shared_pose_handoff_duplicate_image_changed'): - validate_shared_tag_references(capture[0], records, evidence) - - -def test_dip_uses_handed_off_pip_model_but_keeps_original_pose_reference(capture, replay): - profile, _, _, data, reference = capture - registry = BranchInitialization(profile, source_urdf=SOURCE).parent_references - source = next(r for r in data['records'] if r['kind'] == 'motion_branch_initialization') - restore_parent_models(registry, [source, replay[2]], 1) - task = next(t for t in profile.motion.tasks if t.key == 'pinky_dip_side') - model, report = registry.pose_source(task, 1) - assert model.image_model == replay[0].model - assert JointZeroReference.from_record(report['shared_tag_pose_bridges'][0]['source_reference']) == reference - installed = [] - from types import SimpleNamespace - registry.activate(task, 1, SimpleNamespace(activate_reference_model=installed.append)) - assert installed == [model] - altered = deepcopy(report) - altered['shared_tag_pose_bridges'][0]['source_model_sha256'] = 'f'*64 - registry.models[(1, 'side', 'pinky_pip')] = (model, altered) - with pytest.raises(ValueError, match='parent_reference_handoff_source_changed'): - registry.pose_source(task, 1) - - -@pytest.mark.parametrize('finger', ['pinky', 'ring', 'middle', 'index']) -def test_parent_model_ownership_applies_to_every_finger(capture, finger): - profile = capture[0] - registry = BranchInitialization(profile, source_urdf=SOURCE).parent_references - task = next(t for t in profile.motion.tasks if t.key == finger+'_dip_side') - original = (object(), {'image_model_sha256':'a'*64}) - latest = (object(), {'image_model_selection_policy':SHARED_TAG_HANDOFF_POLICY, - 'shared_tag_pose_bridges':[{'joint':finger+'_pip', 'source_model_sha256':'a'*64, - 'source_reference':{'joint':finger+'_mcp_pitch'}}]}) - registry.models[(1, 'side', finger+'_mcp_pitch')] = original - registry.models[(1, 'side', finger+'_pip')] = latest - assert registry.pose_source(task, 1) is latest - - -def test_recovery_preserves_raw_epoch_and_restart_drops_live_grant(capture): - from linkerhand_calibration.runtime.shared_pose_handoff import SharedPoseHandoff - profile, _, _, data, reference = capture - cache = SharedPoseHandoff(profile) - for row in data['records']: cache.observe(row) - old = cache.rows(1, reference, 'side') - assert not cache.rows(2, reference, 'side') - cache.advance_recovery_epoch(1, 2) - assert cache.rows(2, reference, 'side') == old - assert all(r['session_epoch'] == 1 for r in old) - assert not SharedPoseHandoff(profile).rows(2, reference, 'side') - - -def test_dip_parent_check_uses_formal_capture_and_roundtrip(capture, replay): - """Replay real held pixels as a simulated later DIP visit, not a real DIP run.""" - import numpy as np - from dataclasses import asdict - from types import SimpleNamespace - from scipy.spatial.transform import Rotation - from linkerhand_calibration.runtime.capture import ObservationCapture, CaptureFrame - from linkerhand_calibration.runtime.reference_lock import ReferenceLock - from linkerhand_calibration.runtime.parent_reference_evidence import validate_parent_references - from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 - from linkerhand_calibration.core.geometry.tag_pose.cad_image_model import TransferredImageMotionModel - from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry - from linkerhand_calibration.core.geometry.tag_pose.motion_image_types import ImageHingeGeometry - from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation - profile, _, request, data, reference = capture - registry = BranchInitialization(profile, source_urdf=SOURCE).parent_references - source = next(r for r in data['records'] if r['kind'] == 'motion_branch_initialization') - restore_parent_models(registry, [source, replay[2]], 1) - task = next(t for t in profile.motion.tasks if t.key == 'pinky_dip_side') - root = replay[0].model.reference_frames[0].pose - rotation = Rotation.from_quat(reference.samples[0].parent_pose_common.quaternion_xyzw) - transform = np.eye(4) - transform[:3, :3] = rotation.as_matrix() - transform[:3, 3] = np.asarray(reference.samples[0].parent_pose_common.translation_xyz_m)-rotation.apply(root.translation_xyz_m) - host = ObservationCapture(profile, reference_lock=ReferenceLock({'side':3}), - extrinsics=SimpleNamespace(transform=lambda _:transform)) - registry.activate(task, 1, host) - target = profile.motion.joint_zero_references['pinky_dip'].command - motion = MotionCommand('joint_zero', target, 200., task_key=task.key, - zero_joints=('pinky_mcp_pitch',), reference_only=True, reference_reuse=True) - now = max(replay[0].model.source_stamps_ns)+1_000_000_000 - journal, samples = list(replay[3]), [] - for i, frame in enumerate(request.pose_bridges[0].frames): - rows, _ = host.consume(CaptureFrame('side', now+i*40_000_000, - np.asarray(frame.camera_matrix), {o.role:np.asarray(o.corners_xy) for o in frame.observations}, - reference.samples[0].feedback, target), motion) - journal.extend(rows) - samples.extend(r for r in rows if r['kind'] == 'joint_zero_sample') - assert len(samples) == 10 - check = registry.confirm(task, 1, 80, reference, samples) - assert check['schema_version'] == 2 and check['pose_model_joint'] == 'pinky_pip' - assert check['source_reference'] == reference.as_record() - journal.append(check) - hinges, check_id = registry.geometry(task, 1) - # A synthetic downstream model envelope exercises only the provenance - # boundary. Its DIP geometry is not presented as measured hardware data. - parent = hinges[0].geometry - axis = Rotation.from_quat(parent.reference_quaternion_xyzw).inv().apply(parent.axis_parent_xyz) - normal = np.cross(axis, [1.,0.,0.]); normal /= np.linalg.norm(normal) - child = ImageHingeGeometry(MotionRelation('pinky_dip', 'pinky_pip', 'pinky_dip'), - tuple(axis), (0.,0.,0.,1.), tuple(.03*normal-np.asarray(parent.mount_child_xyz_m)), (.015,0.,0.)) - later = now+2_000_000_000 - transferred = TransferredImageMotionModel((child,), replay[0].model.camera_matrix, - (('pinky_pip',.0165),('pinky_dip',.0165)), (later,later+1), (later,), (later+1,), - (('pinky_pip',0),('pinky_dip',0)), - constraints=(ParallelAxisGeometry('pinky_pip','pinky_dip',1,.03),), - source_urdf_sha256='d'*64, source_hinges=hinges) - evidence = _initializations(replay[3]) - evidence['synthetic_dip'] = dict(frozen_image_model=asdict(transferred), - image_model_sha256=image_model_sha256(asdict(transferred)), view='side', tag_role='pinky_dip', - session_epoch=1, motion_version=82, task_name=task.key, - parent_reference_sha256=check_id, source_image_stamps=[later,later+1]) - validate_parent_references(profile, journal, evidence) - damaged = deepcopy(check) - damaged['pose_model_joint'] = 'pinky_mcp_pitch' - journal[-1] = damaged - evidence['synthetic_dip']['parent_reference_sha256'] = source_frame_sha256(damaged) - with pytest.raises(ValueError, match='parent_reference_handoff_source_changed'): - validate_parent_references(profile, journal, evidence) - downgraded = deepcopy(check) - downgraded['schema_version'] = 1 - downgraded.pop('pose_model_joint'); downgraded.pop('pose_model_policy') - journal[-1] = downgraded - evidence['synthetic_dip']['parent_reference_sha256'] = source_frame_sha256(downgraded) - with pytest.raises(ValueError, match='parent_reference_handoff_policy_changed'): - validate_parent_references(profile, journal, evidence) diff --git a/src/linkerhand_calibration/test/test_shared_tag_pose_bridge.py b/src/linkerhand_calibration/test/test_shared_tag_pose_bridge.py deleted file mode 100644 index 94bf5b1..0000000 --- a/src/linkerhand_calibration/test/test_shared_tag_pose_bridge.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Real MCP/PIP capture: shared physical posture disambiguates the new hinge.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.domain.reference import JointZeroReference -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import resolve_image_motion -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.passed_resume import restore_parent_models -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -from linkerhand_calibration.profiles import load_bundled_hand_profile -from o30_capture_fixture import SOURCE - - -@pytest.fixture(scope='module') -def recording(): - path = Path(__file__).parent/'fixtures/o30_shared_tag_pip.json.gz' - return json.loads(gzip.decompress(path.read_bytes())) - - -def request_from_recording(data, *, epoch=1, mutate=None): - profile = load_bundled_hand_profile('o30_right_18') - owner = BranchInitialization(profile, source_urdf=SOURCE) - rows = deepcopy(data['records']) - reference = JointZeroReference.from_record(next(r for r in rows if r['kind']=='joint_zero_reference')) - source = next(r for r in rows if r['kind']=='motion_branch_initialization') - restore_parent_models(owner.parent_references, [source], epoch) - if mutate: - rows, reference = mutate(rows, reference) - joint = data['original_failure']['zero_joints'][0] - spec = profile.motion.joint_zero_references[joint] - version = data['original_failure']['motion_version'] - motion = MotionCommand('zero_approach', spec.command, 200., task_key=spec.task_key, - reference_joints=(joint,)) - owner.begin(motion, session_epoch=epoch, motion_version=version) - for row in rows: - if row['kind']=='motion_branch_observation' and row['task_name']==spec.task_key: - owner.observe({**row, 'session_epoch': epoch}) - request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=(joint,)), - session_epoch=epoch, motion_version=version+1, zero_references={reference.joint: reference}) - return owner, request - - -def test_recorded_pip_requires_shared_pose_not_lower_pixel_error(recording): - owner, request = request_from_recording(recording) - assert not request.invalid_reason - assert len(request.image_frames)==129 and len(request.pose_bridges)==1 - assert len(request.pose_bridges[0].frames)==10 - assert not {f.evidence.stamp_ns for f in request.image_frames} & { - f.evidence.stamp_ns for f in request.pose_bridges[0].frames} - failed = resolve_image_motion(request.image_frames, request.relations) - assert not failed.resolved and failed.reason=='image_motion_families_not_distinguishable' - result = solve_initialization(request) - assert result.resolved, result - winner = next(h for h in result.hypotheses if h.branches==result.model.branches) - alternative = next(h for h in result.hypotheses if h.branches!=result.model.branches) - assert winner.pose_bridge_checks[0].status=='consistent' - assert alternative.pose_bridge_checks[0].status=='consistent' - # Both original IPPE starts now converge to the same constrained physical - # family; a numerical difference in pixel score cannot select a normal. - assert abs(winner.validation_quality[0].rms_px-alternative.validation_quality[0].rms_px) < 1e-5 - assert result.model.maximum_reprojection_error_px==1.5 and result.model.reprojection_tie_px==.03 - model, report = owner.accept(request, result) - assert report['image_model_selection_policy']=='training_shared_zero_pose_holm_v4' - for role, payload in report['evidence_payloads'].items(): - assert payload['shared_tag_pose_bridges']==report['shared_tag_pose_bridges'] - assert source_frame_sha256(payload)==report['evidence_ids'][role] - - -def test_passed_resume_uses_original_source_without_old_image_replay(recording, monkeypatch): - from linkerhand_calibration.runtime import shared_tag_reference - original = shared_tag_reference.bridge_frames - monkeypatch.setattr(shared_tag_reference, 'bridge_frames', lambda rows, relation: ( - pytest.fail('old images replayed') if any(r['task_name']!='pinky_pip_side' for r in rows) - else original(rows, relation))) - _, request = request_from_recording(recording, epoch=7) - assert not request.invalid_reason - assert request.pose_bridge_records[0]['source_reference']['session_epoch']==1 - assert solve_initialization(request).resolved - - -@pytest.mark.parametrize('difference,accepted', [(7, True), (10, True), (11, False)]) -def test_shared_zero_feedback_repeatability_is_separate_from_stability(recording, difference, accepted): - from linkerhand_calibration.runtime.shared_tag_reference import build_pose_bridge - from linkerhand_calibration.runtime.revisit import REVISIT_POLICY, revisit_feedback_limits - import numpy as np - owner, request = request_from_recording(recording) - record = request.pose_bridge_records[0] - reference = JointZeroReference.from_record(record['source_reference']) - stamps = {int(s) for s in record['source_frame_hashes']} - rows = deepcopy([r for r in owner._endpoint_records['side'] if r['image_stamp_ns'] in stamps]) - baseline = np.median([s.feedback[5] for s in reference.samples]) - for row in rows: - row['feedback_vector'][5] = baseline + difference - kwargs = dict(verification=REVISIT_POLICY, feedback_limits=revisit_feedback_limits(owner.profile)) - def build(**policy): - return build_pose_bridge(owner.profile, 'pinky_pip', reference, rows, - tuple(record['held_channels']), **policy) - if accepted: - assert build(**kwargs).frames - else: - with pytest.raises(ValueError, match='shared_pose_feedback_changed'): - build(**kwargs) - # Unversioned historical evidence retains its original difference-of-four gate. - with pytest.raises(ValueError, match='shared_pose_feedback_changed'): - build() - rows[-1]['feedback_vector'][5] -= 3 - with pytest.raises(ValueError, match='shared_pose_feedback_changed'): - build(**kwargs) - with pytest.raises(ValueError, match='reference_feedback_policy_changed'): - build(verification=REVISIT_POLICY, feedback_limits={**kwargs['feedback_limits'], - 'between_visit_difference': 11.}) - - -def test_verify_resume_uses_fresh_pose_without_replacing_preserved_zero(recording): - owner, request = request_from_recording(recording) - reference = JointZeroReference.from_record(request.pose_bridge_records[0]['source_reference']) - owner.parent_references.remember_zero(reference, 1) - preserved = replace(reference, branch_references=tuple( - replace(b, motion_evidence_id='f'*64) for b in reference.branch_references)) - from linkerhand_calibration.runtime.shared_tag_reference import prepare_pose_bridges - bridges, reports = prepare_pose_bridges(owner, 'side', {reference.joint:preserved}, - owner._endpoint_records['side'], {f.evidence.stamp_ns for f in request.image_frames}, 1) - assert reports[0]['source_reference']==reference.as_record() - assert preserved.branch_references[0].motion_evidence_id=='f'*64 - assert solve_initialization(replace(request, pose_bridges=bridges)).resolved - - -@pytest.mark.parametrize('change', ['feedback', 'images', 'source_identity']) -def test_invalid_bridge_never_silently_falls_back(recording, change): - def mutate(rows, ref): - if change=='feedback': - for row in rows: - if row['kind']=='motion_branch_observation' and row['task_name']=='pinky_pip_side' and row['command_vector']: - row['feedback_vector'][14] += 12 - elif change=='images': - arc=set(recording['original_failure']['source_image_stamps']) - rows=[r for r in rows if r['kind']!='motion_branch_observation' or r['task_name']!='pinky_pip_side' or r['image_stamp_ns'] in arc] - else: - ref=replace(ref, branch_references=tuple(replace(b,motion_evidence_id='f'*64) for b in ref.branch_references)) - return rows, ref - _, request=request_from_recording(recording, mutate=mutate) - assert request.invalid_reason.startswith('shared_pose_bridge_invalid:') - assert not solve_initialization(request).resolved - - -@pytest.fixture(scope='module') -def bridged_journal(recording): - _, request = request_from_recording(recording) - result = solve_initialization(request) - _, report = initialization_artifacts(request, result) - records = json.loads(json.dumps([*recording['records'], report])) - from linkerhand_calibration.runtime.motion_provenance import _initializations - return records, _initializations(records) - - -def test_shared_pose_provenance_replays_current_and_original_zero_pixels(bridged_journal): - from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references - from linkerhand_calibration.runtime.motion_provenance import validate_source_geometry - profile = load_bundled_hand_profile('o30_right_18') - records, evidence = bridged_journal - validate_shared_tag_references(profile, records, evidence) - validate_source_geometry(profile, SOURCE, records) - - -@pytest.mark.parametrize('change', ['current_pixels', 'old_zero_pixels', 'source_reference', - 'source_model', 'overlapping_images', 'held_channels', 'target_model']) -def test_bridge_provenance_rejects_missing_or_relabelled_evidence(bridged_journal, change): - from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references - from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - profile = load_bundled_hand_profile('o30_right_18') - records, evidence = deepcopy(bridged_journal) - payload = next(p for p in evidence.values() if p.get('shared_tag_pose_bridges')) - bridge = payload['shared_tag_pose_bridges'][0] - if change=='current_pixels': - stamp = next(iter(bridge['source_frame_hashes'])) - row = next(r for r in records if r.get('kind')=='motion_branch_observation' and str(r['image_stamp_ns'])==stamp) - row['tags']['pinky_pip']['corners_xy'][0][0] += 30 - bridge['source_frame_hashes'][stamp] = source_frame_sha256(row) - elif change=='old_zero_pixels': - records[:] = [r for r in records if r.get('kind')!='pnp_candidate_frame'] - elif change=='source_reference': - bridge['source_reference']['samples'][0]['relative_translation_xyz_m'][0] += .001 - elif change=='source_model': - bridge['source_model_sha256'] = 'f'*64 - elif change=='overlapping_images': - stamp = payload['source_image_stamps'][-1] - row = next(r for r in records if r.get('kind')=='motion_branch_observation' and r['image_stamp_ns']==stamp) - bridge['source_frame_hashes'][str(stamp)] = source_frame_sha256(row) - elif change=='held_channels': - bridge['held_channels'] = [14] - else: - payload['frozen_image_model']['geometry'][0]['pivot_parent_xyz_m'][0] += .02 - with pytest.raises(ValueError): - validate_shared_tag_references(profile, records, evidence, source_model=UrdfKinematicModel(SOURCE)) - - -def test_bridge_binding_is_part_of_initialization_hash(bridged_journal): - from linkerhand_calibration.runtime.motion_provenance import _initializations - records, _ = deepcopy(bridged_journal) - report = next(r for r in records if r.get('shared_tag_pose_bridges')) - report['shared_tag_pose_bridges'][0]['source_model_sha256'] = 'f'*64 - with pytest.raises(ValueError, match='initialization_binding_changed'): - _initializations(records) - - -def test_resume_epoch_requires_recorded_source_binding(recording): - from linkerhand_calibration.runtime.motion_provenance import _initializations - from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references - from linkerhand_calibration.runtime.passed_resume import restored_model_record - _, request = request_from_recording(recording, epoch=7) - _, report = initialization_artifacts(request, solve_initialization(request)) - records = deepcopy(recording['records']) - for row in records: - if row.get('kind')=='motion_branch_observation' and row['task_name']=='pinky_pip_side': - row['session_epoch']=7 - source=next(r for r in records if r['kind']=='motion_branch_initialization') - records.append(report) - evidence=_initializations(records) - profile=load_bundled_hand_profile('o30_right_18') - with pytest.raises(ValueError, match='source_epoch_changed'): - validate_shared_tag_references(profile, records, evidence) - records.append(restored_model_record([source], 7, - min(f.evidence.stamp_ns for f in request.image_frames)-1)) - validate_shared_tag_references(profile, records, evidence) - - -def test_inconclusive_pose_margin_does_not_force_a_candidate(recording): - import math - from scipy.spatial.transform import Rotation - from linkerhand_calibration.core.geometry.tag_pose.pose_bridge import check_pose_bridge - from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame - from linkerhand_calibration.core.geometry.tag_pose.relative import _relative_pose - _, request = request_from_recording(recording) - model = solve_initialization(request).model - bridge = request.pose_bridges[0] - checked = select_image_motion_frame(model, bridge.frames[0]) - poses = {s.role:s.pose for s in checked.selections} - rotation, point = _relative_pose(poses[bridge.relation.parent_role], poses[bridge.relation.child_role]) - uncertain = replace(bridge, quaternion_xyzw=tuple((rotation*Rotation.from_rotvec([0,0,math.radians(3)])).as_quat()), - translation_xyz_m=tuple(point)) - result = check_pose_bridge(model, uncertain) - assert result.status=='unresolved' and result.reason=='shared_pose_tolerance_regions_overlap' - assert not solve_initialization(replace(request, pose_bridges=(uncertain,))).resolved - - -def test_bridge_cannot_use_training_images_as_independent_support(recording): - from linkerhand_calibration.core.geometry.tag_pose.pose_bridge import check_pose_bridge - _, request = request_from_recording(recording) - model = solve_initialization(request).model - bridge = replace(request.pose_bridges[0], frames=request.image_frames[-10:]) - assert check_pose_bridge(model, bridge).reason=='shared_pose_support_invalid' - - -def test_resume_preserves_bridge_and_epoch_binding_without_replaying_images(bridged_journal, monkeypatch): - from types import SimpleNamespace - from linkerhand_calibration.runtime.passed_resume import reference_import, restored_model_record - from linkerhand_calibration.runtime import shared_tag_evidence - records, _ = deepcopy(bridged_journal) - source = next(r for r in records if r.get('kind')=='motion_branch_initialization') - binding = restored_model_record([source], 1, source['source_image_stamps'][-1]+1) - records.append(binding) - reference = JointZeroReference.from_record(next(r for r in records if r.get('kind')=='joint_zero_reference')) - monkeypatch.setattr(shared_tag_evidence, 'validate_shared_tag_references', - lambda *a, **kw: pytest.fail('passed startup must not revalidate old images')) - imported = reference_import(SimpleNamespace(rows=records, references={reference.joint:reference}, units=set())) - assert binding in imported.rows - # An unfinished recipient is deliberately not imported in passed mode. - assert not any(r.get('shared_tag_pose_bridges') for r in imported.rows) - # This fixture models task ownership only, not a measured PIP zero. - recipient = replace(reference, joint='pinky_pip', task_key='pinky_pip_side') - imported = reference_import(SimpleNamespace(rows=records, - references={reference.joint:reference, recipient.joint:recipient}, units=set())) - report = next(r for r in imported.rows if r.get('shared_tag_pose_bridges')) - bridge_images = set(report['shared_tag_pose_bridges'][0]['source_frame_hashes']) - retained = {str(r['image_stamp_ns']) for r in imported.rows if r.get('kind')=='motion_branch_observation'} - assert bridge_images <= retained - - -@pytest.mark.parametrize('fault', ['limits', 'unknown_policy']) -def test_shared_feedback_contract_tampering_is_rejected(bridged_journal, fault): - from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references - records, evidence = deepcopy(bridged_journal) - payload = next(p for p in evidence.values() if p.get('shared_tag_pose_bridges')) - bridge = payload['shared_tag_pose_bridges'][0] - if fault == 'limits': - bridge['feedback_limits']['between_visit_difference'] = 11. - else: - bridge['verification'] = 'unknown' - with pytest.raises(ValueError, match='feedback_policy_changed|revisit_policy_unknown'): - validate_shared_tag_references(load_bundled_hand_profile('o30_right_18'), records, evidence) diff --git a/src/linkerhand_calibration/test/test_shared_zero_conditioning.py b/src/linkerhand_calibration/test/test_shared_zero_conditioning.py deleted file mode 100644 index 746e38e..0000000 --- a/src/linkerhand_calibration/test/test_shared_zero_conditioning.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Independent real ring MCP/PIP images expose the post-fit-only bridge failure.""" - -from copy import deepcopy -from dataclasses import replace -import gzip -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.pose_bridge import ( - check_source_pose, check_pose_bridge, SHARED_ZERO_CONDITIONING, -) -from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ImageMotionParameters -from linkerhand_calibration.core.geometry.tag_pose.motion_image_diagnostics import _validate_frames -from linkerhand_calibration.core.geometry.tag_pose.shared_zero_solver import SharedZeroImageHingeBundle -from linkerhand_calibration.core.geometry.tag_pose.hinge_uncertainty import hinge_uncertainty -from linkerhand_calibration.runtime.branch_initialization import solve_initialization, initialization_artifacts -from linkerhand_calibration.runtime.shared_tag_evidence import validate_shared_tag_references -from linkerhand_calibration.runtime.motion_provenance import _initializations, validate_source_geometry -from linkerhand_calibration.profiles import load_bundled_hand_profile -from test_shared_tag_pose_bridge import request_from_recording as current_request_from_recording -from o30_capture_fixture import SOURCE - - -def request_from_recording(data): - """Exercise the preserved v3 contract explicitly after v4 becomes default.""" - owner, request = current_request_from_recording(data) - return owner, replace(request, - pose_bridges=tuple(replace(b, conditioning=SHARED_ZERO_CONDITIONING) for b in request.pose_bridges), - pose_bridge_records=tuple(dict(r, conditioning=SHARED_ZERO_CONDITIONING) for r in request.pose_bridge_records)) - - -@pytest.fixture(scope="module") -def recording(): - path = Path(__file__).parent / "fixtures/o30_shared_tag_ring_pip.json.gz" - return json.loads(gzip.decompress(path.read_bytes())) - - -@pytest.fixture(scope="module") -def solved(recording): - _, request = request_from_recording(recording) - assert not request.invalid_reason - result = solve_initialization(request) - assert result.resolved, result - return request, result - - -def test_recorded_failure_is_independent_model_tilt_not_a_changed_source_zero(recording, solved): - request, result = solved - bridge = request.pose_bridges[0] - source = check_source_pose(bridge) - assert source.status == "consistent" - assert np.rad2deg(source.maximum_rotation_rad) < .05 - assert source.maximum_translation_m < .00006 - legacy = solve_initialization(replace(request, pose_bridges=(replace(bridge, source_model=None),))) - assert not legacy.resolved and legacy.reason == "image_motion_shared_pose_unresolved" - assert 2.5 < np.rad2deg(legacy.hypotheses[0].pose_bridge_checks[0].maximum_rotation_rad) < 2.6 - checked = check_pose_bridge(result.model, bridge) - assert checked.status == "consistent" - assert np.rad2deg(checked.maximum_rotation_rad) < .2 - assert checked.maximum_translation_m < .005 - assert checked.maximum_translation_m > .001 # Translation was measured, not copied from the source. - assert len(request.image_frames) == 129 and len(bridge.frames) == 10 - assert not set(result.model.source_stamps_ns) & {f.evidence.stamp_ns for f in bridge.frames} - for hypothesis in result.hypotheses: - assert hypothesis.converged and hypothesis.pose_bridge_checks[0].status == "consistent" - assert hypothesis.validation_quality[0].rms_px < .32 - assert all(select_image_motion_frame(result.model, f).resolved for f in request.image_frames[1::2]) - assert result.model.maximum_reprojection_error_px == 1.5 - assert result.model.reprojection_tie_px == .03 - - -@pytest.mark.parametrize("change", ["zero_orientation", "fixed_tag", "child_pixels", "intrinsics"]) -def test_conditioning_requires_independently_confirmed_source_on_current_images(solved, change): - request, _ = solved - bridge = request.pose_bridges[0] - if change == "zero_orientation": - rotation = Rotation.from_quat(bridge.quaternion_xyzw)*Rotation.from_euler("x", 3, degrees=True) - bridge = replace(bridge, quaternion_xyzw=tuple(rotation.as_quat())) - else: - frames = [] - for frame in bridge.frames: - if change == "intrinsics": - matrix = np.array(frame.camera_matrix); matrix[0, 0] += 1 - frame = replace(frame, camera_matrix=tuple(map(tuple, matrix))) - else: - role = bridge.relation.parent_role if change == "fixed_tag" else bridge.relation.child_role - frame = replace(frame, observations=tuple(replace(o, - corners_xy=tuple((x+20, y) for x, y in o.corners_xy)) if o.role == role else o - for o in frame.observations)) - frames.append(frame) - bridge = replace(bridge, frames=tuple(frames)) - result = solve_initialization(replace(request, pose_bridges=(bridge,))) - assert not result.resolved and result.reason.startswith("image_motion_shared_pose_source_unverified:") - - -def test_uncertainty_keeps_full_image_orientation_sensitivity(solved): - request, _ = solved - limits = ImageMotionParameters() - paths, roles = _validate_frames(request.image_frames, request.relations, limits) - frames = request.image_frames[::2] - poses = {role: paths[role][0][::2] for role in roles} - bundle = SharedZeroImageHingeBundle(frames, request.relations, poses, request.pose_bridges) - fit = bundle.solve(limits) - free, linearization = bundle.uncertainty_problem(fit) - assert np.allclose(bundle.residual(fit.x), free.residual(linearization.x), atol=1e-7) - assert len(linearization.x) == len(fit.x)+2 # Restore the two identifiable tilt freedoms. - conditional_jac = fit.jac.toarray() if hasattr(fit.jac, "toarray") else fit.jac - assert linearization.jac.shape[1] > conditional_jac.shape[1] - uncertainty = hinge_uncertainty(bundle, fit) - assert uncertainty == hinge_uncertainty(free, linearization) - assert 1. < np.rad2deg(uncertainty[0][0][1]) < 3. - - -@pytest.fixture(scope="module") -def journal(recording, solved): - request, result = solved - _, report = initialization_artifacts(request, result) - records = json.loads(json.dumps([*recording["records"], report])) - return records, _initializations(records) - - -def test_conditioned_geometry_and_source_binding_survive_readback(journal): - profile = load_bundled_hand_profile("o30_right_18") - records, evidence = journal - validate_shared_tag_references(profile, records, evidence) - validate_source_geometry(profile, SOURCE, records) - - -@pytest.mark.parametrize("change", ["anchor", "conditioning", "policy"]) -def test_readback_rejects_a_changed_anchor_or_conditioning_contract(journal, change): - profile = load_bundled_hand_profile("o30_right_18") - records, evidence = deepcopy(journal) - payload = next(p for p in evidence.values() if p.get("shared_tag_pose_bridges")) - if change == "anchor": - from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 - geometry = payload["frozen_image_model"]["geometry"][0] - geometry["reference_quaternion_xyzw"] = (Rotation.from_quat(geometry["reference_quaternion_xyzw"]) - * Rotation.from_euler("z", .1, degrees=True)).as_quat().tolist() - payload["image_model_sha256"] = image_model_sha256(payload["frozen_image_model"]) - elif change == "conditioning": - payload["shared_tag_pose_bridges"][0]["conditioning"] = "unsupported" - else: - next(r for r in records if r.get("shared_tag_pose_bridges"))["image_model_selection_policy"] = "training_squared_loss_shared_pose_holm_v2" - with pytest.raises(ValueError, match="conditioning_policy_changed"): - _initializations(records) - return - with pytest.raises(ValueError, match="conditioned_orientation_changed|conditioning_policy_changed"): - validate_shared_tag_references(profile, records, evidence) - - -def test_legacy_shared_pose_records_still_read_back_without_rewriting_them(): - from test_shared_tag_pose_bridge import request_from_recording - data = json.loads(gzip.decompress((Path(__file__).parent / "fixtures/o30_shared_tag_pip.json.gz").read_bytes())) - _, request = request_from_recording(data) - records = tuple({k: v for k, v in record.items() if k != "conditioning"} - for record in request.pose_bridge_records) - request = replace(request, pose_bridges=tuple(replace(b, source_model=None) for b in request.pose_bridges), - pose_bridge_records=records) - result = solve_initialization(request) - assert result.resolved - _, report = initialization_artifacts(request, result) - assert report["image_model_selection_policy"] == "training_squared_loss_shared_pose_holm_v2" - journal = [*data["records"], report] - validate_shared_tag_references(load_bundled_hand_profile("o30_right_18"), journal, _initializations(journal)) diff --git a/src/linkerhand_calibration/test/test_single_pass_capture.py b/src/linkerhand_calibration/test/test_single_pass_capture.py deleted file mode 100644 index cb6b196..0000000 --- a/src/linkerhand_calibration/test/test_single_pass_capture.py +++ /dev/null @@ -1,151 +0,0 @@ -"""A formal task completes four round trips through real image capture. - -Only the task scope and finalization output are replaced. The production -coordinator, frozen image geometry, SDK adapter and sample gates run unchanged. -Synthetic observations verify the flow; they are not a hand accuracy result. -""" - -from collections import defaultdict - -import numpy as np -import pytest - -from linkerhand_calibration.core.domain.sampling import command_nodes -from linkerhand_calibration.core.fitting.observed_motion import fit_native_observed_rotation -from linkerhand_calibration.runtime.acquisition import accepted_joint_records -from linkerhand_calibration.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.scan_quality import evaluate_capture_unit -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from runtime_host_fixture import coordinator_fixture, ready -from test_diagnostic_coordinator import advance, TERMINAL_PHASES - - -class CapturedFinalization: - """Keep the actual finalizer input without building or publishing files.""" - - def __init__(self): - self.started = False - self.inputs = [] - - def start(self, inputs): - assert not self.started - self.inputs.append(inputs) - self.started = True - - def poll(self, *_args): - return None - - def cancel(self): - pass - - def close(self): - pass - - -def test_formal_yaw_four_round_trips_use_one_pass_and_disjoint_sample_phases(tmp_path, monkeypatch): - original_units = CalibrationEngine.scan_units - monkeypatch.setattr(CalibrationEngine, "scan_units", lambda engine: - tuple(unit for unit in original_units(engine) if unit.task_key == "thumb_yaw_top")) - finalizer = CapturedFinalization() - from dataclasses import replace - host, clock = coordinator_fixture(tmp_path, finalization=finalizer, - profile_transform=lambda p: replace(p, acquisition=replace(p.acquisition, - command_capture_mode="interleaved", steady_extra_training_nodes={}))) - 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.diagnostic_capture is None - assert host.branch_initialization.image_geometry is True - assert host.start().success - task = next(task for task in host.profile.motion.tasks if task.key == "thumb_yaw_top") - units = host.execution.session.engine.scan_units() - expected_units = [(cycle, direction) for cycle in range(4) - for direction in ("decreasing", "increasing")] - assert [(unit.cycle, unit.direction) for unit in units] == expected_units - entries, commands = defaultdict(list), defaultdict(list) - seen_segments, order, previous_key = set(), [], None - try: - for _ in range(9000): - if finalizer.started: - break - if host.execution.session.phase in TERMINAL_PHASES: - pytest.fail(f"premature capture exit: {host.state}: {host.reason}: {host.execution.last_quality}") - before, first_command = host._motion, len(clock.positions) - advance(host, clock) - motion = host._motion or before - if motion is None or motion.phase not in {"sweep", "steady"}: - continue - key = (motion.cycle, motion.direction) - if key != previous_key: - order.append(key) - previous_key = key - commands[key].extend(value[task.command_index] for value in clock.positions[first_command:]) - if host._motion is not None and host._segment_number not in seen_segments: - seen_segments.add(host._segment_number) - entries[key].append((motion.phase, motion.target[task.command_index], motion.steady_index)) - else: - pytest.fail(f"virtual formal capture timed out: {host.state}: {host.reason}") - - assert host.execution.session.phase == Phase.FIT and finalizer.started - assert order == expected_units - assert len(host.execution.completed_units) == 8 - assert len(finalizer.inputs) == 1 - records = finalizer.inputs[0].records - samples = [row for row in records if row.get("kind") == "joint_sample"] - assert {row["joint"] for row in samples} == {"rh_thumb_cmc_yaw"} - assert {row["attempt"] for row in samples} == {1} - assert {row["sample_phase"] for row in samples} == {"sweep", "steady"} - assert {row["pnp_observation_evidence"]["child"]["pose_source"] for row in samples} == { - "image_constrained_current_image"} - sweep_ids = {row["image_stamp_ns"] for row in samples if row["sample_phase"] == "sweep"} - steady_ids = {row["image_stamp_ns"] for row in samples if row["sample_phase"] == "steady"} - assert sweep_ids and steady_ids and sweep_ids.isdisjoint(steady_ids) - assert len(sweep_ids) + len(steady_ids) == len(samples) - zero_rows = [row for row in records if row.get("kind") == "joint_zero_reference"] - assert [row["joint"] for row in zero_rows] == ["rh_thumb_cmc_yaw"] - assert {row["cycle"] for row in records if row.get("kind") == "joint_zero_sample"} == {0} - - policy = host.profile.acquisition - assert policy.minimum_valid_samples == 40 and policy.minimum_bins == 32 - assert policy.maximum_unobserved_fraction == .0625 - first_spans = {} - for unit in units: - key = (unit.cycle, unit.direction) - targets = command_nodes(host.profile, task, holdout=unit.cycle == 3) - if unit.direction == "decreasing": - targets = tuple(reversed(targets)) - expected = [("sweep", targets[0], None), ("steady", targets[0], 0)] - for index, target in enumerate(targets[1:], 1): - expected.extend((("sweep", target, None), ("steady", target, index))) - assert entries[key] == expected - values = np.asarray(commands[key]) - assert values[0] == unit.start and values[-1] == unit.end - sign = 1 if unit.direction == "increasing" else -1 - assert np.all(sign * np.diff(values) >= 0), key - quality = evaluate_capture_unit(host.profile, unit, 1, records, first_cycle_spans=first_spans) - assert quality.passed, quality.failures - for metrics in quality.metrics.values(): - assert metrics["valid_frames"] >= 40 - assert metrics["feedback_bins"] >= 32 - assert metrics["maximum_bin_gap"] <= metrics["allowed_maximum_bin_gap"] == 16 - for row in samples: - if (row["cycle"], row["direction"]) == key and row["sample_phase"] == "steady": - assert row["command_u8"] == row["steady_target"] == targets[row["steady_index"]] - - train_nodes = set(command_nodes(host.profile, task)) - validation_nodes = set(command_nodes(host.profile, task, holdout=True)) - assert train_nodes & validation_nodes == {0., 255.} - rotation_rows = accepted_joint_records(host.profile, records)["rh_thumb_cmc_yaw"] - reference = host.execution.zero_references["rh_thumb_cmc_yaw"] - curve, errors = fit_native_observed_rotation(rotation_rows, - input_domain=host.profile.curve_input_domain, input_to_joint_direction=-1, - reference_xyzw=reference.quaternion("top"), joint=reference.joint, view="top") - training = {row["sample_id"] for row in rotation_rows if row["cycle"] in {0, 1, 2}} - holdout = {row["sample_id"] for row in rotation_rows if row["cycle"] == 3} - assert set(curve.circle["training_sample_ids"]) == training - assert training.isdisjoint(holdout) and len(errors) == len(holdout) - assert all(tuple(value[i] for i in (0, 2, 3, 4, 5)) == (255.,) * 5 for value in clock.positions) - assert not host.final_json and not host.final_urdf - assert not list(tmp_path.rglob("*.urdf")) and not list(tmp_path.rglob("release_manifest.json")) - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_source_mimic_export.py b/src/linkerhand_calibration/test/test_source_mimic_export.py deleted file mode 100644 index bb35c9f..0000000 --- a/src/linkerhand_calibration/test/test_source_mimic_export.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Source coupling is immutable; JSON motion and preview bounds are separate.""" - -from copy import deepcopy -from dataclasses import replace -import hashlib -import json -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy as np -import pytest - -from test_independent_urdf_export import nonlinear_release, assert_robot_state_publisher_angles -from linkerhand_calibration.core.urdf.acceptance import validate_artifact_structure -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.core.urdf.limits import MotionRangeEvidence -from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan -from linkerhand_calibration.core.urdf.source_mimic import ( - compile_source_mimic_envelopes, validate_source_mimic_elements, -) -from linkerhand_calibration.runtime.artifacts.urdf_from_json import ( - rebuild_urdf_from_json, serialize_urdf_correction, -) - - -def nominal_evidence(model, name, bounds, *, delta=0.0, safety=None): - joint = model.joints[name] - return MotionRangeEvidence(name, name, True, (joint.lower, joint.upper), bounds, bounds, bounds, - delta, 0., (0, 1, 2), ("view:100", "view:200"), ("view:10",), - "calibrated_motion_envelope", "synthetic measured support", safety, - "synthetic hard safety range" if safety else "") - - -@pytest.fixture -def source_mimic_release(nonlinear_release): - source, rights, old_plan, payload, path = nonlinear_release - model = UrdfKinematicModel(source) - bounds = (model.joints["ip"].lower, model.joints["ip"].upper) - evidence = {"ip": nominal_evidence(model, "ip", bounds)} - plan = build_standard_correction_plan(source_urdf=source, - source_sha256=old_plan.source_sha256, zero_offsets_rad={}, authorized_fields=rights, - measured_ranges_output_rad={"ip": bounds}, range_evidence=evidence, preserve_source_mimic=True) - payload = deepcopy(payload) - payload["urdf_correction"] = serialize_urdf_correction(plan) - path.write_text(json.dumps(payload)) - return source, rights, plan, payload, path - - -def rebuild(release, output): - source, rights, _, payload, path = release - path.write_text(json.dumps(payload)) - return rebuild_urdf_from_json(path, source, output, authorized_fields=rights, - expected_profile_id=payload["profile_id"]) - - -def test_near_zero_extension_preserves_source_coupling_and_measured_support(source_mimic_release, tmp_path): - source, rights, plan, payload, _ = source_mimic_release - before = source.read_bytes() - output = rebuild(source_mimic_release, tmp_path/"retained.urdf") - validate_source_mimic_elements(source, output) - model = UrdfKinematicModel(output) - assert payload["urdf_correction"]["schema_version"] == 4 - assert model.joints["ip"].mimic_multiplier == 1.86 - assert model.joints["ip"].mimic_offset == 0 - assert model.joints["ip"].lower == pytest.approx(1.86*model.joints["pitch"].lower) - assert plan.range_evidence["ip"].output_limits_rad[0] == -.0020103987491093 - assert plan.mimic_envelopes["ip"].base_limits_rad == plan.range_evidence["ip"].output_limits_rad - assert output.read_bytes() == plan.write(source, tmp_path/"direct.urdf").read_bytes() - assert source.read_bytes() == before - validate_artifact_structure(source_urdf=source, corrected_urdf=output, source_sha256=plan.source_sha256, - authorized_fields=rights, payload=payload, zero_offsets_rad={}, range_evidence=plan.range_evidence) - for command in (0, 64, 128, 255): - angles = {name: row["angle_rad"][command] for name, row in payload["joints"].items()} - assert model.resolve_angles(angles, independent_mimic_angles=True) == angles - assert model.resolve_angles({"pitch": angles["pitch"]})["ip"] == pytest.approx(1.86*angles["pitch"]) - assert payload["joints"]["ip"]["angle_rad"][-1] != pytest.approx(1.86*.6) - - -@pytest.mark.parametrize("mutation", ["multiplier", "offset", "remove", "envelope", "padding", "missing", "policy"]) -def test_json_cannot_edit_source_mimic_or_forge_envelope(source_mimic_release, tmp_path, mutation): - _, _, _, payload, _ = source_mimic_release - correction = payload["urdf_correction"] - if mutation in {"multiplier", "offset", "remove"}: - field = {"multiplier": "mimic_multiplier", "offset": "mimic_offset", "remove": "remove_mimic"}[mutation] - correction["joint_patches"]["ip"][field] = True if mutation == "remove" else "0.1" - elif mutation == "envelope": - correction["mimic_envelopes"]["ip"]["mimic_reachable_limits_rad"] = [-.5, 1.2] - elif mutation == "padding": - correction["joint_patches"]["ip"]["limit_lower"] = "-0.5" - correction["mimic_envelopes"]["ip"]["output_limits_rad"] = [-.5, 1.2] - elif mutation == "missing": - del correction["mimic_envelopes"]["ip"] - else: - correction["mimic_policy"] = "unknown" - output = tmp_path/"rejected.urdf" - with pytest.raises(ValueError): - rebuild(source_mimic_release, output) - assert not output.exists() - - -def test_union_cannot_be_presented_as_measured_support(source_mimic_release, tmp_path): - source, rights, plan, payload, _ = source_mimic_release - # Coherently changing both fields still needs trusted fit evidence at final acceptance. - correction = payload["urdf_correction"] - correction["mimic_envelopes"]["ip"]["base_limits_rad"] = [-.5, 1.2] - correction["mimic_envelopes"]["ip"]["output_limits_rad"] = [-.5, 1.2] - correction["joint_patches"]["ip"]["limit_lower"] = "-0.5" - output = rebuild(source_mimic_release, tmp_path/"candidate.urdf") - with pytest.raises(ValueError, match="differs_from_trusted_ranges"): - validate_artifact_structure(source_urdf=source, corrected_urdf=output, source_sha256=plan.source_sha256, - authorized_fields=rights, payload=payload, zero_offsets_rad={}, range_evidence=plan.range_evidence) - - -def test_hard_bounds_are_not_relaxed_to_retain_mimic(source_mimic_release): - source, rights, plan, _, _ = source_mimic_release - args = dict(source_urdf=source, source_sha256=plan.source_sha256, - zero_offsets_rad={}, authorized_fields=rights, preserve_source_mimic=True) - with pytest.raises(ValueError, match="source_mimic_exceeds_safety_range:ip"): - build_standard_correction_plan(**args) - measured = plan.range_evidence["ip"] - with pytest.raises(ValueError, match="source_mimic_exceeds_safety_range:ip"): - build_standard_correction_plan(**args, measured_ranges_output_rad={"ip": measured.output_limits_rad}, - range_evidence={"ip": replace(measured, independent_safety_limits_cad_rad=measured.output_limits_rad, - safety_evidence_source="actual hard bound")}) - - -def test_standard_ros_still_drives_original_passive_joint(source_mimic_release, tmp_path): - assert_robot_state_publisher_angles(source_mimic_release, tmp_path, use_mimic=True) - - -@pytest.mark.parametrize("multiplier,offset", [(-2., .1), (0., .2), (2., -.1)]) -def test_mimic_chains_use_reachable_parent_not_its_wider_json_envelope(tmp_path, multiplier, offset): - source = tmp_path/"chain.urdf" - source.write_text(f''' - - - - - - - - - - - -''') - model = UrdfKinematicModel(source) - ranges = {"active": (-.5, .5)} - offsets = {"active": .2, "passive": .3, "tip": -.4} - envelopes = compile_source_mimic_envelopes(model, ranges, offsets, {}) - expected = tuple(sorted((multiplier*-.5+offset, multiplier*.5+offset))) - assert envelopes["passive"].mimic_reachable_limits_rad == expected - assert envelopes["tip"].mimic_reachable_limits_rad == expected - assert envelopes["tip"].mimic_reachable_limits_rad != envelopes["passive"].output_limits_rad - plan = build_standard_correction_plan(source_urdf=source, - source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), - zero_offsets_rad=offsets, measured_ranges_output_rad=ranges, preserve_source_mimic=True, - authorized_fields={name: ("origin.rpy", "limit.lower", "limit.upper") for name in model.joints}) - output = plan.write(source, tmp_path/"chain-output.urdf") - validate_source_mimic_elements(source, output) - assert ET.parse(output).find(".//joint[@name='tip']/mimic").attrib == {"joint": "passive"} - corrected = UrdfKinematicModel(output) - for angle in np.linspace(-.5, .5, 20): - assert corrected.resolve_angles({"active": angle})["tip"] == pytest.approx(multiplier*angle+offset) - - -@pytest.mark.parametrize("relative", [ - "l6_left/linkerhand_l6v3.1_left.urdf", "l6_right/linkerhand_l6v3.1_right.urdf", - "o6_left/linkerhand_o6_left.urdf", "o6_right/linkerhand_o6_right.urdf", - "o12_left/linkerhand_o12_t3_left_urdf-0703.urdf", "o12_right/linkerhand_o12_t3_right-0703.urdf", - "g20_left/linkerhand_g20_left.urdf", "g20_right/linkerhand_g20_right.urdf", -]) -def test_each_vendor_source_preserves_its_own_coefficients_and_topology(relative, tmp_path): - source = Path(__file__).resolve().parents[1]/"urdf"/relative - model = UrdfKinematicModel(source) - before = source.read_bytes() - # Synthetic subranges avoid the known inconsistencies in full CAD ranges. - # This checks both sides' export contracts, not their hardware calibration. - ranges = {name: (j.lower*.25, j.upper*.25) for name, j in model.joints.items() - if j.kind != "fixed" and j.mimic_joint is None} - plan = build_standard_correction_plan(source_urdf=source, - source_sha256=hashlib.sha256(before).hexdigest(), zero_offsets_rad={}, - measured_ranges_output_rad=ranges, preserve_source_mimic=True, - authorized_fields={name: ("limit.lower", "limit.upper") for name in model.joints}) - output = plan.write(source, tmp_path/"output.urdf") - validate_source_mimic_elements(source, output) - assert source.read_bytes() == before - - -@pytest.mark.parametrize("relative,joint", [ - ("l6_right/linkerhand_l6v3.1_right.urdf", "rh_thumb_dip"), - ("o12_right/linkerhand_o12_t3_right-0703.urdf", "ring_pip"), - ("g20_right/linkerhand_g20_right.urdf", "index_dip"), -]) -def test_known_full_source_range_conflicts_are_explicit(relative, joint): - source = Path(__file__).resolve().parents[1]/"urdf"/relative - with pytest.raises(ValueError, match=f"source_mimic_exceeds_safety_range:{joint}:"): - build_standard_correction_plan(source_urdf=source, - source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), - zero_offsets_rad={}, authorized_fields={}, preserve_source_mimic=True) diff --git a/src/linkerhand_calibration/test/test_spatial_solve_order.py b/src/linkerhand_calibration/test/test_spatial_solve_order.py deleted file mode 100644 index 9e2146b..0000000 --- a/src/linkerhand_calibration/test/test_spatial_solve_order.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Recorded serial geometry must not depend on required-zero list order.""" - -from dataclasses import replace -from pathlib import Path -import gzip -import json -import math - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.domain.measurement import JointCurveFit -from linkerhand_calibration.core.fitting.session import compile_spatial_profile -from linkerhand_calibration.core.fitting.spatial import JointAxisMeasurement, solve_urdf_zero_offsets - - -def test_recorded_thumb_pitch_uses_the_fitted_upstream_yaw(): - package = Path(__file__).resolve().parents[1] - with gzip.open(package / "test/fixtures/o6_spatial_125641.json.gz", "rt") as stream: - values = json.load(stream) - values["measurements"] = [JointAxisMeasurement(**row) for row in values["measurements"]] - values["curves"] = {name: JointCurveFit(**row) for name, row in values["curves"].items()} - profile = load_bundled_hand_profile("o6_right_8") - spatial = compile_spatial_profile(profile) - values.update(source_urdf=package / "urdf/o6_right/linkerhand_o6_right.urdf") - wrong = solve_urdf_zero_offsets(**values, zero_profile=replace(spatial, - direct_zero_joints=("rh_pinky_mcp_pitch", "rh_thumb_cmc_pitch", "rh_thumb_cmc_yaw"))) - assert not wrong.passed - assert max(wrong.validation_errors_rad) > math.radians(4) - corrected = solve_urdf_zero_offsets(**values, zero_profile=spatial) - assert corrected.passed, corrected.failure_reasons - assert max(corrected.validation_errors_rad) < math.radians(1) - reordered = replace(profile, zero=replace(profile.zero, - direct_zero_joints=tuple(reversed(profile.zero.direct_zero_joints)))) - assert compile_spatial_profile(reordered).direct_zero_joints == spatial.direct_zero_joints diff --git a/src/linkerhand_calibration/test/test_staged_artifacts.py b/src/linkerhand_calibration/test/test_staged_artifacts.py deleted file mode 100644 index 09b49bf..0000000 --- a/src/linkerhand_calibration/test/test_staged_artifacts.py +++ /dev/null @@ -1,105 +0,0 @@ -"""One complete formal O30 path, with shared artifacts for rejection tests.""" - -import json -import xml.etree.ElementTree as ET -from copy import deepcopy - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import evidence_digest -from linkerhand_calibration.core.artifacts.storage import atomic_write_json -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session -from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json -from linkerhand_calibration.runtime.staged_training import resolve_staged_plan - -pytestmark = pytest.mark.integration - - -@pytest.fixture(scope='module') -def staged_artifacts(o30_capture, tmp_path_factory): - from staged_capture_fixture import staged_capture - directory = tmp_path_factory.mktemp('staged_formal') - profile, source, records, hashes, cameras, truth, frozen = staged_capture(o30_capture, directory) - session = directory/'session' - session.mkdir() - atomic_write_json(session/'frozen_training.json', frozen) - result = finalize_profile_session(profile=profile, source_urdf=source, records=records, - protected_inputs=hashes, serial_number='SYNTHETIC_FORMAL_O30', session_dir=session, - standard_loader=ET.parse, require_motion_evidence=True, camera_extrinsics_file=cameras, - frozen_training_sha256=evidence_digest(frozen), publish=False) - return profile, source, records, frozen, result, session, truth - - -def test_formal_staged_json_rebuilds_identical_urdf(staged_artifacts): - profile, source, records, frozen, (result, fit, artifact), session, truth = staged_artifacts - assert json.loads((session/'capture_validation.json').read_text())['passed'] - assert result['capture_plan']['policy'] == 'staged_2_to_3' - assert all(t['training_cycles'] == [0, 1] for t in result['capture_plan']['tasks'].values()) - assert all(fit.zero_method_by_joint[n] == 'assumed_source_cad_zero' for n in profile.zero.cad_zero_assumptions) - for name, expected in truth.items(): - assert fit.zero_offsets_rad[name] == pytest.approx(expected, abs=.004) - rebuilt = rebuild_urdf_from_json(artifact.staged_release.calibration_json, source, session/'rebuilt.urdf', - authorized_fields=profile.urdf_authorized_fields, expected_profile_id=profile.key.profile_id) - assert rebuilt.read_bytes() == artifact.path.read_bytes() - accepted = json.loads(artifact.staged_release.validation_json) - assert accepted['final_file_image_holdout_verified'] and accepted['urdf_rebuilt_from_json'] - assert not (session.parent/'latest_passed.json').exists() - - -def test_offline_decision_reproduces_original_freeze(staged_artifacts): - profile, source, records, *_ = staged_artifacts - restored = resolve_staged_plan(profile, records, source_urdf=source, verify_decisions=True, require_complete=True) - assert restored == profile - - -@pytest.mark.parametrize('fault', ['early_validation', 'post_freeze_training', 'decision_hash', 'late_reference']) -def test_rejects_invalid_phase_or_decision_without_another_fit(staged_artifacts, fault): - profile, _, records, *_ = staged_artifacts - changed = list(records) - row = next(r for r in records if r.get('kind') == 'joint_sample') - if fault == 'early_validation': - changed.insert(1, {**row, 'cycle': 3}) - elif fault == 'post_freeze_training': - changed.append(row) - elif fault == 'decision_hash': - index = next(i for i, r in enumerate(changed) if r.get('kind') == 'staged_training_decision') - changed[index] = {**changed[index], 'frozen_training_sha256': '0'*64} - else: - index = next(i for i, r in enumerate(changed) if str(r.get('verification', '')).startswith('staged_revisit')) - changed[index] = deepcopy(changed[index]) - for sample in changed[index]['current_reference']['samples']: - sample['image_stamp_ns'] += 10**16 - sample['sample_id'] = f"{sample['view']}:{sample['image_stamp_ns']}" - with pytest.raises(ValueError, match='staged_'): - resolve_staged_plan(profile, changed, require_complete=True) - - -def test_resume_keeps_complete_visits_and_preserves_frozen_prefix(staged_artifacts, tmp_path): - from linkerhand_calibration.runtime.engine import CalibrationEngine - from linkerhand_calibration.runtime.joint_resume import JointResume - from linkerhand_calibration.runtime.staged_resume import stage_visits, load_frozen_checkpoint - profile, source, records, frozen, *_ = staged_artifacts - last = next(i for i, r in enumerate(records) if r.get('kind') == 'joint_sample' and r.get('cycle') == 1 - and r.get('task_name') == profile.motion.tasks[2].key) - partial = JointResume(profile) - stage_visits(partial, CalibrationEngine(profile), records[:last+1]) - assert not any(key[0] == profile.motion.tasks[2].key and key[1] == 1 for key in partial.units) - assert {u.identity for u in CalibrationEngine(profile).scan_units() if u.cycle == 0} <= partial.units - frozen_end = next(i for i, r in enumerate(records) if r.get('kind') == 'staged_training_decision')+1 - resumed = JointResume(profile) - stage_visits(resumed, CalibrationEngine(profile), records[:frozen_end]) - assert {u.identity for u in CalibrationEngine(profile).scan_units() if u.cycle != 3} == resumed.units - atomic_write_json(tmp_path/'frozen_training.json', frozen) - assert load_frozen_checkpoint(profile, records[:frozen_end], tmp_path/'raw_samples.jsonl', source) == frozen - atomic_write_json(tmp_path/'frozen_training.json', {**frozen, 'solver_version': 'tampered'}) - with pytest.raises(ValueError, match='frozen_model_changed'): - load_frozen_checkpoint(profile, records[:frozen_end], tmp_path/'raw_samples.jsonl', source) - - -def test_cancel_before_finalization_never_writes_or_publishes(staged_artifacts, tmp_path): - profile, source, records, *_ = staged_artifacts - with pytest.raises(ValueError, match='operator_abort'): - finalize_profile_session(profile=profile, source_urdf=source, records=records, - protected_inputs={}, serial_number='SYNTHETIC_FORMAL_O30', session_dir=tmp_path, - cancelled=lambda: True) - assert not tuple(tmp_path.iterdir()) diff --git a/src/linkerhand_calibration/test/test_staged_runtime.py b/src/linkerhand_calibration/test/test_staged_runtime.py deleted file mode 100644 index b33ab31..0000000 --- a/src/linkerhand_calibration/test/test_staged_runtime.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Visit scheduling, bounded recovery and the persistent training service.""" - -import time - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import STAGED, CapturePlan, configure_training, evidence_digest -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.staged_training import dependency_tasks, staged_snapshot - - -def decision(profile, action, tasks=()): - row = dict(kind='staged_training_decision', decision=action, supplement_tasks=list(tasks), - input_plan_sha256=CapturePlan.from_profile(profile).sha256, failures=['persistent'] if action == 'fail' else []) - return {**row, 'decision_sha256': evidence_digest(row)} - - -@pytest.mark.parametrize('scope,final', [('local', 'freeze'), ('global', 'freeze'), ('local', 'fail')]) -def test_one_dependency_complete_supplement_then_freeze_or_fail(o30_capture, scope, final): - profile, _, records, _ = o30_capture - profile = configure_training(profile, STAGED) - execution = SessionExecution(profile) - session = execution.session - session._unit_index = max(i for i, u in enumerate(session._units) if u.cycle == 1) - session.phase = Phase.EVALUATE - tasks = dependency_tasks(profile, 'middle_pip' if scope == 'local' else 'palm_and_static_zero') - assert execution.evaluate(records, training_decision=decision(profile, 'supplement', tasks)).passed - assert session.current_unit.cycle == 2 - assert {u.task_key for u in session._units if u.cycle == 2} == set(tasks) - if scope == 'local': - assert len(tasks) < len(profile.motion.tasks) - session._unit_index = max(i for i, u in enumerate(session._units) if u.cycle == 2) - session.phase = Phase.EVALUATE - assert execution.evaluate(records, training_decision=decision(execution.profile, final)).passed - if final == 'freeze': - assert session.current_unit.cycle == 3 and session.phase == Phase.PREPARE - else: - assert session.phase == Phase.FAILED - - -def test_incremental_snapshot_equals_raw_evidence_and_invalidates_only_changed_task(o30_capture): - from linkerhand_calibration.runtime.capture_index import CaptureRecordIndex - profile, _, records, _ = o30_capture - profile = configure_training(profile, STAGED) - index = CaptureRecordIndex(retain_complete=False, retain_training_geometry=True) - index.extend(records) - first = staged_snapshot(profile, index) - assert evidence_digest(first) == evidence_digest(staged_snapshot(profile, records)) - task = profile.motion.tasks[0] - saved = index.training_task_snapshot(task.key, (0, 1)) - index.append({'kind': 'scan_unit_complete', 'task_name': profile.motion.tasks[-1].key, 'cycle': 1, 'passed': True}) - assert index.training_task_snapshot(task.key, (0, 1)) is saved - assert all(r['cycle'] in (0, 1) for r in first) - - -@pytest.mark.integration -def test_worker_reuses_one_process_and_stops_after_close(o30_capture): - from linkerhand_calibration.runtime.training import TrainingWorker - profile, source, _, _ = o30_capture - worker = TrainingWorker(timeout_seconds=10) - request = dict(profile=configure_training(profile, STAGED), source_urdf=source, rows=(), references={}) - try: - pid = None - for _ in range(2): - deadline, result = time.monotonic()+10, None - while result is None and time.monotonic() < deadline: - result = worker.poll(**request) - time.sleep(.005) - assert result['decision']['decision'] == 'supplement' - current = worker._process.pid - assert pid is None or pid == current - pid = current - finally: - worker.close() - with pytest.raises(RuntimeError, match='cancelled'): - worker.poll(**request) - - -def test_retry_allowance_survives_checkpoint_and_never_replaces_zero(o30_capture): - from linkerhand_calibration.runtime.motion_execution import MotionCommand - from linkerhand_calibration.runtime.zero_recovery import ZeroRecovery - from linkerhand_calibration.runtime.staged_resume import restore_recovery - profile = configure_training(o30_capture[0], STAGED) - execution, recovery = SessionExecution(profile), ZeroRecovery() - unit = execution.session._units[0] - name = profile.motion.tasks[0].joints[0] - motion = MotionCommand('joint_zero', profile.command.baseline_values, 200., - task_key=unit.task_key, zero_joints=(name,), reference_check=True) - assert recovery.take(motion, geometry_error='', reports={}, referenced_joints={name}) == 'retry_zero_images' - rows = [dict(kind='joint_zero_recovery', task_name=unit.task_key, joints=[name]), - dict(kind='scan_unit_complete', task_name=unit.task_key, cycle=unit.cycle, direction=unit.direction, - attempt=1, passed=False)] - restored = ZeroRecovery() - restore_recovery(execution.session, restored, rows) - assert restored.take(motion, geometry_error='', reports={}, referenced_joints={name}) is None - assert execution.session._retry_by_unit[unit.identity] == 1 - with pytest.raises(ValueError, match='recovery_exhausted'): - restore_recovery(execution.session, restored, [{**rows[-1], 'attempt': 2}]) diff --git a/src/linkerhand_calibration/test/test_staged_training.py b/src/linkerhand_calibration/test/test_staged_training.py deleted file mode 100644 index 4a781f9..0000000 --- a/src/linkerhand_calibration/test/test_staged_training.py +++ /dev/null @@ -1,210 +0,0 @@ -"""The staged policy shares evidence contracts with the fixed collector.""" - -import json -import gzip -from dataclasses import replace -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import ( - STAGED, TASKWISE_STAGED, CapturePlan, configure_training, select_task_training, -) -from linkerhand_calibration.core.domain.reference import read_joint_zero_references -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.staged_training import ( - assess_staged_training, staged_snapshot, read_frozen_training, dependency_tasks, -) - - -@pytest.fixture(scope='module') -def staged_fit(o30_capture): - profile, source, records, truth = o30_capture - profile = configure_training(profile, STAGED) - references = read_joint_zero_references(profile, records) - rows = staged_snapshot(profile, records) - result = assess_staged_training(profile=profile, source_urdf=source, rows=rows, references=references) - assert result['decision']['decision'] == 'freeze', result['decision'] - return profile, source, records, references, rows, result - - -def test_all_first_visits_precede_second_and_validation_keeps_original_motion(): - fixed = configure_training(load_bundled_hand_profile('o30_right_18'), 'fixed') - profile = configure_training(fixed, STAGED) - units = CalibrationEngine(profile).scan_units() - assert CapturePlan.from_profile(profile).as_dict()['version'] == 'capture_plan_v2' - assert [u.cycle for u in units] == sorted(u.cycle for u in units) - assert units == tuple(sorted((u for u in CalibrationEngine(fixed).scan_units() if u.cycle != 2), key=lambda u: u.cycle)) - selected = select_task_training(profile, 'fingers_mcp_roll_front', (0, 1, 2)) - assert len([u for u in CalibrationEngine(selected).scan_units() if u.cycle == 2]) == 4 - assert fixed.quality.training_policy == 'fixed' - - -def test_taskwise_schedule_keeps_first_pass_and_validation_global_but_supplements_locally(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), TASKWISE_STAGED) - plan = CapturePlan.from_profile(profile).as_dict() - assert plan['version'] == 'capture_plan_v3' - assert plan['schedule'] == ( - 'first_whole_hand_pass_then_taskwise_second_pass_with_immediate_supplement_then_validation' - ) - first_task = profile.motion.tasks[0] - profile = select_task_training(profile, first_task.key, (0, 1, 2)) - units = CalibrationEngine(profile).scan_units() - visits = [] - for unit in units: - visit = unit.task_key, unit.cycle - if not visits or visits[-1] != visit: - visits.append(visit) - task_keys = [task.key for task in profile.motion.tasks] - assert visits[:len(task_keys)] == [(key, 0) for key in task_keys] - training_visits = visits[len(task_keys):-len(task_keys)] - assert training_visits[:2] == [(first_task.key, 1), (first_task.key, 2)] - assert training_visits[2:] == [(key, 1) for key in task_keys[1:]] - assert visits[-len(task_keys):] == [(key, 3) for key in task_keys] - - -def test_taskwise_resume_carries_the_decision_that_created_optional_round(): - from linkerhand_calibration.runtime.joint_resume import JointResume - from linkerhand_calibration.runtime.staged_resume import visit_rows - - profile = configure_training(load_bundled_hand_profile('o30_right_18'), TASKWISE_STAGED) - task = profile.motion.tasks[0] - profile = select_task_training(profile, task.key, (0, 1, 2)) - units = [unit for unit in CalibrationEngine(profile).scan_units() - if unit.task_key == task.key and unit.cycle == 2] - decision = {'kind': 'training_decision', 'task_name': task.key, - 'assessed_cycles': [0, 1, 2], 'decision': 'freeze'} - rows = [dict(kind='scan_unit_complete', task_name=unit.task_key, - cycle=unit.cycle, direction=unit.direction, segment_key=unit.segment_key, - passed=True) for unit in units] - resume = JointResume(profile) - resume.rows = (*rows, decision) - resume.units = {unit.identity for unit in units} - resume.verified_visits.update((joint, 2) for joint in task.joints) - used, imported = visit_rows(resume, task.key, 2) - assert used == tuple(sorted(unit.identity for unit in units)) - assert [row for row in imported if row.get('kind') == 'training_decision'] == [decision] - - -def test_revisit_reuses_model_but_checks_zero_via_existing_path(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - execution = SessionExecution(profile) - task = profile.motion.tasks[0] - execution.zero_references.update({name: None for name in task.joints}) - session = execution.session - session._unit_index = next(i for i, u in enumerate(session._units) if u.task_key == task.key and u.cycle == 1) - session.phase = CalibrationPhase.PREPARE - effects = execution._make_effects(profile.command.baseline_values) - checks = [e for e in effects if e.phase == 'joint_zero' and not e.reference_only] - assert checks and all(e.reference_check for e in checks) - assert all(e.reference_check for e in effects if e.phase == 'zero_approach' and not e.reference_only) - - -@pytest.fixture(scope='module') -def recorded_revisit(): - from linkerhand_calibration.core.domain.reference import JointZeroReference - data = json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_revisit_feedback.json.gz').read_bytes())) - return (load_bundled_hand_profile('o30_right_18'), - JointZeroReference.from_record(data['previous']), JointZeroReference.from_record(data['current'])) - - -def test_revisit_real_feedback_repeatability_and_legacy_contract(recorded_revisit): - from linkerhand_calibration.runtime.revisit import revisit_record, verify_revisit - profile, previous, current = recorded_revisit - with pytest.raises(ValueError, match='held_feedback_changed'): - verify_revisit(profile, previous, current) # v1 retains the old limit. - row = revisit_record(profile, previous, current, 0) - assert row['feedback_limits'] == dict(unit='u8', within_hold_range=2., between_visit_difference=10.) - assert row['preserved_reference'] == previous.as_record() - verify_revisit(profile, previous, current, verification=row['verification'], feedback_limits=row['feedback_limits']) - with pytest.raises(ValueError, match='feedback_policy_changed'): - verify_revisit(profile, previous, current, verification=row['verification'], - feedback_limits={**row['feedback_limits'], 'between_visit_difference': 11.}) - with pytest.raises(ValueError, match='policy_unknown'): - verify_revisit(profile, previous, current, verification='staged_revisit_v999') - - -@pytest.mark.parametrize('fault', ['at_limit', 'over_limit', 'unstable', 'pose', 'installation']) -def test_revisit_repeatability_does_not_replace_stability_or_geometry(recorded_revisit, fault): - from linkerhand_calibration.runtime.revisit import revisit_record - profile, previous, current = recorded_revisit - samples = [] - for i, sample in enumerate(current.samples): - feedback = list(sample.feedback) - if fault in {'at_limit', 'over_limit'}: - feedback[5] = 48. - (10. if fault == 'at_limit' else 10.01) - elif fault == 'unstable': - feedback[5] = 41. + (3. if i == 0 else 0.) - sample = replace(sample, feedback=tuple(feedback)) - if fault == 'pose': - sample = replace(sample, child_pose_common=replace(sample.child_pose_common, - translation_xyz_m=(1., 1., 1.))) - samples.append(sample) - current = replace(current, samples=tuple(samples)) - if fault == 'installation': - current = replace(current, branch_references=()) - if fault == 'at_limit': - assert revisit_record(profile, previous, current, 0)['preserved_reference'] == previous.as_record() - else: - with pytest.raises(ValueError, match='revisit_reference_changed'): - revisit_record(profile, previous, current, 0) - - -def test_revisit_new_feedback_policy_is_scoped_to_o30(): - from linkerhand_calibration.runtime.revisit import revisit_feedback_limits - for name in ('o6_right_8', 'g20_right_19'): - limits = revisit_feedback_limits(load_bundled_hand_profile(name)) - assert limits['between_visit_difference'] == 2*limits['within_hold_range'] - - -def test_dependency_closure_keeps_complete_observation_tasks(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - tasks = dependency_tasks(profile, 'middle_pip') - assert 'middle_dip_side' in tasks - assert 'middle_pip_side' in tasks - assert set(dependency_tasks(profile, 'shared_palm_geometry')) == {t.key for t in profile.motion.tasks} - - -@pytest.mark.integration -def test_two_training_rounds_freeze_once_and_final_validation_does_not_fit(staged_fit, monkeypatch): - from linkerhand_calibration.runtime.acquisition import accepted_joint_records - from linkerhand_calibration.core.fitting.session import fit_profile_calibration - profile, source, records, references, rows, result = staged_fit - payload = json.loads(json.dumps(result['frozen_training'])) - frozen = read_frozen_training(profile, rows, references, source, payload) - def forbidden(*args, **kwargs): - raise AssertionError('frozen training must not run another optimizer') - monkeypatch.setattr('linkerhand_calibration.core.fitting.command_motion.fit_command_training', forbidden) - monkeypatch.setattr('linkerhand_calibration.core.fitting.spatial_solver.solve.solve_selected', forbidden) - kept = [r for r in records if r.get('cycle') != 2 or r.get('kind') == 'joint_zero_reference'] - fit = fit_profile_calibration(profile, source, accepted_joint_records(profile, kept), - zero_references=references, steady_records=accepted_joint_records(profile, kept, sample_phase='steady'), - frozen_training=frozen) - assert len(fit.output_mappings) == 20 - with pytest.raises(ValueError, match='frozen_hand_training_inputs_changed'): - read_frozen_training(profile, rows[:-1], references, source, payload) - - -@pytest.mark.integration -def test_holdout_is_never_a_training_input(staged_fit): - profile, source, records, references, rows, result = staged_fit - poisoned = [dict(r, relative_quaternion_xyzw=[float('nan')]*4) if r.get('cycle') == 3 else r for r in records] - assert staged_snapshot(profile, poisoned) == rows - with pytest.raises(ValueError, match='received_holdout'): - assess_staged_training(profile=profile, source_urdf=source, rows=({**rows[0], 'cycle': 3},), references=references) - - -@pytest.mark.integration -def test_mixed_rounds_count_only_complete_real_dependencies(o30_capture): - profile, source, records, _ = o30_capture - profile = configure_training(profile, STAGED) - for key in dependency_tasks(profile, 'middle_pip'): - profile = select_task_training(profile, key, (0, 1, 2)) - result = assess_staged_training(profile=profile, source_urdf=source, rows=staged_snapshot(profile, records), - references=read_joint_zero_references(profile, records)) - assert result['decision']['decision'] == 'freeze', result['decision'] - counts = result['decision']['metrics']['spatial']['independent_cycles_by_joint'] - assert counts['middle_pip'] == 3 and counts['pinky_pip'] == 2 diff --git a/src/linkerhand_calibration/test/test_stall_retry.py b/src/linkerhand_calibration/test/test_stall_retry.py deleted file mode 100644 index 265969c..0000000 --- a/src/linkerhand_calibration/test/test_stall_retry.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Exercise actual coordinator command delivery with a stationary motor.""" -import json -from dataclasses import replace - -import pytest - -from runtime_host_fixture import coordinator_fixture, ready -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase - - -def blocked_host(tmp_path, monkeypatch): - host, clock = coordinator_fixture(tmp_path, model='o30') - ready(host, clock) - host.parameters = replace(host.parameters, reuse_camera_calibration=True) - assert host.start().success - host.execution.session.phase = Phase.SWEEP - start = host.profile.command.baseline_values - target = list(start); target[0] = 255. - task = host.profile.motion.tasks[0] - motion = MotionCommand('sweep', tuple(target), 200., task_key=task.key, - command_index=0, cycle=0, direction='increasing') - host._motion = motion - host.last_command = start - host.segment = MotionExecution(host.profile, motion, initial_command=start, - initial_feedback=start, now=clock.now, identity='test_stalled_segment') - monkeypatch.setattr(host.execution, 'motion', lambda current: motion) - return host, clock, start - - -def heartbeat(host, clock): - 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={}))) - - -def advance(host, clock, feedback, seconds=.1): - clock.now += seconds - heartbeat(host, clock) - host.receive_feedback(host.profile.command.names, feedback, host.ports.clock_ns()) - host.tick() - - -def test_two_delayed_resends_then_third_failure_stops(tmp_path, monkeypatch): - host, clock, feedback = blocked_host(tmp_path, monkeypatch) - try: - for _ in range(500): - if host.state == 'PAUSED': - break - waiting = host.stall_retry.waiting and host.stall_retry.remaining(clock.now) > .11 - sent = len(clock.positions) - advance(host, clock, feedback) - if waiting: - assert len(clock.positions) == sent - assert host.state == 'PAUSED' - assert 'consecutive_failures=3' in host.reason - rows = [json.loads(line) for line in host.raw_path.read_text().splitlines()] - failures = [r for r in rows if r.get('kind') == 'motion_stall_retry'] - resumes = [r for r in rows if r.get('kind') == 'motion_stall_retry_resumed'] - assert [r['failure_count'] for r in failures] == [1, 2, 3] - assert [r['attempt'] for r in resumes] == [2, 3] - assert all(3. <= r['waited_seconds'] < 3.11 for r in resumes) - assert all(a['stamp_ns'] < b['stamp_ns'] for a,b in zip(failures,resumes)) - finally: - host.close() - - -@pytest.mark.parametrize('fault', ['feedback_stale', 'duplicate_controller']) -def test_other_protection_still_stops_during_retry_delay(tmp_path, monkeypatch, fault): - host, clock, feedback = blocked_host(tmp_path, monkeypatch) - try: - for _ in range(200): - advance(host, clock, feedback) - if host.stall_retry.waiting: - break - assert host.stall_retry.waiting - if fault == 'feedback_stale': - clock.now += host.profile.acquisition.feedback_stale_seconds+.1 - else: - clock.publishers = 2 - heartbeat(host, clock) - host.tick() - assert host.state == 'PAUSED' - assert fault in host.reason - assert host.stall_retry.failures == 1 - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_standard_correction_plan.py b/src/linkerhand_calibration/test/test_standard_correction_plan.py deleted file mode 100644 index c1068fb..0000000 --- a/src/linkerhand_calibration/test/test_standard_correction_plan.py +++ /dev/null @@ -1,84 +0,0 @@ -from dataclasses import replace -import hashlib - -import numpy as np -import pytest - -from linkerhand_calibration.core.urdf import build_standard_correction_plan -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - - -XML = ''' - - - - -''' - - -@pytest.fixture -def source(tmp_path): - path = tmp_path / "original.urdf" - path.write_text(XML) - return path - - -def plan(source, offsets=None, ranges=None, rights=None): - return build_standard_correction_plan(source_urdf=source, - source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), - zero_offsets_rad=offsets or {"drive": 0.1, "follower": -0.04}, - measured_ranges_output_rad=ranges, - authorized_fields=rights if rights is not None else { - "drive": ("origin.rpy", "limit.lower", "limit.upper"), - "follower": ("origin.rpy", "limit.lower", "limit.upper", "mimic.offset"), - }) - - -def test_zero_origin_both_limits_and_mimic_shift_together(source, tmp_path): - correction = plan(source) - output = correction.write(source, tmp_path / "corrected.urdf") - old, new = UrdfKinematicModel(source), UrdfKinematicModel(output) - assert new.joints["drive"].lower == pytest.approx(-0.1) - assert new.joints["drive"].upper == pytest.approx(0.9) - assert new.joints["follower"].mimic_offset == pytest.approx(0.14) - for cad_angle in np.linspace(0, 1, 81): - for joint in ("drive", "follower"): - before = old.link_transform(joint, zero_offsets={}, joint_angles={"drive": cad_angle}) - after = new.link_transform(joint, zero_offsets={}, joint_angles={"drive": cad_angle - 0.1}) - np.testing.assert_allclose(after, before, atol=1e-12) - - -def test_range_expansion_cannot_hide_a_bad_zero(source): - with pytest.raises(ValueError, match="mechanical range"): - plan(source, ranges={"drive": (0, 1)}) - - -def test_authorization_is_independent_of_the_result(source): - with pytest.raises(ValueError, match="authorization"): - plan(source, rights={"drive": ("origin.rpy",)}) - - -def test_changed_source_cannot_be_patched(source, tmp_path): - correction = plan(source) - source.write_text(XML.replace('effort="1"', 'effort="2"')) - with pytest.raises(ValueError, match="SHA256"): - correction.write(source, tmp_path / "corrected.urdf") - assert not (tmp_path / "corrected.urdf").exists() - - -def test_invalid_mimic_never_materializes_an_output(source, tmp_path): - correction = plan(source) - from linkerhand_calibration.core.urdf import UrdfJointPatch, UrdfPatchSet - correction = replace(correction, patches=UrdfPatchSet({"follower": UrdfJointPatch(mimic_multiplier="3")}), - authorized_fields={"follower": ("mimic.multiplier",)}) - with pytest.raises(ValueError, match="mimic reachable range"): - correction.write(source, tmp_path / "corrected.urdf") - assert not (tmp_path / "corrected.urdf").exists() - - -def test_existing_output_cannot_be_overwritten_or_deleted(source, tmp_path): - output = tmp_path / "corrected.urdf" - output.write_text("user file") - with pytest.raises(ValueError, match="overwrite"): - plan(source).write(source, output) - assert output.read_text() == "user file" diff --git a/src/linkerhand_calibration/test/test_standard_loader.py b/src/linkerhand_calibration/test/test_standard_loader.py deleted file mode 100644 index 7c7152f..0000000 --- a/src/linkerhand_calibration/test/test_standard_loader.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Offline ROS parser check; no SDK or command-topic publisher is started.""" -from pathlib import Path - -import pytest - -from linkerhand_calibration.runtime.artifacts.standard_loader import validate_with_robot_state_publisher - - -def test_actual_standard_parser_loads_isolated_robot_and_rejects_broken_tree(tmp_path): - pytest.importorskip("ament_index_python") - from ament_index_python.packages import get_package_prefix, PackageNotFoundError - try: - prefix = get_package_prefix("robot_state_publisher") - except PackageNotFoundError: - pytest.skip("robot_state_publisher is not installed") - if not (Path(prefix) / "lib/robot_state_publisher/robot_state_publisher").is_file(): - pytest.skip("robot_state_publisher binary is not installed") - source = tmp_path / "virtual.urdf" - source.write_text(''' - - - ''') - validate_with_robot_state_publisher(source) - source.write_text('') - with pytest.raises(ValueError, match="standard_urdf_loader_failed"): - validate_with_robot_state_publisher(source) diff --git a/src/linkerhand_calibration/test/test_standard_urdf_acceptance.py b/src/linkerhand_calibration/test/test_standard_urdf_acceptance.py deleted file mode 100644 index 986bff9..0000000 --- a/src/linkerhand_calibration/test/test_standard_urdf_acceptance.py +++ /dev/null @@ -1,124 +0,0 @@ -from dataclasses import replace -import math - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.urdf.acceptance import ( - JointHoldout, SerializedJointMapping, validate_standard_urdf_holdout, -) -from linkerhand_calibration.core.urdf.kinematics import ( - UrdfKinematicModel, corrected_mimic_offset, corrected_origin_rpy, -) -from linkerhand_calibration.core.urdf.validate import validate_structural_urdf_diff - - -XML = ''' - - - - -''' - - -@pytest.fixture -def artifacts(tmp_path): - path = tmp_path / "hand.urdf" - path.write_text(XML) - knots = np.linspace(0, 1, 65).tolist() - payload = {"schema_version": 7, "curve_input_domain": "feedback_rad", "joints": { - name: {"motor_index": 0, "curve_input_knots_rad": knots, "angle_rad": knots} - for name in ("drive", "follower")}} - return path, payload - - -def rows(nonlinear=False): - records = [] - for i, q in enumerate(np.linspace(0, 1, 64)): - for name in ("drive", "follower"): - observed = q + (0.18 * math.sin(math.pi * q) if nonlinear and name == "follower" else 0) - # Independent planar forward-kinematics oracle, not the URDF parser. - t = np.eye(4) - a = q if name == "drive" else 2*q - t[:3, :3] = ((math.cos(a), -math.sin(a), 0), (math.sin(a), math.cos(a), 0), (0, 0, 1)) - if name == "follower": - t[:3, 3] = (0.03*math.cos(q), 0.03*math.sin(q), 0) - records.append(JointHoldout(str(i), name, (float(q),), observed, "increasing", base_from_link=tuple(map(tuple, t)))) - return records - - -def test_serialized_urdf_and_independent_geometry_pass(artifacts): - path, payload = artifacts - metrics = validate_standard_urdf_holdout(corrected_urdf=path, payload=payload, - observations=rows(), required_joints=("drive", "follower"), required_pose_joints=("drive", "follower")) - assert all(value.maximum_deg < 1e-10 for value in metrics.values()) - - -def test_perfect_nonlinear_json_cannot_compensate_wrong_urdf(artifacts): - path, payload = artifacts - payload["joints"]["follower"]["angle_rad"] = [q + 0.18*math.sin(math.pi*q) for q in payload["joints"]["follower"]["curve_input_knots_rad"]] - with pytest.raises(ValueError, match="standard_urdf_mimic_not_accurate"): - validate_standard_urdf_holdout(corrected_urdf=path, payload=payload, - observations=rows(nonlinear=True), required_joints=("drive", "follower")) - - -def test_wrong_origin_cannot_pass_correct_angle_curves(artifacts): - path, payload = artifacts - path.write_text(XML.replace('rpy="0 0 0"', 'rpy="0.1 0 0"', 1)) - with pytest.raises(ValueError, match="spatial_holdout_failed"): - validate_standard_urdf_holdout(corrected_urdf=path, payload=payload, - observations=rows(), required_joints=("drive", "follower"), required_pose_joints=("drive",)) - - -@pytest.mark.parametrize("corrupt", ["training", "duplicate", "missing", "nan"]) -def test_holdout_evidence_cannot_be_invented(artifacts, corrupt): - path, payload = artifacts - observations = rows() - if corrupt == "training": - observations[0] = replace(observations[0], cycle=2) - elif corrupt == "duplicate": - observations.append(observations[0]) - elif corrupt == "missing": - observations = [row for row in observations if row.joint == "drive"] - else: - observations[0] = replace(observations[0], observed_rad=math.nan) - with pytest.raises(ValueError): - validate_standard_urdf_holdout(corrected_urdf=path, payload=payload, - observations=observations, required_joints=("drive", "follower")) - - -def test_mimic_resolves_source_on_sibling_branch(artifacts): - path, _ = artifacts - path.write_text(XML.replace('', '')) - model = UrdfKinematicModel(path) - pose = model.link_transform("follower", zero_offsets={}, joint_angles={"drive": 0.4, "follower": 0.9}) - assert Rotation.from_matrix(pose[:3, :3]).as_rotvec()[2] == pytest.approx(0.4) - - -def test_zero_composes_about_local_axis_and_mimic_coordinates(artifacts): - path, _ = artifacts - path.write_text(XML.replace('rpy="0 0 0"', 'rpy="0.2 -0.3 0.4"', 1).replace('axis xyz="0 0 1"', 'axis xyz="1 2 3"', 1)) - joint = UrdfKinematicModel(path).joints["drive"] - output = corrected_origin_rpy(joint, 0.17) - expected = Rotation.from_euler("xyz", (0.2, -0.3, 0.4)) * Rotation.from_rotvec(np.array((1, 2, 3))/math.sqrt(14)*0.17) - assert np.allclose(Rotation.from_euler("xyz", output).as_matrix(), expected.as_matrix()) - for parent in np.linspace(-0.2, 0.8, 20): - b = corrected_mimic_offset(1.2, 0.08, 0.1, -0.03) - assert 1.2*parent + b - 0.03 == pytest.approx(1.2*(parent+0.1)+0.08) - - -def test_nested_origin_is_not_authorized_joint_origin(artifacts, tmp_path): - path, _ = artifacts - source = XML.replace('', '', 1) - path.write_text(source) - changed = tmp_path / "changed.urdf" - changed.write_text(source.replace(' 15 - assert diagnostics["reference_frame_is_tag_pose"] is False - assert image_reference_from_dict(asdict(reference.reference)) == reference.reference - - -def test_coordinate_reference_preserves_independent_world_geometry(resolved_reference): - source, model, _ = resolved_reference - clean = frames() - for observed, expected in zip(source[::5], clean[::5]): - selected = select_image_motion_frame(model, observed) - assert selected.resolved, selected - child = next(item.pose for item in selected.selections if item.role == "moving") - actual = expected.evidence.roles[1].candidates[0] - assert np.linalg.norm(np.subtract(child.translation_xyz_m, actual.translation_xyz_m)) < 1e-6 - assert (Rotation.from_quat(child.quaternion_xyzw).inv() - * Rotation.from_quat(actual.quaternion_xyzw)).magnitude() < 1e-5 - assert np.allclose(model.geometry[0].axis_parent_xyz, [0, 0, 1], atol=1e-5) - assert image_motion_model_from_dict(image_model_payload(model)) == model - - -def test_stationary_reference_does_not_resolve_ambiguous_moving_tag(): - source = frames(alternatives=True) - reference = reference_from_frame(source[0]) - result = resolve_image_motion(tuple(in_reference(f, reference) for f in source), RELATIONS) - assert not result.resolved and result.model is None - assert len(result.hypotheses) == 2 - - -@pytest.mark.parametrize("change", ["moved", "missing", "matrix", "normal", "undeclared"]) -def test_current_image_and_coordinate_binding_are_mandatory(resolved_reference, change): - source, model, _ = resolved_reference - frame = source[-1] - if change == "moved": - image = replace(frame.observations[0], corners_xy=tuple((x + 2., y) - for x, y in frame.observations[0].corners_xy)) - frame = replace(frame, observations=(image, frame.observations[1])) - elif change == "missing": - frame = replace(frame, observations=frame.observations[1:]) - elif change == "matrix": - frame = replace(frame, camera_matrix=((1599., 0., 640.), *frame.camera_matrix[1:])) - elif change == "normal": - root = frame.evidence.roles[0] - pose = replace(root.candidates[0], quaternion_xyzw=tuple(Rotation.from_euler("y", 10, degrees=True).as_quat())) - frame = replace(frame, evidence=replace(frame.evidence, roles=(replace(root, candidates=(pose,)), frame.evidence.roles[1]))) - else: - frame = replace(frame, reference_frames=()) - assert not select_image_motion_frame(model, frame).resolved - - -def observed_reference(resolved_reference): - source, model, reference = resolved_reference - bank, tracker = ImageReferenceTracker(10), SquareTagPoseTracker() - for image in reference.source_images: - bank.estimate(reference.role, image.corners_xy, size=reference.tag_size_m, - matrix=reference.camera_matrix, stamp_ns=image.stamp_ns, locking=True) - frame = replace(source[-1], evidence=replace(source[-1].evidence, - stamp_ns=model.source_stamps_ns[-1] + 100_000_000)) - for observation in frame.observations: - if observation.role == reference.role: - bank.estimate(observation.role, observation.corners_xy, size=observation.tag_size_m, - matrix=frame.camera_matrix, stamp_ns=frame.evidence.stamp_ns, locking=False) - else: - tracker.estimate(observation.role, observation.corners_xy, tag_size_m=observation.tag_size_m, - camera_matrix=frame.camera_matrix, stamp_ns=frame.evidence.stamp_ns, motion_managed=True) - snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - frame = replace(frame, evidence=replace(frame.evidence, roles=(frame.evidence.roles[0], - RoleCandidates("moving", snapshot.candidates)))) - roots = (bank.motion_snapshot("root", frame.evidence.stamp_ns),) - decision = tracker.verify_image_motion_frame((snapshot,), model, frame, {"moving": "test_evidence"}, - root_snapshots=roots) - assert decision.accepted, decision - return bank, tracker, frame, decision - - -@pytest.mark.parametrize("change", ["reset", "new_image", "forged_snapshot"]) -def test_reference_change_between_solve_and_commit_is_rejected(resolved_reference, change): - bank, tracker, frame, decision = observed_reference(resolved_reference) - if change == "reset": - bank.reset() - elif change == "new_image": - bank.estimate("root", frame.observations[0].corners_xy, size=.016, matrix=frame.camera_matrix, - stamp_ns=frame.evidence.stamp_ns+1, locking=False) - else: - snapshot = decision.root_snapshots[0] - assert not bank.snapshot_current(replace(snapshot)) - assert bank.snapshot_current(snapshot) - return - assert tracker.commit_image_motion_frame(decision, establish=True).reason == "pose_observation_superseded" - - -def replayable_reference(resolved_reference): - bank, tracker, frame, decision = observed_reference(resolved_reference) - result = tracker.commit_image_motion_frame(decision, establish=True) - assert result.accepted, result - child_snapshot = tracker.motion_snapshot("moving", frame.evidence.stamp_ns) - assert tracker.freeze_roles({"moving": child_snapshot.revision}, require_motion_evidence=True) - selected = {"root": bank.reference.pose, "moving": result.selections[0].pose} - items = {} - for role, provider in (("root", bank), ("moving", tracker)): - observation = next(o for o in frame.observations if o.role == role) - tag = SimpleNamespace(tag_id=0 if role == "root" else 1, size_m=.016) - items.update(snapshot_pose_evidence({role: tag}, {role: np.asarray(observation.corners_xy)}, - {role: selected[role]}, provider, (), stamp_ns=frame.evidence.stamp_ns)) - record = dict(kind="pnp_candidate_frame", view="front", task_name="task", cycle=0, - direction="increasing", attempt=1, image_stamp_ns=frame.evidence.stamp_ns, - input_is_rectified=True, camera_matrix_source="CameraInfo.P[:3,:3]", camera_matrix=frame.camera_matrix, - roles={role: {**item, "selected": item["selected_pose"], "locked_reference": role == "root"} - for role, item in items.items()}) - parent, child = selected["root"], selected["moving"] - rp, rc = Rotation.from_quat(parent.quaternion_xyzw), Rotation.from_quat(child.quaternion_xyzw) - row = {k: record[k] for k in ("view", "task_name", "cycle", "direction", "attempt", "image_stamp_ns")} - row.update(pnp_observation_evidence={"parent": items["root"], "child": items["moving"]}, - parent_pose_common=asdict(parent), child_pose_common=asdict(child), - relative_quaternion_xyzw=(rp.inv()*rc).as_quat().tolist(), - relative_translation_xyz_m=rp.inv().apply(np.subtract(child.translation_xyz_m, parent.translation_xyz_m)).tolist()) - return record, row - - -def test_reference_capture_and_serialized_readback_preserve_raw_candidates(resolved_reference): - from linkerhand_calibration.core.geometry.frozen_evidence import EvidenceDecoder - frame, row = replayable_reference(resolved_reference) - parent = row["pnp_observation_evidence"]["parent"] - assert parent["pose_source"] == IMAGE_REFERENCE_SOURCE - assert parent["reprojection_valid_candidates"] - assert reference_from_evidence(parent, stamp_ns=row["image_stamp_ns"]) == resolved_reference[2] - validate_image_sample(row, frame, replay_image_models(frame)) - decoder = EvidenceDecoder() - saved_frame, saved_row = decoder.loads(json.dumps((frame, row))) - assert (saved_frame["roles"]["root"]["reference_frame"] - is saved_row["pnp_observation_evidence"]["parent"]["reference_frame"]) - with pytest.raises(TypeError): - saved_frame["roles"]["root"]["reference_frame"]["role"] = "another_tag" - validate_image_sample(saved_row, saved_frame, replay_image_models(saved_frame)) - - -@pytest.mark.parametrize("change", ["origin", "identity", "normal_claim", "pose_source", "corners", "model_marker"]) -def test_readback_rejects_changed_reference_evidence(resolved_reference, change): - frame, row = json.loads(json.dumps(replayable_reference(resolved_reference))) - item = frame["roles"]["root"] - if change == "origin": - item["reference_frame"]["origin_xyz_m"][0] += .001 - elif change == "identity": - item["reference_frame_sha256"] = "a"*64 - elif change == "normal_claim": - item["candidate_diagnostics"]["reference_frame_is_tag_pose"] = True - elif change == "pose_source": - item["pose_source"] = "verified_fixed_reference" - elif change == "corners": - row["pnp_observation_evidence"]["parent"]["corners_xy"][0][0] += .1 - else: - child = frame["roles"]["moving"] - child["frozen_image_model"].pop("reference_frames") - child["image_model_sha256"] = image_model_sha256(child["frozen_image_model"]) - with pytest.raises(ValueError): - validate_image_sample(row, frame, replay_image_models(frame)) - - -def test_legacy_model_payload_keeps_original_hash(resolved_reference): - legacy = replace(resolved_reference[1], reference_frames=()) - old = asdict(legacy) - del old["reference_frames"] - assert image_model_payload(legacy) == old - assert image_model_sha256(image_model_payload(image_motion_model_from_dict(old))) == image_model_sha256(old) - - -def test_new_coordinate_mode_cannot_resume_old_pose_based_schedule(): - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.engine import CalibrationEngine, ACQUISITION_POLICY_VERSION - from linkerhand_calibration.core.geometry.tag_pose.parameters import POSE_TRACKING_POLICY_VERSION - from linkerhand_calibration.core.urdf.partial_scope import profile_scope - profile = load_bundled_hand_profile("o30_right_18", scope="without_finger_roll") - current = CalibrationEngine(profile) - old = CalibrationEngine(replace(profile, acquisition=replace(profile.acquisition, - fixed_reference_mode="tag_pose"))) - header = dict(profile_id=profile.key.profile_id, calibration_scope=profile_scope(profile), - acquisition_policy_version=ACQUISITION_POLICY_VERSION, - pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, - capture_schedule_version=old.capture_schedule_version) - assert old.resume_compatible(header) - assert not current.resume_compatible(header) - header["capture_schedule_version"] = current.capture_schedule_version - assert current.resume_compatible(header) - assert not old.resume_compatible(header) diff --git a/src/linkerhand_calibration/test/test_steady_feedback_window.py b/src/linkerhand_calibration/test/test_steady_feedback_window.py deleted file mode 100644 index 131182d..0000000 --- a/src/linkerhand_calibration/test/test_steady_feedback_window.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Feedback stability must survive scheduler jitter without hiding motion.""" - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution - - -@pytest.fixture(params=["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) -def profile(request): - return load_bundled_hand_profile(request.param) - - -@pytest.fixture(params=["steady", "joint_zero"]) -def phase(request): - return request.param - - -def segment_at_baseline(profile, phase, *, target=None, started_at=0.): - task = profile.motion.tasks[0] - baseline = profile.command.baseline_values - speed = task.formal_speed_u8 if profile.command.unit == "u8" else task.formal_speed - return MotionExecution(profile, MotionCommand( - phase, baseline if target is None else target, speed, - task_key=task.key, command_index=task.command_index), - initial_command=baseline, initial_feedback=baseline, now=started_at, identity="test") - - -def observe(segment, now, *, stamp=None, feedback=None): - segment.sample(now) - segment.observe(segment.profile.command.baseline_values if feedback is None else feedback, - stamp=now if stamp is None else stamp, now=now) - return segment.steady_ready(now) - - -@pytest.mark.parametrize("started_at", [0., 10., 1_000_000.]) -def test_constant_feedback_with_90ms_timer_becomes_and_remains_stable(profile, phase, started_at): - segment = segment_at_baseline(profile, phase, started_at=started_at) - for index in range(21): - now = started_at + index * .09 - stable = observe(segment, now) - if index < 3: - assert not stable - else: - assert stable, f"Constant feedback lost stability at {now:.2f} s" - - -@pytest.mark.parametrize("started_at", [0., 10., 1_000_000.]) -def test_mixed_timer_intervals_do_not_revoke_constant_feedback_stability(profile, phase, started_at): - segment = segment_at_baseline(profile, phase, started_at=started_at) - times = [index * .02 for index in range(26)] - times += [.59, .68, .77, .79, .81, .83, .85, .87, .89, .91] - for elapsed in times: - now = started_at + elapsed - stable = observe(segment, now) - if elapsed >= .24: - assert stable, f"Scheduler jitter cleared stable feedback at {now:.2f} s" - - -@pytest.mark.parametrize("started_at", [0., 10., 1_000_000.]) -def test_100hz_timer_preserves_stability_between_60hz_feedback_samples(profile, phase, started_at): - segment = segment_at_baseline(profile, phase, started_at=started_at) - stamps = set() - for tick in range(200): - # Feedback identities advance at 60 Hz. The 100 Hz timer often sees - # the same independent observation and must retain its stable window. - stamp = tick * 60 // 100 - stamps.add(stamp) - now = started_at + tick / 100 - stable = observe(segment, now, stamp=stamp) - if tick >= 24: - assert stable, f"A tick without new feedback revoked stability at {now:.2f} s" - assert len(segment.stamped_history) == len(stamps) < 200 - - -def test_actual_feedback_change_requires_a_new_stable_window(profile, phase): - segment = segment_at_baseline(profile, phase) - for now in (0., .09, .18, .27): - stable = observe(segment, now) - assert stable - - index = segment.command.command_index - changed = list(profile.command.baseline_values) - lower, upper = (profile.command.minimum_feedback_values[index], - profile.command.maximum_feedback_values[index]) - # A real encoder change well above the existing stability threshold. - delta = .05 * (upper - lower) - changed[index] += delta if changed[index] <= (lower + upper) / 2 else -delta - assert not observe(segment, .30, feedback=changed) - for now in (.39, .48, .57): - stable = observe(segment, now, feedback=changed) - assert stable - - -def test_repeated_feedback_stamp_cannot_supply_three_independent_samples(profile, phase): - segment = segment_at_baseline(profile, phase) - for now in (0., .10, .20): - assert not observe(segment, now, stamp=1) - for now in (.21, .24, .27): - assert not observe(segment, now, stamp=2) - assert len(segment.stamped_history) == 2 - - -def test_feedback_before_trajectory_end_cannot_establish_the_hold_window(profile, phase): - task = profile.motion.tasks[0] - index = task.command_index - target = list(profile.command.baseline_values) - target[index] = (profile.command.minimum_values[index] + profile.command.maximum_values[index]) / 2 - segment = segment_at_baseline(profile, phase, target=tuple(target)) - for now in (0., .01, .02): - assert not observe(segment, now) - assert segment.finished_at is None - arrival = segment.duration + .1 - for delay in (0., .02, .04): - assert not observe(segment, arrival + delay, feedback=target) - assert segment.finished_at == arrival - for delay in (.10, .20, .30): - stable = observe(segment, arrival + delay, feedback=target) - assert stable - - -def test_stale_feedback_history_cannot_remain_stable_indefinitely(profile, phase): - segment = segment_at_baseline(profile, phase) - for now in (0., .09, .18, .27): - stable = observe(segment, now) - assert stable - stale_time = .27 + profile.acquisition.steady_window_seconds + .01 - assert not segment.steady_ready(stale_time) - - -def test_joint_zero_checks_held_channels_in_addition_to_the_current_joint(profile): - segment = segment_at_baseline(profile, "joint_zero") - for now in (0., .09, .18, .27): - stable = observe(segment, now) - assert stable - index = next(index for index in range(profile.command.command_count) - if index != segment.command.command_index and index not in profile.command.disabled_indices) - changed = list(profile.command.baseline_values) - lower, upper = (profile.command.minimum_feedback_values[index], - profile.command.maximum_feedback_values[index]) - delta = .05 * (upper - lower) - changed[index] += delta if changed[index] <= (lower + upper) / 2 else -delta - assert not observe(segment, .30, feedback=changed) diff --git a/src/linkerhand_calibration/test/test_storage.py b/src/linkerhand_calibration/test/test_storage.py deleted file mode 100644 index 03419ad..0000000 --- a/src/linkerhand_calibration/test/test_storage.py +++ /dev/null @@ -1,69 +0,0 @@ -from linkerhand_calibration.storage import ( - append_jsonl, - append_jsonl_many, - atomic_write_json, - completed_scan_keys, - load_json, - load_jsonl, - sync_jsonl, -) - - -def test_jsonl_checkpoint_and_resume_keys(tmp_path) -> None: - raw_path = tmp_path / "raw_samples.jsonl" - record = { - "kind": "sample", - "phase": "root", - "cycle": 0, - "direction": "decreasing", - "command_u8": 255, - } - append_jsonl(raw_path, record) - append_jsonl(raw_path, {"kind": "validation", "command_u8": 10}) - loaded = load_jsonl(raw_path) - assert loaded[0] == record - assert completed_scan_keys(loaded) == {("root", 0, "decreasing", 255)} - - checkpoint_path = tmp_path / "checkpoint.json" - atomic_write_json(checkpoint_path, {"state": "PAUSED", "records": 1}) - assert load_json(checkpoint_path) == {"state": "PAUSED", "records": 1} - - -def test_resume_ignores_only_a_truncated_final_jsonl_record(tmp_path) -> None: - raw_path = tmp_path / "raw_samples.jsonl" - append_jsonl(raw_path, {"kind": "sample", "phase": "root"}) - with raw_path.open("a", encoding="utf-8") as stream: - stream.write('{"kind":"sample"\n\n') - assert load_jsonl(raw_path) == [{"kind": "sample", "phase": "root"}] - - -def test_continuous_batches_flush_in_order_and_checkpoint_syncs_all_prior_rows(tmp_path, monkeypatch): - from linkerhand_calibration.core.artifacts import storage - path = tmp_path / "raw_samples.jsonl" - persisted = [] - monkeypatch.setattr(storage.os, "fsync", lambda _fd: persisted.append(load_jsonl(path))) - rows = [{"kind": "joint_sample", "image_stamp_ns": index} for index in range(3)] - append_jsonl_many(path, rows, durable=False) - assert load_jsonl(path) == rows - assert persisted == [] - checkpoint = {"kind": "scan_unit_complete", "passed": True} - append_jsonl(path, checkpoint) - assert persisted == [rows + [checkpoint]] - more = {"kind": "pnp_branch_event"} - append_jsonl_many(path, [more]) # Existing callers remain durable by default. - assert persisted[-1] == rows + [checkpoint, more] - sync_jsonl(path) - assert len(persisted) == 3 - assert persisted[-1] == load_jsonl(path) - - -def test_explicit_sync_failure_is_not_swallowed(tmp_path, monkeypatch): - import pytest - from linkerhand_calibration.core.artifacts import storage - path = tmp_path / "raw_samples.jsonl" - append_jsonl_many(path, [{"kind": "joint_sample"}], durable=False) - def failed(_fd): - raise OSError("simulated disk sync failure") - monkeypatch.setattr(storage.os, "fsync", failed) - with pytest.raises(OSError, match="disk sync failure"): - sync_jsonl(path) diff --git a/src/linkerhand_calibration/test/test_tag_border.py b/src/linkerhand_calibration/test/test_tag_border.py deleted file mode 100644 index a0999b7..0000000 --- a/src/linkerhand_calibration/test/test_tag_border.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Occluded decoded quads must not become fixed-reference movement evidence.""" - -from pathlib import Path - -import cv2 -import numpy as np - -from linkerhand_calibration.core.geometry.tag_border import inspect_tag_border -from linkerhand_calibration.runtime.image_pairing import ExactImagePairing -from linkerhand_calibration.runtime.reference_lock import ReferenceLock - - -def test_recorded_front_tag_border_distinguishes_clear_and_thumb_occlusion(): - fixture = np.load(Path(__file__).parent/"fixtures/o6_front_border_121857.npz") - for image, corners, expected in zip(fixture["images"], fixture["corners"], fixture["expected"]): - assert inspect_tag_border(image, corners)["passed"] == bool(expected) - - -def test_visible_translation_still_triggers_original_five_pixel_movement_guard(): - fixture = np.load(Path(__file__).parent/"fixtures/o6_front_border_121857.npz") - image, corners = fixture["images"][0], fixture["corners"][0] - shifted = cv2.warpAffine(image, np.float32([[1, 0, 12], [0, 1, 8]]), - (image.shape[1], image.shape[0])) - moved = corners + [12, 8] - assert inspect_tag_border(shifted, moved)["passed"] - lock = ReferenceLock({"front": 0}) - lock.begin_session(); lock.start_locking() - for _ in range(10): lock.observe("front", 0, corners) - for _ in range(9): assert lock.observe("front", 0, moved) is None - assert lock.observe("front", 0, moved).drift_px > 5 - - -def test_blank_and_outside_image_have_no_border_evidence(): - corners = np.float32([[30, 30], [90, 30], [90, 90], [30, 90]]) - assert not inspect_tag_border(np.full((120, 120), 100, np.uint8), corners)["passed"] - assert not inspect_tag_border(np.full((120, 120), 100, np.uint8), corners-40)["passed"] - - -def test_pairing_requires_same_stamp_in_either_order_and_never_reuses_old_images(): - pair = ExactImagePairing(capacity=2) - assert pair.add("detections", 10, "d10") is None - assert pair.add("image", 11, "i11") is None - assert pair.add("image", 10, "i10") == ("i10", "d10") - assert pair.add("detections", 11, "d11") == ("i11", "d11") - assert pair.add("image", 10, "old") is None - assert pair.add("detections", 11, "duplicate") is None - for stamp in (12, 13, 14): pair.add("image", stamp, f"i{stamp}") - assert len(pair.images) == 2 - assert pair.add("detections", 12, "expired") is None - assert pair.add("detections", 14, "d14") == ("i14", "d14") - - -def test_coordinator_discards_occluded_quad_but_pauses_on_visible_motion(tmp_path): - from runtime_host_fixture import coordinator_fixture, ready - from linkerhand_calibration.runtime.inputs import DetectionInput, TagDetection - from linkerhand_calibration.runtime.session import CalibrationPhase as Phase - host, clock = coordinator_fixture(tmp_path) - try: - ready(host, clock) - assert host.start().success - corners = np.float32([[100, 160], [160, 160], [160, 100], [100, 100]]) - host.reference_lock.start_locking() - for view, tag_id in host.reference_lock.fixed_tag_by_view.items(): - for _ in range(10): host.reference_lock.observe(view, tag_id, corners) - for passed in (False, True): - for _ in range(10): - clock.now += .04 - detection = TagDetection(0, tuple(map(tuple, corners+[12, 8])), 0, 100., - {"passed": passed, "reason": "" if passed else "tag_border_incomplete"}) - host.receive_detections(DetectionInput("front", host.ports.clock_ns(), (detection,))) - if not passed: - assert host.execution.session.phase != Phase.PAUSED - assert host.reference_lock.latest_drift_px["front"] == 0 - assert sum(r["kind"] == "detection_quality_rejected" for r in host.raw_records) == 10 - assert not any(r["kind"] in {"joint_sample", "joint_zero_sample"} for r in host.raw_records) - assert host.execution.session.phase == Phase.PAUSED - assert host.reason.startswith("fixed_reference_moved:front:") - finally: - host.close() - - -def test_image_process_publishes_checked_pixels_with_exact_identity(monkeypatch, tmp_path): - import rclpy - from types import SimpleNamespace - from linkerhand_calibration.profiles import load_bundled_hand_profile - from linkerhand_calibration.runtime.ros.tag_border_filter import TagBorderFilter - from linkerhand_calibration.runtime.ros.io import checked_detection_input - monkeypatch.setenv("ROS_DOMAIN_ID", "221") - monkeypatch.setenv("ROS_LOG_DIR", str(tmp_path/"logs")) - fixture = np.load(Path(__file__).parent/"fixtures/o6_front_border_121857.npz") - rclpy.init() - node = TagBorderFilter(load_bundled_hand_profile("o6_right_8"), ("front",)) - sent = [] - monkeypatch.setattr(node.publishers_by_view["front"], "publish", sent.append) - try: - for index in (0, 5): - image = node.bridge.cv2_to_imgmsg(fixture["images"][index], encoding="mono8") - image.header.stamp.sec = index+1 - detection = SimpleNamespace(header=image.header, detections=[SimpleNamespace( - id=0, hamming=0, decision_margin=100, - corners=[SimpleNamespace(x=float(x), y=float(y)) for x, y in fixture["corners"][index]])]) - node.receive("front", "detections", detection) - assert len(sent) == (0 if index == 0 else 1) - node.receive("front", "image", image) - observed = checked_detection_input("front", sent[-1], fixed_ids={0}) - assert observed.stamp_ns == (index+1)*1_000_000_000 - assert observed.detections[0].border_quality["passed"] == (index == 0) - finally: - node.destroy_node() - rclpy.shutdown() diff --git a/src/linkerhand_calibration/test/test_task_input_support.py b/src/linkerhand_calibration/test/test_task_input_support.py deleted file mode 100644 index 6da33d8..0000000 --- a/src/linkerhand_calibration/test/test_task_input_support.py +++ /dev/null @@ -1,97 +0,0 @@ -"""A variable hardware endpoint must not escape into a later whole-hand fit.""" - -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.runtime.engine import CalibrationEngine -from linkerhand_calibration.runtime.execution import SessionExecution -from linkerhand_calibration.runtime.session import CalibrationPhase as Phase -from linkerhand_calibration.runtime.steady import steady_targets -from linkerhand_calibration.runtime.task_quality import evaluate_task_input_support - - -def capture(*, held_upper=253., attempt=1): - profile = load_bundled_hand_profile("o30_right_18", scope="full") - profile = replace(profile, acquisition=replace(profile.acquisition, motion_observation="feedback"), - measurement=replace(profile.measurement, release_basis="feedback_and_command")) - task = profile.motion.tasks[0] - units = [unit for unit in CalibrationEngine(profile).scan_units() if unit.task_key == task.key] - rows = [] - for unit in units: - upper = 250. if unit.cycle < 3 else held_upper - common = dict(joint="thumb_cmc_roll", view="front", task_name=task.key, - cycle=unit.cycle, direction=unit.direction, attempt=attempt) - for command in np.linspace(unit.start, unit.end, 129): - rows.append(dict(common, image_stamp_ns=len(rows)+1, sample_phase="sweep", - command_u8=float(command), feedback_u8=3.+(upper-3.)*command/255.)) - for index, command in enumerate(steady_targets(profile, unit)): - for _ in range(profile.acquisition.steady_minimum_samples): - rows.append(dict(common, image_stamp_ns=len(rows)+1, sample_phase="steady", - command_u8=float(command), feedback_u8=3.+(upper-3.)*command/255., - steady_index=index, steady_target=command)) - return profile, task, units, rows - - -def test_completed_direction_cannot_hide_unseen_holdout_endpoint(): - profile, task, units, rows = capture() - original = deepcopy(rows) - driver = SessionExecution(profile) - driver.session.phase = Phase.EVALUATE - driver.session._unit_index = len(units)-1 - driver.completed_units.update(unit.identity for unit in units[:-1]) - quality = driver.evaluate(rows) - assert quality.passed # Direction coverage and command endpoints are complete. - assert driver.session.phase == Phase.PAUSED - assert not driver.last_task_quality.passed - assert driver.session.status().pause.code == "task_input_support_failed" - assert not driver.completed_units - assert rows == original # No clipping, relabelling or held-out training. - metrics = driver.last_task_quality.metrics["joint:thumb_cmc_roll:front"] - assert metrics["training_support"] == [3., 250.] - assert metrics["holdout_span"] == [3., 253.] - assert metrics["outside_training_support"] > 0 - - -def test_complete_supported_task_advances_without_extra_travel(): - profile, task, units, rows = capture(held_upper=250.) - driver = SessionExecution(profile) - driver.session.phase = Phase.EVALUATE - driver.session._unit_index = len(units)-1 - driver.completed_units.update(unit.identity for unit in units[:-1]) - assert driver.evaluate(rows).passed - assert driver.last_task_quality.passed - assert driver.session.phase == Phase.PREPARE - assert driver.session.current_unit.task_key == profile.motion.tasks[1].key - - -def test_old_attempt_or_another_joint_cannot_extend_training_support(): - profile, task, _, rows = capture(attempt=2) - distractors = [dict(row, attempt=1, feedback_u8=255.) for row in rows if row["cycle"] < 3] - distractors += [dict(row, joint="thumb_mcp", feedback_u8=255.) for row in rows if row["cycle"] < 3] - quality = evaluate_task_input_support(profile, task, distractors+rows) - assert not quality.passed - assert quality.metrics["joint:thumb_cmc_roll:front"]["training_support"] == [3., 250.] - - -@pytest.mark.parametrize("missing", ["training", "holdout"]) -def test_missing_native_input_evidence_is_rejected(missing): - profile, task, _, rows = capture(held_upper=250.) - rows = [row for row in rows if (row["cycle"] == 3) == (missing == "training")] - assert not evaluate_task_input_support(profile, task, rows).passed - - -def test_command_release_does_not_gate_on_feedback_support(): - profile, task, _, rows = capture() - profile = replace(profile, measurement=replace(profile.measurement, release_basis="steady_command")) - quality = evaluate_task_input_support(profile, task, rows) - assert quality.passed - metrics = quality.metrics["joint:thumb_cmc_roll:front"] - assert metrics["input_domain"] == "command_u8" - assert metrics["training_support"] == [0., 255.] - # Missing commanded endpoint coverage still blocks the same release. - reduced = [r for r in rows if r["cycle"] == 3 or r["command_u8"] < 255] - assert not evaluate_task_input_support(profile, task, reduced).passed diff --git a/src/linkerhand_calibration/test/test_taskwise_recovery.py b/src/linkerhand_calibration/test/test_taskwise_recovery.py deleted file mode 100644 index 9f956ef..0000000 --- a/src/linkerhand_calibration/test/test_taskwise_recovery.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Bounded whole-task recovery preserves acquired data and release requirements.""" -from itertools import groupby -import json - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.session import CalibrationSession, CalibrationPhase as Phase -from linkerhand_calibration.runtime.task_deferral import ( - TaskDeferralQueue, TaskObservationState, recoverable_task_observation, -) - -REASON = 'image_motion_families_not_distinguishable' -TASK = 'pinky_mcp_pitch_side' - - -@pytest.fixture -def profile(): - return load_bundled_hand_profile('o30_right_18') - - -def at_task(session, key): - session._unit_index = next(i for i, u in enumerate(session.scheduled_units) if u.task_key == key) - session.phase = Phase.PREPARE - - -def test_entire_chain_moves_once_without_reordering_rounds_or_completed_prefix(profile): - session = CalibrationSession(profile) - at_task(session, TASK) - before = session.scheduled_units - prefix = before[:session._unit_index] - decision = session.defer_current_task(REASON) - assert decision.affected_tasks == (TASK, 'pinky_pip_side', 'pinky_dip_side') - assert decision.moved_units == 18 - assert session.current_unit.task_key == 'ring_mcp_pitch_side' - assert session.scheduled_units[:session._unit_index] == prefix - assert set(session.scheduled_units) == set(before) - for key, units in groupby(session.scheduled_units, lambda u: u.task_key): - assert [c for c, _ in groupby(units, lambda u: u.cycle)] == [0, 1, 3] - assert session.scheduled_units[-1].task_key == 'pinky_dip_side' - - -def test_second_failure_finishes_independent_tasks_returns_home_and_never_fits(profile): - session = CalibrationSession(profile) - at_task(session, TASK) - session.defer_current_task(REASON) - at_task(session, TASK) # All independent tasks have now been visited. - decision = session.defer_current_task(REASON) - assert decision.state == TaskObservationState.UNOBSERVABLE - assert session.phase == Phase.RETURN_BASELINE - session.return_complete() - assert session.phase == Phase.FAILED - assert session.status().pause.code == 'unobservable_tasks' - - -def test_recovery_budget_and_pending_order_survive_restart(profile): - session = CalibrationSession(profile) - at_task(session, TASK) - first = session.defer_current_task(REASON).as_record(stamp_ns=1) - restored = CalibrationSession(profile) - restored.restore_task_deferrals([first]) - assert restored.scheduled_units == session.scheduled_units - at_task(restored, TASK) - second = restored.defer_current_task(REASON).as_record(stamp_ns=2) - again = CalibrationSession(profile) - again.restore_task_deferrals([first, second]) - assert not {u.task_key for u in again.scheduled_units} & set(first['affected_tasks']) - assert again.task_deferrals.attempts[(TASK, 0)] == 2 - with pytest.raises(ValueError, match='budget_exhausted'): - again.task_deferrals.decide(TASK, 0, REASON, 0) - - -def test_resolved_parent_audit_keeps_dependency_contract(profile): - queue = TaskDeferralQueue(profile) - pending = queue.decide(TASK, 0, REASON, 18).as_record(stamp_ns=1) - resolved = queue.resolve(TASK, 0).as_record(stamp_ns=2) - recovered = TaskDeferralQueue(profile) - recovered.restore([pending, resolved]) - assert (TASK, 0) in recovered.resolved - assert not recovered.unobservable - - -def test_incomplete_recovery_cannot_publish_even_if_other_tasks_pass(profile): - from linkerhand_calibration.runtime.artifacts.capture_validation import validate_capture_units - queue = TaskDeferralQueue(profile) - rows = [queue.decide(TASK, 0, REASON, 18).as_record(stamp_ns=1)] - with pytest.raises(ValueError, match='capture_unobservable_tasks'): - validate_capture_units(profile, rows) - - -def test_started_task_cannot_be_reordered(profile): - session = CalibrationSession(profile) - at_task(session, TASK) - session._unit_index += 1 - before = session.scheduled_units - with pytest.raises(ValueError, match='requires_unstarted_task'): - session.defer_current_task(REASON) - assert session.scheduled_units == before - assert not session.task_deferrals.attempts - - -def test_live_coordinator_continues_independent_task_and_logs_budget(tmp_path): - from runtime_host_fixture import coordinator_fixture - from linkerhand_calibration.runtime.motion_execution import MotionCommand - host, clock = coordinator_fixture(tmp_path, model='o30') - try: - session = host.execution.session - at_task(session, TASK) - motion = MotionCommand('joint_zero', host.profile.command.baseline_values, 200, - task_key=TASK, cycle=0, zero_joints=('pinky_mcp_pitch',)) - host.branch_initialization.zero_joints = motion.zero_joints - host.branch_initialization.reports['side'] = dict(status='unresolved', reason=REASON) - assert host._defer_unobservable_task(motion, host.branch_initialization.error()) - assert session.phase == Phase.PREPARE - assert session.current_unit.task_key == 'ring_mcp_pitch_side' - assert not host._pause_effects_applied - rows = [json.loads(line) for line in host.raw_path.read_text().splitlines()] - audit = [r for r in rows if r['kind'] == 'task_observation_state'] - assert len(audit) == 1 and audit[0]['moved_units'] == 18 - assert host.execution.motion(host.profile.command.baseline_values).task_key == 'ring_mcp_pitch_side' - finally: - host.close() - - -def test_one_ambiguous_view_cannot_hide_another_changed_reference(): - reports = {'side': dict(status='unresolved', reason=REASON), - 'front': dict(status='unresolved', reason='motion_held_feedback_changed')} - assert not recoverable_task_observation('view=side:joints=pinky_dip:'+REASON, reports) - - -@pytest.mark.parametrize('reason', [ - 'image_reference_current_image_moved', 'feedback_stalled', 'sdk_disconnected', - 'over_temperature', 'joint_zero_unavailable', 'image_motion_solver_timeout', - 'feedback_stalled:image_motion_families_not_distinguishable', -]) -def test_live_reference_or_hardware_failures_cannot_be_deferred(tmp_path, reason): - from runtime_host_fixture import coordinator_fixture - from linkerhand_calibration.runtime.motion_execution import MotionCommand - host, clock = coordinator_fixture(tmp_path, model='o30') - try: - at_task(host.execution.session, TASK) - motion = MotionCommand('joint_zero', host.profile.command.baseline_values, 200, - task_key=TASK, cycle=0, zero_joints=('pinky_mcp_pitch',)) - before = host.execution.session.scheduled_units - assert not host._defer_unobservable_task(motion, reason) - assert host.execution.session.scheduled_units == before - finally: - host.close() - - -def test_resume_persists_budget_without_any_reusable_zero(tmp_path): - from types import SimpleNamespace - from runtime_host_fixture import coordinator_fixture - from linkerhand_calibration.runtime.passed_resume import reference_import - from linkerhand_calibration.runtime.motion_execution import MotionCommand - host, clock = coordinator_fixture(tmp_path, model='o30') - try: - queue = TaskDeferralQueue(host.profile) - first = queue.decide(TASK, 0, REASON, 18).as_record(stamp_ns=1) - host.joint_resume.rows = (first,) - pending_import = reference_import(host.joint_resume) - assert any(row['kind'] == 'task_observation_state' for row in pending_import.rows) - checkpoint = SimpleNamespace(rows=(first,), passed_import=pending_import) - host._restore_taskwise_recovery(checkpoint) - assert not any(row['kind'] == 'task_observation_state' for row in pending_import.rows) - at_task(host.execution.session, TASK) - motion = MotionCommand('joint_zero', host.profile.command.baseline_values, 200, - task_key=TASK, cycle=0, zero_joints=('pinky_mcp_pitch',)) - assert host._defer_unobservable_task(motion, REASON) - rows = [json.loads(line) for line in host.raw_path.read_text().splitlines()] - audit = [r for r in rows if r['kind'] == 'task_observation_state'] - assert [r['attempt'] for r in audit] == [1, 2] - restored = TaskDeferralQueue(host.profile) - restored.restore(audit) - assert (TASK, 0) in restored.unobservable - finally: - host.close() - - -def test_incomplete_task_restart_cannot_reset_a_previously_resolved_budget(profile): - queue = TaskDeferralQueue(profile) - first = queue.decide(TASK, 0, REASON, 18).as_record(stamp_ns=1) - resolved = queue.resolve(TASK, 0).as_record(stamp_ns=2) - # The first training visit passed, but the process stopped before a full task commit. - restored = TaskDeferralQueue(profile) - restored.restore([first, resolved]) - second = restored.decide(TASK, 0, REASON, 18).as_record(stamp_ns=3) - assert second['state'] == 'unobservable' - replay = TaskDeferralQueue(profile) - replay.restore([first, resolved, second]) - assert (TASK, 0) in replay.unobservable - assert (TASK, 0) not in replay.resolved diff --git a/src/linkerhand_calibration/test/test_taskwise_two_round.py b/src/linkerhand_calibration/test/test_taskwise_two_round.py deleted file mode 100644 index cd6de0c..0000000 --- a/src/linkerhand_calibration/test/test_taskwise_two_round.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Fixed task-contiguous collection preserves the existing release boundary.""" - -from itertools import groupby -import json -import xml.etree.ElementTree as ET - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import ( - CapturePlan, TASKWISE_TWO_ROUND, configure_training, select_task_training, -) -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.training import resolve_capture_plan - - -@pytest.fixture -def profile(): - return load_bundled_hand_profile('o30_right_18') - - -def test_every_task_finishes_two_training_rounds_and_validation_before_next(profile): - assert profile.quality.training_policy == TASKWISE_TWO_ROUND - assert not profile.motion.observer_preparations - assert not profile.motion.preparation_witnesses - engine = CalibrationEngine(profile) - units = engine.scan_units() - visits = [key for key, _ in groupby(units, key=lambda u: (u.task_key, u.cycle))] - assert visits == [(t.key, cycle) for t in profile.motion.tasks for cycle in (0, 1, 3)] - assert len(units) == 108 - assert CapturePlan.from_profile(profile).as_dict()['version'] == 'capture_plan_v5' - assert all(t.training == (0, 1) and t.holdout == 3 for t in CapturePlan.from_profile(profile).tasks) - assert engine.capture_schedule_version != CalibrationEngine(configure_training(profile, 'fixed')).capture_schedule_version - - -def test_round_transition_does_not_repeat_task_exit_or_reference_preparation(profile): - execution = SessionExecution(profile) - task = profile.motion.tasks[0] - execution._entered_task = task.key - execution._entered_visit = (task.key, 0) - execution.zero_references.update({name: None for name in task.joints}) - execution.session._unit_index = next(i for i, u in enumerate(execution.session._units) - if u.task_key == task.key and u.cycle == 1) - execution.session.phase = CalibrationPhase.PREPARE - effects = execution._make_effects(profile.command.baseline_values) - assert not any(e.phase in {'task_exit', 'stage_return', 'joint_zero', 'zero_approach'} for e in effects) - assert execution.session.status().task.cycle == 2 - assert execution.session.status().task.cycle_count == 3 - - -def test_partition_replay_rejects_added_round_or_changed_header(profile): - header = dict(kind='session_start', capture_plan=CapturePlan.from_profile(profile).as_dict()) - assert resolve_capture_plan(profile, [header]) == profile - with pytest.raises(ValueError, match='cannot_add_training'): - select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2)) - with pytest.raises(ValueError, match='unexpected_cycle'): - resolve_capture_plan(profile, [header, dict(kind='joint_sample', cycle=2)]) - with pytest.raises(ValueError, match='header_changed'): - resolve_capture_plan(profile, [dict(header, capture_plan={})]) - - -def test_existing_preparation_policy_remains_available_without_stage_reordering(profile, tmp_path): - from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_image_budget - from linkerhand_calibration.profiles.observer_policy import TRAJECTORY_OBSERVER - from linkerhand_calibration.profiles.observer_preparation import configure_observer_preparations, observer_actions - from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - from linkerhand_calibration.profiles.validator import validate_executable_profile - from o30_capture_fixture import SOURCE - prepared = configure_preparation(profile, 'occlusion_aware_trajectory_v3') - prepared = configure_observer_preparations(prepared, UrdfKinematicModel(SOURCE)) - assert prepared.measurement.measurements['pinky_dip'].observer_pose_policy == TRAJECTORY_OBSERVER - assert set(prepared.motion.observer_preparations) == { - 'thumb_ip', 'pinky_dip', 'ring_dip', 'middle_dip', 'index_dip'} - assert observer_actions(prepared, 'pinky_dip')[0].joint == 'pinky_mcp_roll' - validate_executable_profile(prepared, SOURCE) - assert preparation_image_budget(prepared)[0] == 256 - assert CalibrationEngine(prepared).scan_units() == CalibrationEngine(profile).scan_units() - from linkerhand_calibration.profiles.export import write_hand_profile - from linkerhand_calibration.profiles import load_hand_profile - path = tmp_path/'effective_profile.yaml' - write_hand_profile(prepared, path) - assert load_hand_profile(path) == prepared - - -def test_deferral_requires_current_preparation_motion(profile): - from types import SimpleNamespace - from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator - host = SimpleNamespace(profile=profile) - assert CalibrationCoordinator._defer_unobservable_task( - host, None, 'image_motion_families_not_distinguishable') is False - - -@pytest.mark.integration -def test_two_round_formal_json_rebuilds_identical_urdf(tmp_path, o30_capture): - from o30_formal_fixture import formal_capture - from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session - from linkerhand_calibration.runtime.artifacts.urdf_from_json import rebuild_urdf_from_json - from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper - - profile, source, records, hashes, cameras, truth = formal_capture( - o30_capture, tmp_path, training_policy=TASKWISE_TWO_ROUND) - result, fit, artifact = finalize_profile_session(profile=profile, source_urdf=source, - records=records, protected_inputs=hashes, serial_number='SYNTHETIC_FORMAL_O30', - session_dir=tmp_path/'session', standard_loader=ET.parse, - require_motion_evidence=True, camera_extrinsics_file=cameras) - assert result['capture_plan']['policy'] == TASKWISE_TWO_ROUND - assert all(t['training_cycles'] == [0, 1] and t['holdout_cycle'] == 3 - for t in result['capture_plan']['tasks'].values()) - assert len(fit.command_mappings) == 20 - for name, value in truth.items(): - assert fit.zero_offsets_rad[name] == pytest.approx(value, abs=.004) - rebuilt = rebuild_urdf_from_json(artifact.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() == artifact.path.read_bytes() - from linkerhand_calibration.core.urdf.validate import validate_structural_urdf_diff - validate_structural_urdf_diff(source, artifact.path, - authorized_fields=profile.urdf_authorized_fields) - original_joints = {j.get('name'): j for j in ET.parse(source).findall('joint')} - corrected_joints = {j.get('name'): j for j in ET.parse(artifact.path).findall('joint')} - for name in profile.zero.cad_zero_assumptions: - assert fit.zero_offsets_rad[name] == 0.0 - assert fit.zero_method_by_joint[name] == 'assumed_source_cad_zero' - assert corrected_joints[name].find('origin').attrib == original_joints[name].find('origin').attrib - assert set(profile.urdf_authorized_fields[name]) == {'limit.lower', 'limit.upper'} - mapper = load_unified_mapper(artifact.staged_release.calibration_json) - assert len(mapper.map_positions(profile.command.baseline_values)) == 20 - validation = json.loads(artifact.staged_release.validation_json) - assert validation['final_file_image_holdout_verified'] and validation['urdf_rebuilt_from_json'] diff --git a/src/linkerhand_calibration/test/test_three_camera_diagnostics.py b/src/linkerhand_calibration/test/test_three_camera_diagnostics.py deleted file mode 100644 index c72ef46..0000000 --- a/src/linkerhand_calibration/test/test_three_camera_diagnostics.py +++ /dev/null @@ -1,697 +0,0 @@ -import json - -import numpy as np - -from linkerhand_calibration.three_camera_diagnostics import ( - _task_text, - render_three_camera_status_text_zh, - three_camera_reason_zh, -) - - -def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None: - payload = { - "state": "PAUSED", - "reason": "sweep_missing_endpoint_bin", - "progress": 0.1429, - "completed_sweeps": 6, - "total_sweeps": 42, - "active": { - "kind": "sweep", - "view": "front", - "motor_index": 5, - "joints": ["thumb_cmc_roll"], - "cycle": 1, - "repetitions": 3, - "start_u8": 255, - "target_u8": 0, - "actual_u8": 0.4, - "motion_progress": 0.998, - "valid_frames": 239, - "sample": { - "minimum_u8": 0.4, - "maximum_u8": 248.2, - "bin_count": 180, - "minimum_bin_count": 32, - "maximum_bin_gap": 3, - "allowed_maximum_bin_gap": 16, - "missing_endpoint_u8": [255], - "endpoint_tolerance_u8": 2.0, - }, - }, - "views": { - "front": { - "ready": False, - "detection_hz": 30.04, - "valid_rate": 0.0, - "missing_tag_ids": [2], - "pnp_rejections": {}, - "group_pnp_reason": "group_pose_jump", - "pnp_invalid_seconds": 0.8, - "pnp_reset_count": 2, - }, - "side": { - "ready": True, - "detection_hz": 30.02, - "valid_rate": 1.0, - "missing_tag_ids": [], - }, - }, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "标定已暂停(PAUSED)" in text - assert "缺少电机端点255" in text - assert "实际电机范围为0.4~248.2" in text - assert "第1/3轮,255→0" in text - assert "计划扫描6/42个方向,扫描进度14.3%" in text - assert "当前缺失Tag=2" in text - assert "group_pose_jump" not in text - assert "PnP拒绝" not in text - assert "自动重置" not in text - assert "/g20_calibration/resume" in text - - -def test_endpoint_zero_lifecycle_failure_is_not_reported_as_quality() -> None: - payload = { - "state": "PAUSED", - "reason": ( - "validated_endpoint_zero_state_incomplete:" - "missing=index_pip;extra=-" - ), - "active": {}, - "progress": 1.0, - "completed_sweeps": 16, - "total_sweeps": 16, - "views": {}, - } - - text = render_three_camera_status_text_zh(payload) - - assert "程序内部状态生命周期错误" in text - assert "不要移动相机、Tag或机械手底座" in text - - -def test_artifact_failure_explains_that_collection_need_not_restart() -> None: - payload = { - "state": "PAUSED", - "reason": ( - "PUB-ARTIFACT-601:runtime curve exceeds runtime URDF limit " - "for index_mcp_pitch" - ), - "active": {}, - "progress": 1.0, - "completed_sweeps": 16, - "total_sweeps": 16, - "views": {}, - } - - text = render_three_camera_status_text_zh(payload) - - assert "正式发布前" in text - assert "原始URDF未被覆盖" in text - assert "不要重新标定相机或调整Tag" in text - - -def test_combination_prediction_failure_is_not_reported_as_unclassified() -> None: - explanation, action = three_camera_reason_zh( - "PAUSED", "combination_pose_prediction_failed", {} - ) - - assert "多关节组合姿态" in explanation - assert "未分类原因码" not in explanation - assert "combination_validation_failure" in action - assert "不要重新采集16个单关节任务" in action - - -def test_sweep_start_visible_tags_reports_group_pnp_rejection() -> None: - explanation, action = three_camera_reason_zh( - "PAUSED", - "sweep_start_tag_timeout", - { - "group_pnp_reasons": { - "side": "group_initializing:5/8", - }, - "pnp_initialization_progress": { - "side": {"accepted": 5, "required": 8}, - }, - "pnp_rejection_counts": { - "side": { - "ring_pip:no_pose_within_reprojection_or_tilt_limit": 12, - }, - }, - }, - ) - - assert "所需Tag也可见" in explanation - assert "初始化5/8" in explanation - assert "no_pose_within_reprojection_or_tilt_limit×12" in explanation - assert "不要根据可见性重复粘贴" in action - - -def test_mid_sweep_pnp_failure_names_missing_candidate_role() -> None: - explanation, action = three_camera_reason_zh( - "PAUSED", - "synchronised_tag_state_timeout:side", - { - "valid_frames": 150, - "group_pnp_reasons": { - "side": "group_missing_pose_candidates", - }, - "group_missing_candidate_roles": { - "side": ["index_dip"], - }, - "pnp_rejection_counts": { - "side": { - "index_dip:no_pose_within_reprojection_or_tilt_limit": 90, - }, - }, - "pnp_candidate_diagnostics": { - "side": { - "index_dip": { - "solved_candidate_count": 2, - "reprojection_candidate_count": 0, - "independent_tilt_candidate_count": 0, - }, - }, - }, - }, - ) - - assert "已经取得部分有效轨迹" in explanation - assert "缺候选=index_dip" in explanation - assert "solve=2,reproj=0,tilt=0" in explanation - assert "group_pnp_candidate_event" in action - - -def test_multiview_failure_names_the_camera_specific_joint() -> None: - explanation, action = three_camera_reason_zh( - "PAUSED", - "sweep_bin_gap_too_large:pinky_mcp_roll_side", - { - "sample": { - "maximum_bin_gap": 20, - "maximum_bin_gap_start_u8": 100, - "maximum_bin_gap_end_u8": 120, - "allowed_maximum_bin_gap": 16, - } - }, - ) - - assert "小指MCP侧摆(侧面校验)" in explanation - assert "100→120" in explanation - assert "resume" in action - - -def test_preflight_lists_missing_tags_in_chinese() -> None: - payload = { - "state": "PREFLIGHT", - "reason": "waiting_for_three_cameras_tags_and_sdk", - "progress": 0.0, - "completed_sweeps": 0, - "total_sweeps": 0, - "active": {}, - "views": { - "top": { - "ready": False, - "detection_hz": 30.0, - "valid_rate": 0.0, - "missing_tag_ids": [9], - } - }, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "设备和标签预检(PREFLIGHT)" in text - assert "当前缺失Tag=9" in text - assert "全部必需Tag同时有效率0.0%" in text - - -def test_joint_fit_failure_names_metric_and_selective_retry() -> None: - payload = { - "state": "PAUSED", - "reason": "joint_fit_check_failed", - "progress": 18 / 42, - "completed_sweeps": 18, - "total_sweeps": 42, - "active": { - "kind": "fit_failure", - "view": "front", - "motor_index": 6, - "joints": ["index_mcp_roll"], - "attempt": 1, - "directions_to_rescan": 6, - "failures": [ - { - "joint": "index_mcp_roll", - "metric": "arc_deg", - "actual": 3.98, - "limit": 15.0, - "comparison": "minimum", - } - ], - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "食指MCP侧摆的实测圆弧为3.98°" in text - assert "要求至少15.00°" in text - assert "只清除当前失败关节的数据并重扫6个方向" in text - assert "第1次尝试" in text - assert "运动采样:" not in text - - -def test_palm_orientation_failure_explains_thumb_top_coverage() -> None: - explanation, suggestion = three_camera_reason_zh( - "PAUSED", - "palm_orientation_quality_failed", - { - "failures": [ - { - "reason": ( - "palm orientation cycle 2 has 1/2 usable sources: " - "thumb_cmc_roll_top_axis:cycle2=too few samples" - ) - } - ] - }, - ) - - assert "顶部Tag 8/9" in explanation - assert "1/2 usable sources" in explanation - assert "CMC pitch和roll" in suggestion - - -def test_cross_view_side_line_rms_names_side_source() -> None: - payload = { - "state": "PAUSED", - "reason": "joint_fit_check_failed", - "progress": 0.1, - "completed_sweeps": 8, - "total_sweeps": 80, - "active": { - "kind": "fit_failure", - "view": "front", - "motor_index": 9, - "joints": ["pinky_mcp_roll", "pinky_mcp_roll_side"], - "attempt": 1, - "directions_to_rescan": 8, - "failures": [ - { - "joint": "pinky_mcp_roll_side", - "model_joint": "pinky_mcp_roll", - "quality_source_joints": ["pinky_mcp_roll_side"], - "metric": "axis_line_cycle_rms_mm", - "actual": 1.2, - "limit": 1.0, - "comparison": "maximum", - } - ], - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "小指MCP侧摆(侧面校验)的四轮轴线位置RMS为1.20mm" in text - assert "要求不超过1.00mm" in text - - -def test_baseline_hysteresis_failure_shows_values_instead_of_unknown() -> None: - payload = { - "state": "PAUSED", - "reason": "joint_fit_check_failed", - "progress": 0.15, - "completed_sweeps": 24, - "total_sweeps": 160, - "active": { - "kind": "fit_failure", - "view": "front", - "motor_index": 15, - "joints": ["thumb_ip"], - "attempt": 3, - "directions_to_rescan": 6, - "failures": [ - { - "joint": "thumb_ip", - "metric": "baseline_hysteresis_deg", - "actual": 1.34, - "limit": 0.5, - "comparison": "maximum", - "cycle_values_deg": [1.34, 0.04, 0.14], - } - ], - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "baseline正反程关节角差为1.34°" in text - assert "要求不超过0.50°" in text - assert "各轮=1.34°/0.04°/0.14°" in text - assert "未知原因" not in text - - -def test_thumb_yaw_repeatability_failure_names_real_source_retry() -> None: - explanation, suggestion = three_camera_reason_zh( - "PAUSED", - "joint_fit_check_failed", - { - "source_task_names": [ - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - ], - "directions_to_rescan": 16, - "failures": [ - { - "joint": "thumb_cmc_yaw", - "metric": "zero_cycle_offset_range_deg", - "actual": 0.555836, - "limit": 0.5, - "comparison": "maximum", - "cycle": 3, - "cycle_offset_deg": [-2.650924, -2.615277, -2.095088], - } - ], - }, - ) - - assert "训练轮零位极差为0.56°" in explanation - assert "各轮=-2.65°/-2.62°/-2.10°" in explanation - assert "未知原因" not in explanation - assert "CMC pitch/roll顶部轴观测" in suggestion - assert "重扫16个方向" in suggestion - assert "不会无效重扫yaw侧摆" in suggestion - - -def test_sweep_gap_status_names_gap_bounds_and_full_detection_rate() -> None: - payload = { - "state": "PAUSED", - "reason": "sweep_bin_gap_too_large", - "progress": 0.1, - "completed_sweeps": 1, - "total_sweeps": 10, - "active": { - "kind": "sweep", - "view": "top", - "motor_index": 10, - "joints": ["thumb_cmc_yaw"], - "cycle": 1, - "repetitions": 3, - "start_u8": 255, - "target_u8": 0, - "actual_u8": 3.0, - "valid_frames": 610, - "detection_frames": 644, - "detection_valid_frames": 610, - "detection_rate": 610 / 644, - "sample": { - "minimum_u8": 3.0, - "maximum_u8": 252.0, - "maximum_bin_gap": 17, - "maximum_bin_gap_start_u8": 99, - "maximum_bin_gap_end_u8": 116, - "allowed_maximum_bin_gap": 16, - }, - }, - "views": {}, - } - - text = render_three_camera_status_text_zh(payload) - - assert "最大空缺为17,(99→116)" in text - assert "本方向Tag检出:94.7%(610/644帧)" in text - - -def test_zero_model_failure_explains_that_rescan_will_not_help() -> None: - payload = { - "state": "PAUSED", - "reason": "zero_model_validation_failed", - "progress": 0.92, - "scan_progress": 1.0, - "completed_sweeps": 42, - "total_sweeps": 42, - "active": { - "kind": "zero_model_failure", - "view": "front", - "motor_index": 15, - "joints": ["thumb_mcp", "thumb_ip"], - "directions_to_rescan": 0, - "failures": [ - { - "joint": "thumb_mcp", - "metric": "zero_guard", - "reason": "zero_offset_exceeds_configured_limit", - "actual_deg": -39.81, - "limit_deg": 20.0, - } - ], - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "拇指MCP:零位估计超过安全范围" in text - assert "估计-39.81°,允许±20.00°" in text - assert "不会自动重扫" in text - assert "总体进度:92.0%" in text - assert "扫描进度100.0%" in text - assert "运动采样:" not in text - - -def test_sweep_status_shows_localized_fit_retry_cycle() -> None: - active = { - "kind": "sweep", - "view": "front", - "motor_index": 15, - "joints": ["thumb_mcp", "thumb_ip"], - "cycle": 1, - "repetitions": 3, - "start_u8": 255, - "target_u8": 0, - "fit_attempt": 2, - "fit_attempt_limit": 3, - "fit_retry_cycles": [2], - } - - assert "补采异常轮2" in _task_text(active) - - -def test_motor_stall_reason_is_explained_in_chinese() -> None: - reason, action = three_camera_reason_zh( - "PAUSED", - "motor_state_stalled:sweep_motor_19:error_u8=5.000", - {}, - ) - - assert "距目标5.000个u8" in reason - assert "机械端点" in action - assert "不要反复调用resume" in action - - -def test_baseline_stall_names_motor_target_actual_and_tolerance() -> None: - payload = { - "state": "PAUSED", - "reason": ( - "motor_state_stalled:return_baseline:motor_index=10:" - "target_u8=255.0:actual_u8=250.0:tolerance_u8=4.0:" - "timeout_seconds=2.000:" - "error_u8=5.000" - ), - "progress": 0.0, - "scan_progress": 0.0, - "completed_sweeps": 0, - "total_sweeps": 42, - "active": { - "kind": "motion_stall", - "stage": "return_baseline", - "motor_index": 10, - "target_u8": 255.0, - "actual_u8": 250.0, - "error_u8": 5.0, - "tolerance_u8": 4.0, - "timeout_seconds": 2.0, - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "电机10反馈连续2秒" in text - assert "目标255.0、实际250.0、误差5.000 u8" in text - assert "允许容差±4.0 u8" in text - assert "当前任务:电机10运动停滞,目标255.0、实际250.0" in text - assert "运动采样:" not in text - - -def test_return_baseline_prints_the_exact_command() -> None: - baseline = [255] * 20 - baseline[6:10] = [127, 127, 127, 127] - payload = { - "state": "RETURN_BASELINE", - "reason": "return_baseline_before_next_sweep", - "progress": 0.0, - "completed_sweeps": 0, - "total_sweeps": 42, - "baseline_command_u8": baseline, - "active": {}, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "正在恢复目标姿态(RETURN_BASELINE)" in text - assert f"正在确认基准姿态:{baseline}" in text - - -def test_return_recovery_prints_thumb_yaw_start_and_clearance_pose() -> None: - baseline = [255] * 20 - baseline[6:10] = [127, 127, 127, 127] - recovery = list(baseline) - recovery[5] = 145 - recovery[10] = 0 - payload = { - "state": "RETURN_BASELINE", - "reason": "return_baseline_before_retry_sweep", - "progress": 0.5, - "completed_sweeps": 36, - "total_sweeps": 42, - "baseline_command_u8": baseline, - "return_command_u8": recovery, - "active": {}, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert f"正在确认恢复姿态:{recovery}" in text - - -def test_status_numeric_diagnostics_are_json_serializable() -> None: - bins = [0, 16, 255] - payload = { - "state": "SWEEP", - "reason": "collecting_timestamp_synchronised_tag_centres", - "active": { - "sample": { - # np.diff返回NumPy标量;节点必须在放入状态前转成原生int。 - "maximum_bin_gap": int(max(np.diff(bins), default=0)), - } - }, - } - - encoded = json.dumps(payload, ensure_ascii=False) - - assert '"maximum_bin_gap": 239' in encoded - - -def test_urdf_zero_bound_has_specific_chinese_scale_guidance() -> None: - payload = { - "state": "PAUSED", - "reason": ( - "URDF zero offset reached the configured 20.000 degree bound: " - "thumb_cmc_roll=+20.000deg, index_pip=-20.000deg; " - "all_offsets: thumb_cmc_roll=+20.000deg" - ), - "progress": 1.0, - "completed_sweeps": 42, - "total_sweeps": 42, - "active": {}, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "触及±20.000°安全边界" in text - assert "拇指CMC滚转=+20.000deg" in text - assert "食指PIP=-20.000deg" in text - assert "不要调用resume" in text - assert "Tag有效黑框边长" in text - assert "未分类原因码" not in text - - -def test_index_roll_status_prints_clearance_motor_feedback() -> None: - payload = { - "state": "SWEEP", - "reason": "collecting_timestamp_synchronised_tag_centres", - "progress": 0.43, - "completed_sweeps": 18, - "total_sweeps": 42, - "active": { - "kind": "sweep", - "view": "front", - "motor_index": 6, - "joints": ["index_mcp_roll"], - "cycle": 1, - "repetitions": 3, - "automatic_retry_count": 1, - "automatic_retry_limit": 2, - "start_u8": 255, - "target_u8": 0, - "actual_u8": 44.0, - "motion_progress": 0.827, - "valid_frames": 971, - "sample": {"minimum_u8": 40.0, "maximum_u8": 253.0}, - "auxiliary_motors": [ - {"motor_index": 7, "command_u8": 0, "actual_u8": 0.0}, - {"motor_index": 8, "command_u8": 0, "actual_u8": 1.0}, - {"motor_index": 9, "command_u8": 0, "actual_u8": 0.0}, - ], - "speed": { - "commanded_finger_speed": [15, 5, 15, 15, 15], - "reported_finger_speed": [15, 5, 15, 15, 15], - }, - }, - "views": {}, - "result_path": "", - } - - text = render_three_camera_status_text_zh(payload) - - assert "避挡姿态:电机7目标0、实际0.0" in text - assert "电机8目标0、实际1.0" in text - assert "电机9目标0、实际0.0" in text - assert "阶段速度:五指目标[15, 5, 15, 15, 15]" in text - assert "SDK报告[15, 5, 15, 15, 15]" in text - assert "自动重试:当前方向已自动重扫1/2次" in text - - -def test_same_finger_transition_explains_that_clearance_stays_parked() -> None: - explanation, action = three_camera_reason_zh( - "RETURN_BASELINE", - "holding_same_finger_clearance_before_next_task", - {}, - ) - - assert "继续保持当前避让姿态" in explanation - assert "只调整被测关节" in explanation - assert "不要手动展开" in action - - -def test_fixed_base_reference_movement_requires_a_new_session() -> None: - explanation, action = three_camera_reason_zh( - "PAUSED", - "fixed_base_reference_moved", - {}, - ) - - assert "Tag 8" in explanation - assert "基准锁定后" in explanation - assert "下一次标定预检前重新摆放" in action - assert "重新启动新会话" in action diff --git a/src/linkerhand_calibration/test/test_trajectory.py b/src/linkerhand_calibration/test/test_trajectory.py deleted file mode 100644 index ad40ed3..0000000 --- a/src/linkerhand_calibration/test/test_trajectory.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core import ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - PHASE_ROOT, - PHASE_TIP, -) -from linkerhand_calibration.compat.legacy.thumb_core import ( - PAIR_IP, - PAIR_MCP, - PAIR_ROOT, - create_final_payload, -) -from linkerhand_calibration.trajectory import ( - _regularize_coupled_zero_tail, - fit_center_trajectory_curves, - maximum_center_non_target_drift_rad, - measure_center_trajectory_angles, -) - - -def _quat(angle: float) -> list[float]: - return [ - float(value) - for value in Rotation.from_rotvec([0.0, 0.0, angle]).as_quat() - ] - - -def _records( - *, - camera_rotation: Rotation = Rotation.identity(), - camera_translation: np.ndarray = np.zeros(3), - tag_shift: float = 0.0, -) -> list[dict]: - commands = list(range(0, 256, 8)) - if commands[-1] != 255: - commands.append(255) - directions = ( - (DIRECTION_DECREASING, list(reversed(commands))), - (DIRECTION_INCREASING, commands), - ) - t0 = np.asarray([0.0, 0.0, 0.55]) - root_centre = np.asarray([0.025, -0.010, 0.55]) - root_points = { - "t3": np.asarray([0.060 + tag_shift, -0.005, 0.55]), - "t4": np.asarray([0.090, 0.002 + tag_shift, 0.55]), - "t5": np.asarray([0.120, 0.009, 0.55 + tag_shift]), - } - mcp_centre = np.asarray([0.055, -0.004, 0.0]) - t3_tip = np.asarray([0.0, 0.0, 0.55]) - t4_reference = t3_tip + np.asarray([0.080, 0.006 + tag_shift, 0.0]) - ip_centre = t3_tip + np.asarray([0.095, 0.006, 0.0]) - t5_reference = t3_tip + np.asarray([0.125, 0.008 + tag_shift, 0.0]) - - def camera(point: np.ndarray) -> np.ndarray: - return camera_rotation.apply(point) + camera_translation - - records: list[dict] = [] - for phase in (PHASE_ROOT, PHASE_TIP): - for direction, ordered_commands in directions: - for command in ordered_commands: - progress = (255.0 - command) / 255.0 - root_angle = 0.80 * progress if phase == PHASE_ROOT else 0.0 - mcp_angle = 1.15 * progress if phase == PHASE_TIP else 0.0 - ip_angle = 1.02 * mcp_angle if phase == PHASE_TIP else 0.0 - if phase == PHASE_ROOT: - root_rotation = Rotation.from_rotvec( - [0.0, 0.0, root_angle] - ) - positions = { - "t0": t0, - **{ - role: root_centre - + root_rotation.apply(point - root_centre) - for role, point in root_points.items() - }, - } - else: - mcp_rotation = Rotation.from_rotvec( - [0.0, 0.0, mcp_angle] - ) - ip_rotation = Rotation.from_rotvec( - [0.0, 0.0, ip_angle] - ) - # The MCP centre below is expressed relative to T3. - mcp_world = t3_tip + mcp_centre - t4 = mcp_world + mcp_rotation.apply( - t4_reference - mcp_world - ) - ip_at_zero = ip_centre - t5_inside_parent = ip_at_zero + ip_rotation.apply( - t5_reference - ip_at_zero - ) - t5 = mcp_world + mcp_rotation.apply( - t5_inside_parent - mcp_world - ) - positions = { - "t0": t0, - "t3": t3_tip, - "t4": t4, - "t5": t5, - } - records.append( - { - "kind": "sample", - "phase": phase, - "cycle": 0, - "direction": direction, - "command_u8": command, - "relative_quaternion_xyzw": { - PAIR_ROOT: _quat(root_angle), - PAIR_MCP: _quat(mcp_angle), - PAIR_IP: _quat(ip_angle), - }, - "tag_translation_xyz_m": { - role: [ - float(value) for value in camera(point) - ] - for role, point in positions.items() - }, - } - ) - return records - - -def test_centre_trajectory_recovers_three_joint_angles_and_zero() -> None: - records = _records() - fit = fit_center_trajectory_curves( - records, - maximum_plane_rms_m=0.001, - maximum_radial_rms_m=0.001, - maximum_anchor_drift_m=0.001, - ) - assert fit.measurement_mode == "trajectory_center_3d" - assert fit.joints["thumb_cmc_pitch"]["angle_rad"][0] == pytest.approx( - 0.80, abs=2.0e-3 - ) - assert fit.joints["thumb_mcp"]["angle_rad"][0] == pytest.approx( - 1.15, abs=2.0e-3 - ) - assert fit.joints["thumb_ip"]["angle_rad"][0] == pytest.approx( - 1.173, abs=3.0e-3 - ) - for joint in fit.joints.values(): - assert joint["angle_rad"][255] == 0.0 - payload = create_final_payload( - serial_number="G20_LEFT_TRAJECTORY_TEST", - fit=fit, - validation_errors_rad=[0.01, -0.01], - passed=True, - ) - assert payload["zero_command_u8"] == 255 - for joint in payload["joints"].values(): - assert len(joint["angle_rad"]) == 256 - assert joint["angle_rad"][255] == 0.0 - - -def test_centre_trajectory_is_invariant_to_camera_and_tag_offset() -> None: - reference = fit_center_trajectory_curves(_records()) - changed = fit_center_trajectory_curves( - _records( - camera_rotation=Rotation.from_euler( - "xyz", [0.35, -0.25, 0.20] - ), - camera_translation=np.asarray([0.12, -0.04, 0.08]), - tag_shift=0.004, - ) - ) - for joint_name in ("thumb_cmc_pitch", "thumb_mcp", "thumb_ip"): - assert changed.joints[joint_name]["angle_rad"] == pytest.approx( - reference.joints[joint_name]["angle_rad"], - abs=6.0e-3, - ) - - -def test_passive_ip_uses_mimic_constraint_despite_distal_pnp_bias() -> None: - reference = fit_center_trajectory_curves(_records()) - biased_records = _records( - camera_rotation=Rotation.from_euler( - "xyz", [-0.28, 0.31, -0.16] - ), - camera_translation=np.asarray([-0.08, 0.03, 0.11]), - ) - for record in biased_records: - if record["phase"] != PHASE_TIP: - continue - progress = (255.0 - float(record["command_u8"])) / 255.0 - bias = np.asarray( - [ - 0.0012 * np.sin(1.7 * progress), - 0.0008 * progress * progress, - -0.0006 * np.sin(2.3 * progress), - ] - ) - record["tag_translation_xyz_m"]["t5"] = [ - float(value) - for value in ( - np.asarray( - record["tag_translation_xyz_m"]["t5"], dtype=float - ) - + bias - ) - ] - - biased = fit_center_trajectory_curves(biased_records) - for fit in (reference, biased): - mcp = np.asarray(fit.joints["thumb_mcp"]["angle_rad"]) - ip = np.asarray(fit.joints["thumb_ip"]["angle_rad"]) - assert ip == pytest.approx(1.02 * mcp, abs=1.1e-8) - assert fit.ip_coupling["multiplier"] == pytest.approx(1.02) - assert fit.ip_coupling["offset_rad"] == 0.0 - assert fit.ip_coupling["constrained_r_squared"] == 1.0 - assert fit.ip_coupling["r_squared"] > 0.98 - - assert biased.joints["thumb_ip"]["angle_rad"] == pytest.approx( - reference.joints["thumb_ip"]["angle_rad"], - abs=6.0e-3, - ) - assert ( - biased.trajectory_quality["tip"][ - "ip_observed_vs_constrained_max_rad" - ] - > 0.0 - ) - - -def test_passive_ip_multiplier_is_configurable() -> None: - fit = fit_center_trajectory_curves( - _records(), - passive_ip_multiplier=0.97, - ) - mcp = np.asarray(fit.joints["thumb_mcp"]["angle_rad"]) - ip = np.asarray(fit.joints["thumb_ip"]["angle_rad"]) - assert ip == pytest.approx(0.97 * mcp, abs=1.1e-8) - - -def test_static_measurement_uses_fitted_serial_tip_model() -> None: - records = _records() - fit = fit_center_trajectory_curves(records) - command = 128 - record = next( - item - for item in records - if item["phase"] == PHASE_TIP - and item["direction"] == DIRECTION_DECREASING - and item["command_u8"] == command - ) - measured = measure_center_trajectory_angles( - fit.trajectory_models, - record["tag_translation_xyz_m"], - ) - progress = (255.0 - command) / 255.0 - assert measured["thumb_mcp"] == pytest.approx( - 1.15 * progress, abs=2.0e-3 - ) - assert measured["thumb_ip"] == pytest.approx( - 1.02 * 1.15 * progress, abs=3.0e-3 - ) - - -def test_non_target_drift_is_measured_without_tag_orientations() -> None: - records = _records() - fit = fit_center_trajectory_curves(records) - assert maximum_center_non_target_drift_rad( - records, fit.trajectory_models - ) == pytest.approx(0.0, abs=3.0e-3) - - -def test_short_ip_zero_tail_uses_coupled_mcp_shape() -> None: - mcp = np.linspace(1.0, 0.0, 256) - ip = 0.6 * mcp - ip[248:] = 0.0 - - regularized = _regularize_coupled_zero_tail(ip, mcp) - - assert regularized[:248] == pytest.approx(ip[:248]) - assert np.all(regularized[248:255] > 0.0) - assert np.all(np.diff(regularized) <= 1.0e-12) - assert regularized[255] == 0.0 - assert regularized[248:255] == pytest.approx( - 0.6 * mcp[248:255] - ) - - -def test_long_or_unresolved_ip_zero_tail_is_not_invented() -> None: - mcp = np.linspace(1.0, 0.0, 256) - ip = 0.6 * mcp - ip[220:] = 0.0 - - regularized = _regularize_coupled_zero_tail(ip, mcp) - - assert regularized == pytest.approx(ip) diff --git a/src/linkerhand_calibration/test/test_trajectory_observation_v3.py b/src/linkerhand_calibration/test/test_trajectory_observation_v3.py deleted file mode 100644 index 3dea0f9..0000000 --- a/src/linkerhand_calibration/test/test_trajectory_observation_v3.py +++ /dev/null @@ -1,207 +0,0 @@ -"""v3 trajectory observation, bounded deferral and publication refusal.""" - -import hashlib -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import ( - STAGED, TASKWISE_STAGED, configure_training, select_task_training, -) -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.observer_policy import TRAJECTORY_OBSERVER -from linkerhand_calibration.profiles.observer_policy import configure_observer_policy -from linkerhand_calibration.profiles.observer_preparation import configure_observer_preparations -from linkerhand_calibration.profiles.export import dump_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract -from linkerhand_calibration.runtime.artifacts.capture_validation import validate_capture_units -from linkerhand_calibration.runtime.session import CalibrationPhase, CalibrationSession -from linkerhand_calibration.runtime.checkpoint_observer_upgrade import ObserverPolicyUpgrade -from linkerhand_calibration.runtime.task_deferral import ( - POLICY, TaskDeferralQueue, TaskObservationState, deferred_visit_keys, - dependency_closure, recoverable_observation_reason, -) -from o30_capture_fixture import SOURCE - - -@pytest.fixture -def trajectory_profile(): - profile = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - profile = configure_preparation(profile, 'occlusion_aware_trajectory_v3') - return configure_observer_preparations(profile, UrdfKinematicModel(SOURCE)) - - -def test_v3_is_a_versioned_profile_contract_with_two_action_bound(trajectory_profile): - policy = preparation_contract(trajectory_profile)['observer_preparations'] - assert policy['policy'] == 'occlusion_aware_trajectory_v3' - assert policy['trajectory_solver']['holdout_selects_model'] is False - assert policy['trajectory_solver']['maximum_actions_per_task'] == 2 - assert all(1 <= len(plan.actions) <= 2 - for plan in trajectory_profile.motion.observer_preparations.values()) - assert trajectory_profile.measurement.measurements['index_dip'].observer_pose_policy \ - == TRAJECTORY_OBSERVER - - -def test_visual_ambiguity_is_deferred_but_hardware_failure_is_not(): - assert recoverable_observation_reason('image_motion_frame_angles_ambiguous') - assert recoverable_observation_reason('motion_candidates_missing') - assert not recoverable_observation_reason('sdk_disconnected') - assert not recoverable_observation_reason('feedback_stalled') - assert not recoverable_observation_reason('over_temperature') - - -def test_parent_deferral_moves_dependency_closure_to_stage_end(trajectory_profile): - assert 'index_dip_side' in dependency_closure(trajectory_profile, 'index_pip_side') - session = CalibrationSession(trajectory_profile) - session.phase = CalibrationPhase.PREPARE - session._unit_index = next(i for i, unit in enumerate(session._units) - if unit.task_key == 'index_pip_side' and unit.cycle == 0) - original_prefix = tuple(unit.identity for unit in session._units[:session._unit_index]) - decision = session.defer_current_task('rigid_pair_candidates_unresolved') - assert decision.state == TaskObservationState.PENDING - assert {'index_pip_side', 'index_dip_side'} <= set(decision.affected_tasks) - record = decision.as_record(stamp_ns=1) - assert record['dependency_states']['index_dip_side'] == 'dependency_pending' - assert tuple(unit.identity for unit in session._units[:session._unit_index]) == original_prefix - same_cycle = [unit.task_key for unit in session._units if unit.cycle == 0] - assert same_cycle[-1] == 'index_dip_side' - - -def test_staged_assessment_waits_for_deferred_visit(trajectory_profile): - from linkerhand_calibration.runtime.execution import SessionExecution - execution = SessionExecution(trajectory_profile) - session = execution.session - session.phase = CalibrationPhase.PREPARE - session._unit_index = next(i for i, unit in enumerate(session._units) - if unit.task_key == 'index_pip_side' and unit.cycle == 1) - session.defer_current_task('rigid_pair_candidates_unresolved') - cycle = [unit for unit in session.scheduled_units if unit.cycle == 1] - assert cycle[-1].task_key == 'index_dip_side' - session._unit_index = session._units.index(cycle[-2]) - assert not execution.training_check_due() - session._unit_index = session._units.index(cycle[-1]) - assert execution.training_check_due() - - -def test_second_failure_is_terminal_and_budget_survives_restart(trajectory_profile): - queue = TaskDeferralQueue(trajectory_profile) - first = queue.decide('index_dip_side', 0, 'rigid_pair_candidates_unresolved', 2) - records = [first.as_record(stamp_ns=1, corner_evidence_sha256='a'*64)] - restored = TaskDeferralQueue(trajectory_profile) - restored.restore(records) - second = restored.decide('index_dip_side', 0, - 'rigid_pair_candidates_unresolved', 2) - assert second.state == TaskObservationState.UNOBSERVABLE - records.append(second.as_record(stamp_ns=2, corner_evidence_sha256='b'*64)) - again = TaskDeferralQueue(trajectory_profile) - again.restore(records) - assert again.attempts[('index_dip_side', 0)] == 2 - assert ('index_dip_side', 0) in again.unobservable - with pytest.raises(ValueError, match='budget_exhausted'): - again.decide('index_dip_side', 0, 'rigid_pair_candidates_unresolved', 1) - with pytest.raises(ValueError, match='budget_changed'): - again.restore([first.as_record(stamp_ns=3)]) - - -def test_restart_restores_pending_visit_at_end_of_taskwise_stage(trajectory_profile): - profile = configure_training(trajectory_profile, TASKWISE_STAGED) - profile = select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2)) - queue = TaskDeferralQueue(profile) - pending = queue.decide('index_dip_side', 0, - 'image_motion_no_observable_image_model', 2).as_record(stamp_ns=1) - session = CalibrationSession(profile) - session.restore_task_deferrals([pending]) - first_stage = [unit for unit in session.scheduled_units if unit.cycle == 0] - assert first_stage[-1].task_key == 'index_dip_side' - assert session.task_deferrals.attempts[('index_dip_side', 0)] == 1 - - # The same rule treats cycle 1 and optional cycle 2 as one training stage, - # so a restored revisit cannot jump ahead of later training or into holdout. - queue = TaskDeferralQueue(profile) - pending = queue.decide('index_dip_side', 1, - 'image_motion_no_observable_image_model', 2).as_record(stamp_ns=2) - session = CalibrationSession(profile) - session.restore_task_deferrals([pending]) - training_stage = [unit for unit in session.scheduled_units if unit.cycle in {1, 2}] - assert training_stage[-1].task_key == 'index_dip_side' - assert session.scheduled_units[session.scheduled_units.index(training_stage[-1]) + 1].cycle == 3 - - -def test_deferral_audit_allows_only_declared_noncontiguous_resume_gap(trajectory_profile): - queue = TaskDeferralQueue(trajectory_profile) - pending = queue.decide('index_pip_side', 1, - 'rigid_pair_candidates_unresolved', 4).as_record(stamp_ns=1) - keys = deferred_visit_keys(trajectory_profile, [pending]) - assert ('index_pip_side', 1) in keys - assert ('index_dip_side', 1) in keys - changed = {**pending, 'affected_tasks': ['index_pip_side']} - with pytest.raises(ValueError, match='dependencies_changed'): - deferred_visit_keys(trajectory_profile, [changed]) - - -def test_full_visit_commit_closes_crash_window_but_partial_visit_does_not(trajectory_profile): - from linkerhand_calibration.runtime.engine import CalibrationEngine - queue = TaskDeferralQueue(trajectory_profile) - pending = queue.decide('index_dip_side', 1, - 'rigid_pair_candidates_unresolved', 2).as_record(stamp_ns=1) - units = [unit for unit in CalibrationEngine(trajectory_profile).scan_units() - if (unit.task_key, unit.cycle) == ('index_dip_side', 1)] - completed = [dict(kind='scan_unit_complete', task_name=unit.task_key, - cycle=unit.cycle, direction=unit.direction, segment_key=unit.segment_key, - passed=True) for unit in units] - partial = TaskDeferralQueue(trajectory_profile) - partial.restore([pending, *completed[:-1]]) - assert ('index_dip_side', 1) not in partial.resolved - restored = TaskDeferralQueue(trajectory_profile) - restored.restore([pending, *completed]) - assert ('index_dip_side', 1) in restored.inferred_resolutions - - -def test_unobservable_record_blocks_release_before_fitting(trajectory_profile): - queue = TaskDeferralQueue(trajectory_profile) - first = queue.decide('index_dip_side', 0, - 'rigid_pair_candidates_unresolved', 2).as_record(stamp_ns=1) - row = queue.decide('index_dip_side', 0, - 'rigid_pair_candidates_unresolved', 2).as_record(stamp_ns=2) - with pytest.raises(ValueError, match='capture_unobservable_tasks:index_dip_side:0:unobservable'): - validate_capture_units(trajectory_profile, [first, row]) - - -def test_resolved_revisit_clears_release_blocker_state(trajectory_profile): - queue = TaskDeferralQueue(trajectory_profile) - first = queue.decide('index_dip_side', 0, 'rigid_pair_candidates_unresolved', 2) - passed = queue.resolve('index_dip_side', 0) - assert passed is not None and passed.state == TaskObservationState.PASSED - restored = TaskDeferralQueue(trajectory_profile) - restored.restore([first.as_record(stamp_ns=1), passed.as_record(stamp_ns=2)]) - assert ('index_dip_side', 0) in restored.resolved - assert not restored.unobservable - - -def test_v3_checkpoint_upgrade_preserves_accepted_joint_semantics(): - base = configure_training(load_bundled_hand_profile('o30_right_18'), STAGED) - source = configure_preparation(base, 'occlusion_aware_witness_v2') - source = configure_observer_preparations(source, UrdfKinematicModel(SOURCE)) - preserved = set(source.measurement.measurements)-{'index_dip'} - target = configure_observer_policy(source, preserved_joints=preserved, - policy=TRAJECTORY_OBSERVER) - source_yaml, target_yaml = (dump_hand_profile(profile).encode() - for profile in (source, target)) - upgrade = ObserverPolicyUpgrade(source, target, source_yaml, target_yaml, - source_urdf=SOURCE) - inputs = {'profile_config_sha256': hashlib.sha256(target_yaml).hexdigest(), - 'source_urdf_sha256': 'a'*64} - old_inputs = {**inputs, 'profile_config_sha256': hashlib.sha256(source_yaml).hexdigest()} - header = {'kind': 'session_start', **old_inputs, - 'preparation_contract': preparation_contract(source)} - reference = {'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=set())) - _, _, audit = upgrade.metadata(checkpoint, inputs, 'source.jsonl', 'c'*64) - assert audit['policy'] == 'unmeasured_observer_upgrade_v3' - assert audit['preserved_joints'] == sorted(preserved) - assert audit['observer_policy_changes'] == { - 'index_dip': {'previous': 'rigid_pair_candidates_v2', 'current': TRAJECTORY_OBSERVER}} diff --git a/src/linkerhand_calibration/test/test_trajectory_policy.py b/src/linkerhand_calibration/test/test_trajectory_policy.py deleted file mode 100644 index 9d67457..0000000 --- a/src/linkerhand_calibration/test/test_trajectory_policy.py +++ /dev/null @@ -1,39 +0,0 @@ -from linkerhand_calibration.runtime import avoidance_arrival, smoothstep_position - - -def test_return_sweep_prepares_its_own_start_not_the_forward_start(): - from linkerhand_calibration.profiles.loader import load_bundled_hand_profile - from linkerhand_calibration.runtime.engine import CalibrationEngine - from linkerhand_calibration.runtime.trajectory import build_calibration_preparation_waypoints - profile = load_bundled_hand_profile("o12_right_16") - task = profile.motion.tasks[0] - reverse = CalibrationEngine(profile).scan_units()[1] - waypoints = build_calibration_preparation_waypoints(task, profile=profile, start_value=reverse.start) - assert waypoints[-1][task.command_index] == task.end_value - - -def test_cosine_trajectory_has_exact_endpoints() -> None: - assert smoothstep_position(0.0, 1.0, 0.0) == 0.0 - assert smoothstep_position(0.0, 1.0, 1.0) == 1.0 - - -def test_avoidance_accepts_stable_eighty_percent_without_exact_tracking() -> None: - result = avoidance_arrival( - start_feedback=0.0, - requested_target=1.5, - feedback_history=(1.20, 1.205, 1.21, 1.205), - trajectory_complete=True, - ) - assert result.arrived - assert result.progress_01 >= 0.8 - - -def test_avoidance_does_not_accept_wrong_direction() -> None: - result = avoidance_arrival( - start_feedback=0.0, - requested_target=1.5, - feedback_history=(-0.3, -0.31, -0.30), - trajectory_complete=True, - ) - assert not result.arrived - assert not result.direction_correct diff --git a/src/linkerhand_calibration/test/test_transfer_artifact_contract.py b/src/linkerhand_calibration/test/test_transfer_artifact_contract.py deleted file mode 100644 index 2524fea..0000000 --- a/src/linkerhand_calibration/test/test_transfer_artifact_contract.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Synthetic transfer artifacts retain provenance without recipient evidence.""" - -from copy import deepcopy -from dataclasses import replace -import hashlib -import json -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.domain.measurement import JointCurveFit -from linkerhand_calibration.core.domain.reference import JointZeroPose, JointZeroReference, JointZeroSample -from linkerhand_calibration.core.domain.result import JointMapping -from linkerhand_calibration.core.fitting.coupling import LinearMimicFit -from linkerhand_calibration.core.fitting.mimic_approximation import MimicApproximation -from linkerhand_calibration.core.fitting.tag_installation import FrozenTagInstallation, matrix_tuple -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.core.urdf.measured_acceptance import validate_measured_joint_holdout -from linkerhand_calibration.core.urdf.tag_acceptance import TagHoldout -from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher, FrozenTagArtifactValidator -from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper -from linkerhand_calibration.runtime.artifacts.serializers.measured_v3 import from_report, serialize_report - - -@pytest.fixture -def transfer_artifacts(tmp_path): - """Two synthetic chains; only source/source_tip have observation evidence.""" - session = tmp_path / "session" - session.mkdir() - source = tmp_path / "source.urdf" - parts = [''] - for name, parent, xyz, mimic in ( - ("source", "base", "0 0 0", None), ("source_tip", "source_link", ".04 0 0", "source"), - ("target", "base", "0 .1 0", None), ("target_tip", "target_link", ".04 0 0", "target")): - parts.append(f'' - f'' - '' - + (f'' if mimic else '') + '') - source.write_text(''.join(parts) + '') - urdf = session / "corrected.urdf" - urdf.write_bytes(source.read_bytes()) - model = UrdfKinematicModel(source) - transfers = {"target": "source", "target_tip": "source_tip"} - assumptions = {name: "synthetic accepted CAD baseline" for name in ("source_tip", "target_tip")} - profile = SimpleNamespace(key=SimpleNamespace(profile_id="VIRTUAL/right/transfers/v1", model="VIRTUAL", side="right"), - command_based_release=False, vision_motion=False, retained_joints=frozenset(), - command=SimpleNamespace(unit="u8", names=("source", "target"), baseline_values=(0, 0), - feedback_by_index=True, feedback_name_aliases={}), - measurement=SimpleNamespace(transferred_motion_sources=transfers, release_basis="feedback_and_command"), - artifacts=SimpleNamespace(directional_command_joints=frozenset()), - acquisition=SimpleNamespace(command_capture_mode="interleaved", motion_observation="feedback", - fixed_reference_mode="tag_pose"), - zero=SimpleNamespace(known_baseline_geometry={}, cad_zero_assumptions=assumptions, - transferred_zero_sources={"target": "source"})) - knots = tuple(float(i) for i in range(0, 256, 51)) - curves, mappings, commands, references = {}, {}, {}, {} - identity = JointZeroPose((0, 0, 0, 1), (0, 0, 0)) - for name in ("source", "source_tip"): - x = np.asarray(knots) / 255 - angles = tuple(x if name == "source" else .2*x + .4*x*x) - mappings[name] = JointMapping(name, 0, "feedback_u8", knots, angles, angles, angles) - commands[name] = replace(mappings[name], input_domain="command_u8") - curves[name] = JointCurveFit(angles, angles, angles, { - "space": "relative_rotation_3d", "reference_quaternion_xyzw": (0, 0, 0, 1), - "axis_xyz": (0, 0, 1), "training_sample_ids": (f"training:{name}",)}, 0, 0, {}) - sample = JointZeroSample("side:1", "side", 1, (0, 0, 0, 1), (0, 0, 0), - (0, 0), (0, 0), ("decreasing", "decreasing"), identity, identity) - references[name] = JointZeroReference(name, "source_scan", 0, 0, 1, 1, (sample,)) - for target, donor in transfers.items(): - mappings[target] = replace(mappings[donor], joint=target, motor_index=1, transferred_from=donor) - commands[target] = replace(commands[donor], joint=target, motor_index=1, transferred_from=donor) - methods = {"source": "urdf_serial_axis_geometry", "source_tip": "assumed_source_cad_zero", - "target": "transferred_static_zero_on_own_cad", "target_tip": "assumed_source_cad_zero"} - mimic_output = {"source_tip": ("source", .6, 0), "target_tip": ("target", .6, 0)} - approximations = {} - for child, (parent, multiplier, offset) in mimic_output.items(): - approximate = LinearMimicFit(parent, child, multiplier, offset, True, (0, 1, 2), (0, -.1, 0)) - approximations[child] = MimicApproximation(approximate, ("training:source_tip",), ("held:source_tip",), - (0, -.1, 0), transferred_from_joint=transfers.get(child)) - fit = SimpleNamespace(curves=curves, output_mappings=mappings, command_mappings=commands, - zero_offsets_rad={name: 0 for name in mappings}, zero_method_by_joint=methods, - joint_zero_references=references, mimic_approximations=approximations, - command_applicability={name: {"scope": "single_channel_at_declared_pose"} for name in curves}, - command_holdout_metrics={name: {"independently_measured": True} for name in curves}) - prepared = SimpleNamespace(mappings=mappings, plan=SimpleNamespace(mimic_output=mimic_output)) - hashes = {"source_urdf_sha256": hashlib.sha256(source.read_bytes()).hexdigest()} - report = serialize_report(profile, fit, prepared, serial_number="SYNTHETIC_ONLY", protected_inputs=hashes) - payload = from_report(report) - observations, poses = [], [] - for name in curves: - for direction in ("increasing", "decreasing"): - for index, value in enumerate(knots): - angles = {joint: mapping.evaluate(value if mapping.motor_index == 0 else 0) - for joint, mapping in commands.items()} - image = f"holdout:{direction}:{index}" - observations.append({"joint": name, "sample_id": image, "cycle": 3, - "sample_phase": "steady", "direction": direction, "command_vector_u8": (value, 0), - "relative_quaternion_xyzw": Rotation.from_rotvec([0, 0, angles[name]]).as_quat().tolist()}) - transform = model.link_transform(name, zero_offsets={}, joint_angles=angles, independent_mimic_angles=True) - poses.append(TagHoldout(image, name, 3, (value, 0), {name: direction}, matrix_tuple(transform))) - mounts = {name: FrozenTagInstallation(name, name+"_link", matrix_tuple(np.eye(4)), - (f"training:{name}",), 0, 0) for name in curves} - validator = FrozenTagArtifactValidator(profile.key.profile_id, source, hashes["source_urdf_sha256"], - {}, fit.zero_offsets_rad, matrix_tuple(np.eye(4)), mounts, tuple(poses), tuple(curves), lambda _: None, - minimum_samples=3, protected_inputs=hashes, command_observations=tuple(poses), - joint_observations=tuple(observations), joint_curves=curves, - joint_channels={name: mapping.motor_index for name, mapping in mappings.items()}, - transferred_motion_sources=transfers, cad_zero_assumptions=assumptions, - transferred_zero_sources={"target": "source"}, joint_zero_methods=methods) - json_path = session / "calibration.json" - json_path.write_text(json.dumps(payload)) - (session / "calibration_report.json").write_text(json.dumps(report)) - return SimpleNamespace(profile=profile, report=report, payload=payload, fit=fit, validator=validator, - json_path=json_path, urdf=urdf, observations=observations, transfers=transfers) - - -def test_mixed_release_preserves_recipient_channels_without_claiming_recipient_precision(transfer_artifacts): - data = transfer_artifacts - release = ArtifactPublisher(data.json_path.parent.parent, "latest").publish( - session_directory=data.json_path.parent, calibration_json=data.json_path, - corrected_urdf=data.urdf, validate=data.validator) - validation = json.loads(release.manifest.read_text())["validation"] - assert validation["acceptance"] == "measured_and_transferred_json_urdf_v3" - assert set(validation["independent_mapping_holdout"]) == {"source", "source_tip"} - assert set(validation["full_json_urdf_holdout"]) == {"source", "source_tip"} - assert validation["transferred_joints"] == ["target", "target_tip"] - assert max(row["maximum_deg"] for row in validation["urdf_mimic_approximation"].values()) > 3 - mapper = load_unified_mapper(release.manifest) - positions = dict(zip(mapper.urdf_joint_names, mapper.map_positions((0, 102)))) - assert positions["source"] == 0 - assert positions["target"] == pytest.approx(.4) - assert positions["target_tip"] == pytest.approx(.144) - assert mapper.transferred_motion_sources == data.transfers - for target, donor in data.transfers.items(): - assert data.payload["joints"][target]["angle_rad"] == data.payload["joints"][donor]["angle_rad"] - row = data.report["joints"][target] - assert row["independently_measured"] is False - assert "zero_reference" not in row and "command_holdout" not in row - assert row["zero_reference_source_joint"] == donor - - -@pytest.mark.parametrize("corruption", ["undeclared", "pretend_measured", "invent_reference", "changed_curve", "feedback_channel", "wrong_baseline", "chained_source", "invent_mimic_samples"]) -def test_reader_rejects_unfaithful_transfer_reports(transfer_artifacts, corruption): - report = deepcopy(transfer_artifacts.report) - target = report["joints"]["target"] - if corruption == "undeclared": - report.pop("transferred_motion_sources") - elif corruption == "pretend_measured": - target["independently_measured"] = True - elif corruption == "invent_reference": - target["zero_reference"] = deepcopy(report["joints"]["source"]["zero_reference"]) - elif corruption == "changed_curve": - target["command_to_rad"]["angle_rad"][1] += .001 - elif corruption == "feedback_channel": - target["feedback_to_rad"]["channel_index"] = 0 - elif corruption == "wrong_baseline": - report["baseline_command"][1] = 1 - elif corruption == "chained_source": - report["transferred_motion_sources"]["target"] = "target_tip" - else: - report["joints"]["target_tip"]["mimic_approximation"]["evidence"]["training_sample_ids"] = ["invented:recipient"] - with pytest.raises(ValueError): - from_report(report) - - -def test_publisher_binds_report_transfers_to_authoritative_profile(transfer_artifacts): - data = transfer_artifacts - with pytest.raises(ValueError, match="transfer sources differ"): - replace(data.validator, transferred_motion_sources={})(data.json_path, data.urdf) - with pytest.raises(ValueError, match="CAD zero assumptions differ"): - replace(data.validator, cad_zero_assumptions={})(data.json_path, data.urdf) - - -def test_publisher_rejects_consistently_rewritten_recipient_channel(transfer_artifacts): - data = transfer_artifacts - report = deepcopy(data.report) - for kind in ("command_to_rad", "feedback_to_rad"): - report["joints"]["target"][kind]["channel_index"] = 0 - # The file pair is internally consistent, but its channel is not the one - # declared by the actual Profile supplied independently to the publisher. - data.json_path.write_text(json.dumps(from_report(report))) - (data.json_path.parent / "calibration_report.json").write_text(json.dumps(report)) - with pytest.raises(ValueError, match="SDK channel bindings differ"): - data.validator(data.json_path, data.urdf) - - -def test_reader_rejects_cad_assumption_relabelled_as_independent_geometry(transfer_artifacts): - report = deepcopy(transfer_artifacts.report) - report["joints"]["target_tip"]["zero_method"] = "independent_known_baseline_geometry" - with pytest.raises(ValueError, match="assumed_cad_zero_provenance"): - from_report(report) - - -def test_transferred_native_rad_tables_preserve_target_channel_and_support(transfer_artifacts): - from linkerhand_calibration.runtime.artifacts.reader import UnifiedCommandMapper - data = transfer_artifacts - report = deepcopy(data.report) - report["command_unit"] = "rad" - for row in report["joints"].values(): - for kind in ("command", "feedback"): - mapping = row[kind+"_to_rad"] - mapping.update(input_domain=kind+"_rad", input_unit="rad", - knots=[value/255 for value in mapping["knots"]], valid_input_range=[0, 1]) - payload = from_report(report) - mapper = UnifiedCommandMapper(payload, data.urdf, report=report) - values = dict(zip(mapper.urdf_joint_names, mapper.map_positions((0, .4)))) - assert values["source"] == 0 - assert values["target"] == pytest.approx(.4) - assert values["target_tip"] == pytest.approx(.144) - assert payload["joints"]["target"]["input_values"] == payload["joints"]["source"]["input_values"] - with pytest.raises(ValueError, match="outside"): - mapper.map_positions((0, 1.01)) - - -def test_holdout_never_credits_copied_or_fabricated_recipient_observations(transfer_artifacts): - data = transfer_artifacts - result = validate_measured_joint_holdout(data.payload, data.fit.curves, data.observations, - transferred_motion_sources=data.transfers) - assert set(result) == {"source", "source_tip"} - with pytest.raises(ValueError, match="wrong partition or joint"): - validate_measured_joint_holdout(data.payload, data.fit.curves, - data.observations+[dict(data.observations[0], joint="target")], transferred_motion_sources=data.transfers) - wrong = deepcopy(data.payload) - wrong["joints"]["target"]["angle_rad"][102] += .001 - with pytest.raises(ValueError, match="transferred_final_table"): - validate_measured_joint_holdout(wrong, data.fit.curves, data.observations, transferred_motion_sources=data.transfers) - - -def test_recipient_limit_violation_is_rejected_instead_of_clipping(transfer_artifacts): - data = transfer_artifacts - import xml.etree.ElementTree as ET - root = ET.fromstring(data.urdf.read_text()) - root.find("joint[@name='target']/limit").set("upper", ".3") - data.urdf.write_text(ET.tostring(root, encoding="unicode")) - validator = replace(data.validator, authorized_fields={"target": ("limit.upper",)}) - with pytest.raises(ValueError, match="range|limit"): - validator(data.json_path, data.urdf) diff --git a/src/linkerhand_calibration/test/test_transferred_hinge_geometry.py b/src/linkerhand_calibration/test/test_transferred_hinge_geometry.py deleted file mode 100644 index 12bbefd..0000000 --- a/src/linkerhand_calibration/test/test_transferred_hinge_geometry.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Independent motion images distinguish geometry only with bound source evidence.""" - -from copy import deepcopy -from dataclasses import asdict, replace -import gzip -import json -from pathlib import Path - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.core.geometry.image_motion_replay import image_model_sha256 -from linkerhand_calibration.core.geometry.tag_pose.cad_hinge import ParallelAxisGeometry -from linkerhand_calibration.core.geometry.tag_pose.source_hinge import SourceHinge -from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import ( - resolve_image_motion, image_motion_model_from_dict, select_image_motion_frame, -) -from linkerhand_calibration.core.geometry.tag_pose.motion_evidence import MotionRelation -from linkerhand_calibration.runtime.parent_reference_evidence import validate_source_hinges -from test_production_image_motion import recorded_image_frames -from test_cad_image_geometry import synthetic_frames - - -def source_from(result): - winner = next(h for h in result.hypotheses if h.branches == result.model.branches) - _, axis, point = winner.child_frame_uncertainty[0] - return SourceHinge(result.model.geometry[0], "a"*64, image_model_sha256(asdict(result.model)), axis, point) - - -@pytest.fixture(scope="module") -def recorded_transfer(): - payload = json.loads(gzip.decompress((Path(__file__).parent/ - "fixtures/o30_transferred_ip_geometry.json.gz").read_bytes())) - sources, parent_relations = payload["parent"], tuple(MotionRelation(**r) for r in payload["parent"]["relations"]) - parent = resolve_image_motion(recorded_image_frames(sources["frames"]), parent_relations) - assert parent.resolved, parent.reason - source = source_from(parent) - frames = recorded_image_frames(payload["child"]["frames"]) - relations = tuple(MotionRelation(**r) for r in payload["child"]["relations"]) - constraint = ParallelAxisGeometry("thumb_mcp", "thumb_ip", 1, .048556745401643224) - options = dict(geometry_constraints=(constraint,), source_urdf_sha256=payload["source_urdf_sha256"], - source_hinges=(source,)) - result = resolve_image_motion(frames, relations, **options) - return parent, frames, relations, options, result - - -def test_recorded_ip_ambiguity_resolves_without_relaxing_gates(recorded_transfer): - _, frames, relations, _, result = recorded_transfer - free = resolve_image_motion(frames, relations) - assert not free.resolved and free.reason == "image_motion_families_not_distinguishable" - assert result.resolved, result.reason - 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) - winner = next(h for h in result.hypotheses if h.branches == result.model.branches) - assert max(p for _, p in winner.comparison_p_values) < .01 - assert winner.axis_uncertainty_95_rad[0][1] >= result.model.source_hinges[0].child_axis_uncertainty_rad - assert image_motion_model_from_dict(asdict(result.model)) == result.model - - -def test_uncertain_source_cannot_be_made_exact_by_eliminating_axis_parameters(recorded_transfer): - _, frames, relations, options, _ = recorded_transfer - invalid = replace(options["source_hinges"][0], child_axis_uncertainty_rad=.2) - result = resolve_image_motion(frames, relations, **{**options, "source_hinges": (invalid,)}) - assert not result.resolved - assert any(h.reason == "image_motion_geometry_uncertain" for h in result.hypotheses) - result = resolve_image_motion(frames, relations, source_hinges=(invalid,)) - assert result.reason == "image_motion_source_hinge_requires_geometry" - - -@pytest.mark.parametrize("change", ["missing", "axis", "uncertainty", "hash", "epoch", "late"]) -def test_local_geometry_requires_the_actual_earlier_source(recorded_transfer, change): - parent, _, _, _, result = recorded_transfer - source = result.model.source_hinges[0] - original = dict(frozen_image_model=asdict(parent.model), image_model_sha256=source.model_sha256, - geometry_uncertainty=[("thumb_mcp", source.child_axis_uncertainty_rad, source.child_point_uncertainty_m)], - view="front", tag_role="thumb_mcp", session_epoch=1, source_image_stamps=parent.model.source_stamps_ns) - child = dict(frozen_image_model=asdict(result.model), image_model_sha256=image_model_sha256(asdict(result.model)), - view="front", tag_role="thumb_ip", session_epoch=1, source_image_stamps=result.model.source_stamps_ns) - evidence = {source.evidence_id: original, "b"*64: child} - validate_source_hinges(evidence) - changed = deepcopy(evidence) - if change == "missing": del changed[source.evidence_id] - elif change == "axis": - g = changed[source.evidence_id]["frozen_image_model"]["geometry"][0] - g["axis_parent_xyz"] = tuple(-v for v in g["axis_parent_xyz"]) - changed[source.evidence_id]["image_model_sha256"] = image_model_sha256(changed[source.evidence_id]["frozen_image_model"]) - elif change == "uncertainty": changed[source.evidence_id]["geometry_uncertainty"] = [("thumb_mcp", .001, .001)] - elif change == "hash": changed[source.evidence_id]["image_model_sha256"] = "f"*64 - elif change == "epoch": changed[source.evidence_id]["session_epoch"] = 2 - else: changed[source.evidence_id]["source_image_stamps"] = child["source_image_stamps"] - with pytest.raises(ValueError): - validate_source_hinges(changed) - - -@pytest.mark.parametrize("sign", [-1, 1]) -def test_source_axis_in_tag_frame_survives_upstream_motion(sign): - frames, truth = synthetic_frames(sign) - relation = MotionRelation("first", "root", "moving") - parent_frames = tuple(replace(f, evidence=replace(f.evidence, roles=f.evidence.roles[:2]), - observations=f.observations[:2]) for f in frames) - parent = resolve_image_motion(parent_frames, (relation,)) - assert parent.resolved, parent.reason - source = source_from(parent) - child_frames = [] - for frame in frames: - # Independently rendered current root poses. The parent moves between - # frames, so transporting its world-space axis would give wrong angles. - root, tip = frame.evidence.roles[1:] - root = replace(root, candidates=(truth[len(child_frames)]["moving"],), frozen=True) - child_frames.append(replace(frame, evidence=replace(frame.evidence, - stamp_ns=frame.evidence.stamp_ns+10000000000, roles=(root, tip)), observations=frame.observations[1:])) - result = resolve_image_motion(child_frames, (MotionRelation("second", "moving", "terminal"),), - geometry_constraints=(ParallelAxisGeometry("first", "second", sign, .037),), - source_urdf_sha256="c"*64, source_hinges=(source,)) - assert result.resolved, result.reason - before = asdict(result.model) - for i in (0, 9, 24, 40, 48): - selection = select_image_motion_frame(result.model, child_frames[i]) - assert selection.resolved, selection.reason - pose = next(item.pose for item in selection.selections if item.role == "terminal") - expected = truth[i]["terminal"] - assert (Rotation.from_quat(pose.quaternion_xyzw).inv()*Rotation.from_quat(expected.quaternion_xyzw)).magnitude() < 1e-4 - np.testing.assert_allclose(pose.translation_xyz_m, expected.translation_xyz_m, atol=1e-5) - assert asdict(result.model) == before diff --git a/src/linkerhand_calibration/test/test_turnaround_support.py b/src/linkerhand_calibration/test/test_turnaround_support.py new file mode 100644 index 0000000..dbeb4e7 --- /dev/null +++ b/src/linkerhand_calibration/test/test_turnaround_support.py @@ -0,0 +1,191 @@ +"""Unobserved outgoing endpoints cannot create calibrated travel.""" +import copy +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from linkerhand_calibration.profiles import load_bundled_hand_profile +from linkerhand_calibration.profiles.observations import compile_tag_links +from linkerhand_calibration.core.geometry.tag_pose.urdf_command_images import UrdfCommandImages +from linkerhand_calibration.core.fitting.raw_joint_uncertainty import angle_derivatives +from linkerhand_calibration.core.fitting.command_support import validate_turnaround_mapping +from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel +from linkerhand_calibration.runtime.artifacts.raw_joint_report import prepare_raw_motion_ranges + + +@pytest.fixture(scope='module') +def inputs(): + from raw_image_fixture import raw_image_rows + profile = load_bundled_hand_profile('o30_right_18') + model = UrdfKinematicModel(Path(__file__).resolve().parents[1]/ + 'urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf') + rows = copy.deepcopy(raw_image_rows(cycles=(0, 1))) + tags = {t.role: t for v in profile.vision.views for t in v.tags} + for row in rows: + row['command_direction_by_index'] = [ + 'increasing' if value == 255 else 'decreasing' if value == 0 else direction + for value, direction in zip(row['command_vector'], row['command_direction_by_index'])] + row.update(feedback_vector=row['command_vector'], target_command_vector=row['command_vector'], + command_image_skew_ns=0, state_image_sync_error_ms=0., observation_protocol='raw_joint_images_v2') + for role, item in row['tags'].items(): + item.update(tag_role=role, tag_id=tags[role].tag_id, image_stamp_ns=row['image_stamp_ns'], + pose_source='raw_corners') + bundle = UrdfCommandImages(profile, model, SimpleNamespace(transform=lambda _: np.eye(4)), + rows, compile_tag_links(profile, model)) + parameters = np.zeros(len(bundle.scale)) + parameters[5] = 1. + for columns in bundle.curve_columns.values(): + parameters[columns[columns >= 0]] = .08 + return bundle, parameters, rows + + +def test_unobserved_increment_cannot_change_curve_range_or_export(inputs): + bundle, parameters, rows = inputs + original = bundle.output_mappings(parameters) + ranges = prepare_raw_motion_ranges(bundle, parameters, rows) + shifted = parameters.copy() + for name in ('thumb_cmc_roll', 'thumb_cmc_yaw', 'thumb_mcp', 'thumb_ip'): + column = bundle.curve_columns[name][1, -1] + assert column not in bundle.optimization_columns + shifted[column] += np.radians(20) + support = ranges[name].command_support + assert support['constraints'] and support['independently_measured_outgoing_endpoint'] is False + validate_turnaround_mapping(original[name], support) + assert np.array_equal(bundle.residual(parameters), bundle.residual(shifted)) + assert bundle.output_mappings(shifted) == original + assert prepare_raw_motion_ranges(bundle, shifted, rows) == ranges + + +def test_shared_endpoint_derivative_and_uncertainty_follow_observed_branch(inputs): + bundle, parameters, _ = inputs + channel = bundle.channels['thumb_cmc_yaw'] + commands = np.tile(bundle.profile.command.baseline_values, (4, 1)).astype(float) + commands[:, channel] = [223, 240, 255, 255] + directions = np.zeros(commands.shape, dtype=int) + directions[:, channel] = [1, 1, 1, -1] + derivatives = angle_derivatives(bundle, commands, directions) + rng = np.random.default_rng(42) + vector = rng.normal(size=len(parameters)) + h = 1e-6 + plus = bundle.joint_angles(parameters+h*vector, commands, directions) + minus = bundle.joint_angles(parameters-h*vector, commands, directions) + for name in bundle.joints: + cad = bundle.cad_names[name] + assert np.allclose((plus[cad]-minus[cad])/(2*h), derivatives[cad]@vector, atol=1e-8) + assert np.all(derivatives['thumb_cmc_yaw'][:, bundle.curve_columns['thumb_cmc_yaw'][1, -1]] == 0) + numeric = (bundle.residual(parameters+h*vector)-bundle.residual(parameters-h*vector))/(2*h) + assert np.allclose(numeric, bundle.jacobian(parameters)@vector, rtol=1e-6, atol=1e-5) + + +def test_missing_endpoint_is_rejected_without_using_validation(inputs): + bundle, parameters, rows = inputs + filtered = [r for r in rows if not (r['task_name'] == 'thumb_cmc_yaw_top' + and r['command_vector'][1] == 255)] + with pytest.raises(ValueError, match='endpoint_support_missing:thumb_cmc_yaw'): + prepare_raw_motion_ranges(bundle, parameters, filtered) + holdout = [dict(r, cycle=3) for r in rows] + with pytest.raises(ValueError, match='training_partition_invalid'): + UrdfCommandImages(bundle.profile, bundle.model, bundle.extrinsics, holdout, bundle.tag_links) + + +def test_observed_return_endpoint_remains_independent(inputs): + bundle, _, rows = inputs + observed = copy.deepcopy(rows) + for row in observed: + if row['task_name'] == 'thumb_cmc_yaw_top' and row['direction'] == 'decreasing': + row['command_direction_by_index'][1] = 'decreasing' + other = UrdfCommandImages(bundle.profile, bundle.model, bundle.extrinsics, observed, bundle.tag_links) + assert not other.turnaround_constraints.get('thumb_cmc_yaw') + assert other.curve_columns['thumb_cmc_yaw'][1, -1] in other.optimization_columns + + +def test_saved_endpoint_constraint_rejects_different_curve(inputs): + bundle, parameters, rows = inputs + from dataclasses import replace + mapping = bundle.output_mappings(parameters)['thumb_cmc_yaw'] + support = prepare_raw_motion_ranges(bundle, parameters, rows)['thumb_cmc_yaw'].command_support + wrong = replace(mapping, decreasing_rad=(*mapping.decreasing_rad[:-1], mapping.decreasing_rad[-1]+.05)) + with pytest.raises(ValueError, match='differs_from_evidence'): + validate_turnaround_mapping(wrong, support) + wrong_support = copy.deepcopy(support) + wrong_support['constraints'][0]['source_cycles'] = [3] + with pytest.raises(ValueError, match='differs_from_evidence'): + validate_turnaround_mapping(mapping, wrong_support) + + +def test_unknown_initial_history_does_not_create_a_free_outgoing_endpoint(inputs): + bundle, parameters, rows = inputs + unknown = copy.deepcopy(rows) + for row in unknown: + if row['task_name'] == 'thumb_cmc_yaw_top' and row['command_vector'][1] == 255 and row['cycle'] == 0: + row['command_direction_by_index'][1] = '' + other = UrdfCommandImages(bundle.profile, bundle.model, bundle.extrinsics, unknown, bundle.tag_links) + assert other.turnaround_constraints['thumb_cmc_yaw'] + shifted = parameters.copy(); shifted[other.curve_columns['thumb_cmc_yaw'][1, -1]] += 1. + assert np.array_equal(other.residual(parameters), other.residual(shifted)) + + +def test_snapshot_can_load_offline_and_is_sealed(tmp_path): + from linkerhand_calibration.product import load_product_config + from linkerhand_calibration.runtime.raw_completion import ( + snapshot_capture_inputs, save_capture_manifest, verify_capture_manifest, + ) + package = Path(__file__).resolve().parents[1] + config = load_product_config(package/'config/o30_right_product.yaml', check_can=False) + (tmp_path/'capture_profile.yaml').write_bytes(config.profile_config.read_bytes()) + snapshot_capture_inputs(config, tmp_path) + saved = load_product_config(tmp_path/'capture_product.yaml', workspace=config.workspace, check_can=False) + assert saved.calibration_contract.typed_profile == config.calibration_contract.typed_profile + assert saved.source_urdf_sha256 == config.source_urdf_sha256 + assert saved.camera_extrinsics_sha256 == config.camera_extrinsics_sha256 + import xml.etree.ElementTree as ET + assets = {node.get('filename') for node in ET.parse(saved.source_urdf).findall('.//mesh')} + assert assets and all((saved.source_urdf.parent/name).is_file() for name in assets) + raw = tmp_path/'raw_samples.jsonl'; raw.write_text('{}\n') + save_capture_manifest(raw, profile=saved.calibration_contract.typed_profile, + serial_number=saved.serial_number, protected_inputs={}, completed_units=set(), incomplete_units={}, retries={}) + verify_capture_manifest(raw) + import json + manifest_path = tmp_path/'raw_capture_manifest.json' + original = manifest_path.read_text() + manifest = json.loads(original) + manifest['files']['../outside'] = '0'*64 + manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match='invalid_sealed_path'): + verify_capture_manifest(raw) + manifest_path.write_text(original) + saved.camera_extrinsics.write_text('changed') + with pytest.raises(ValueError, match='sealed_file_changed'): + verify_capture_manifest(raw) + + +def test_reduced_covariance_removes_only_declared_constraints(): + bundle = object.__new__(UrdfCommandImages) + bundle.scale = np.ones(3) + bundle.optimization_columns = np.array([0, 1]) + jac = np.array([[1., 0., 0.], [0., 2., 0.], [1., 1., 0.], [2., 1., 0.]]) + fit = SimpleNamespace(jac=jac, fun=np.ones(4)*.001, x=np.zeros(3)) + covariance = bundle.covariance(fit) + assert covariance[0, 0] > 0 and covariance[1, 1] > 0 + assert np.all(covariance[2] == 0) and np.all(covariance[:, 2] == 0) + # A second unobservable parameter must still fail the original rank gate. + fit.jac[:, 1] = 0 + with pytest.raises(ValueError, match='geometry_rank_deficient'): + bundle.covariance(fit) + + +def test_linear_drive_keeps_actual_limits_and_nonzero_byte_baseline(inputs): + from linkerhand_calibration.core.urdf.linear_drive import UrdfLinearImages + bundle, parameters, _ = inputs + probe = np.tile(bundle.profile.command.baseline_values, (3, 1)).astype(float) + probe[:, 1] = [0., 128., 255.] + before = parameters.copy() + angles = UrdfLinearImages(bundle, bundle.model).joint_angles(parameters, probe, np.zeros(probe.shape)) + joint = bundle.model.joints['thumb_cmc_yaw'] + assert np.allclose(angles['thumb_cmc_yaw'], joint.lower+(joint.upper-joint.lower)*probe[:, 1]/255) + middle = bundle.model.joints['middle_mcp_roll'] + expected = middle.lower+(middle.upper-middle.lower)*196/255 + assert np.allclose(angles['middle_mcp_roll'], expected) + assert np.array_equal(parameters, before) diff --git a/src/linkerhand_calibration/test/test_unified_artifact_pair.py b/src/linkerhand_calibration/test/test_unified_artifact_pair.py deleted file mode 100644 index 73211d4..0000000 --- a/src/linkerhand_calibration/test/test_unified_artifact_pair.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Both input domains must pass the actual serialized standard URDF gate.""" - -from dataclasses import replace -import copy -import json - -import numpy as np -import pytest - -from test_frozen_tag_replay import fixture -from linkerhand_calibration.core.fitting.tag_installation import matrix_tuple -from linkerhand_calibration.runtime.artifacts.publisher import ArtifactPublisher, FrozenTagArtifactValidator -from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper - - -def dual_fixture(tmp_path): - source, plan, base, _, mounts, feedback, legacy = fixture(tmp_path) - old = legacy["joints"]["drive"] - def mapping(kind): - knots = old["curve_input_knots_rad"] - if kind == "command": - knots = [2*v+.1 for v in knots] - return {"input_domain": kind+"_rad", "input_unit": "rad", "output_unit": "rad", - "channel_index": 0, "knots": knots, "valid_input_range": [knots[0], knots[-1]], - "interpolation": "piecewise_linear", "extrapolation": "reject", - **{key: old[key].copy() for key in ("angle_rad", "increasing_rad", "decreasing_rad")}} - parent, multiplier, offset = plan.mimic_output["passive"] - payload = {"format": "unified_calibration_v1", "schema_version": 1, - "profile_id": legacy["profile_id"], "model": "VIRTUAL", "side": "right", "serial_number": "TEST", - "command_unit": "rad", "command_names": ["vendor_channel"], "joints": { - "drive": {"urdf_joint": "drive", "passive": False, - "command_to_rad": mapping("command"), "feedback_to_rad": mapping("feedback")}, - "passive": {"urdf_joint": "passive", "passive": True, - "mimic": {"joint": parent, "multiplier": multiplier, "offset_rad": offset}}}} - commands = tuple(replace(r, sample_id="command:"+r.sample_id, sdk_values=(2*r.sdk_values[0]+.1,)) for r in feedback) - validator = FrozenTagArtifactValidator(payload["profile_id"], source, plan.source_sha256, - plan.authorized_fields, plan.zero_offsets_rad, matrix_tuple(base), mounts, - tuple(feedback), ("a", "b"), lambda _: None, command_observations=commands) - session = tmp_path/"session"; session.mkdir() - urdf = plan.write(source, session/"corrected.urdf") - path = session/"calibration.json" - path.write_text(json.dumps(payload)) - return session, path, urdf, payload, validator - - -def test_dual_final_file_gate_manifest_reader_and_standard_passive(tmp_path): - session, path, urdf, payload, validator = dual_fixture(tmp_path) - release = ArtifactPublisher(tmp_path, "latest").publish(session_directory=session, - calibration_json=path, corrected_urdf=urdf, validate=validator) - command = load_unified_mapper(release.manifest) - feedback = load_unified_mapper(path, input_kind="feedback") - assert command.map_positions((1.1,)) == pytest.approx(feedback.map_positions((.5,)), abs=1e-9) - assert command.map_positions((1.1,))[0] == pytest.approx(.2+.8*.5-.07) - feedback.feedback_name_aliases = {"vendor_reported_name": "vendor_channel"} - assert feedback.map_positions((.5,), ("vendor_reported_name",)) == pytest.approx(command.map_positions((1.1,))) - # Unnamed vendor positions retain the fixed array contract; no G20/O12 registry. - with pytest.raises(ValueError, match="outside"): - command.map_positions((3.,)) - path.write_text(path.read_text()+" ") - with pytest.raises(ValueError, match="SHA256"): - load_unified_mapper(path) - - -@pytest.mark.parametrize("corruption", ["command_curve", "domain", "passive_bypass", "missing_command_holdout"]) -def test_good_feedback_cannot_hide_bad_command_mapping(tmp_path, corruption): - session, path, urdf, payload, validator = dual_fixture(tmp_path) - if corruption == "command_curve": - for key in ("angle_rad", "increasing_rad", "decreasing_rad"): - values = payload["joints"]["drive"]["command_to_rad"][key] - payload["joints"]["drive"]["command_to_rad"][key] = (np.asarray(values)+.07*np.sin(np.linspace(0, np.pi, len(values)))).tolist() - elif corruption == "domain": - payload["joints"]["drive"]["command_to_rad"]["input_domain"] = "feedback_rad" - elif corruption == "passive_bypass": - payload["joints"]["passive"]["command_to_rad"] = copy.deepcopy(payload["joints"]["drive"]["command_to_rad"]) - else: - validator = replace(validator, command_observations=()) - path.write_text(json.dumps(payload)) - with pytest.raises(ValueError): - ArtifactPublisher(tmp_path, "latest").publish(session_directory=session, - calibration_json=path, corrected_urdf=urdf, validate=validator) - assert not (tmp_path/"latest").exists() diff --git a/src/linkerhand_calibration/test/test_unified_engine.py b/src/linkerhand_calibration/test/test_unified_engine.py deleted file mode 100644 index bca8644..0000000 --- a/src/linkerhand_calibration/test/test_unified_engine.py +++ /dev/null @@ -1,308 +0,0 @@ -from __future__ import annotations - -import math -from pathlib import Path - -import numpy as np -import pytest - -from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry -from linkerhand_calibration.core import ( - ArtifactPolicy, - CalibrationProfile, - CommandLayout, - MeasurementPolicy, - MeasurementSpec, - MotionPolicy, - ProfileKey, - QualityPolicy, - ScopePolicy, - TagSpec, - TaskSpec, - ViewSpec, - VisionRigSpec, - ZeroSolvePolicy, -) -from linkerhand_calibration.core.urdf import ( - UrdfJointPatch, - UrdfPatchSet, - write_urdf_patches, -) -from linkerhand_calibration.runtime import ( - ACQUISITION_POLICY_VERSION, - CalibrationEngine, -) -from linkerhand_calibration.runtime.adapters import ProfileSdkAdapter -from linkerhand_calibration.core.geometry.rotation import fit_rotation_axis -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import fit_coupling_model - - -def test_all_product_profiles_use_unified_engine_and_sdk_adapter() -> None: - registry = get_default_registry() - products = { - registered.profile.key.model: registered - for registered in registry - if registered.profile.key.layout - in {"g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16"} - } - assert set(products) == {"G20", "L6", "O6", "O12"} - for registered in products.values(): - engine = registered.build_engine() - adapter = registered.build_sdk_adapter() - assert engine.checkpoint_token == ACQUISITION_POLICY_VERSION - assert adapter.command_layout is registered.profile.command - units = engine.scan_units() - assert len([u for u in units if u.sample_phase == "sweep"]) == len(registered.profile.motion.tasks) * 8 - separate = registered.profile.acquisition.command_capture_mode == "separate" - assert len([u for u in units if u.sample_phase == "steady"]) == len(registered.profile.motion.tasks) * (4 if separate else 0) - - -def test_only_o12_has_one_three_degree_mapping_probe() -> None: - for registered in get_default_registry(): - if registered.profile.key.layout not in { - "g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16" - }: - continue - engine = registered.build_engine() - task = registered.profile.motion.tasks[0] - probe = engine.mapping_probe_delta(task) - if registered.profile.key.model == "O12": - assert probe is not None - assert 0.0 < abs(probe) <= math.radians(3.0) - else: - assert probe is None - - -def test_short_vision_loss_and_low_ideal_rates_are_warnings() -> None: - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "o12_right_16" - ) - result = CalibrationEngine(profile).evaluate_sweep( - [index / 63.0 for index in range(64)], - minimum_span=0.85, - total_frames=90, - joint_frame_rate=0.71, - feedback_hz=12.0, - detection_rate=0.72, - bin_count=64, - ) - assert result.passed - assert any(value.startswith("tag_rate=") for value in result.warnings) - assert any(value.startswith("joint_frame_rate=") for value in result.warnings) - - -def test_observability_failure_gets_only_one_same_speed_rescan() -> None: - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "l6_right_8" - ) - engine = CalibrationEngine(profile) - failed = engine.evaluate_sweep( - [index / 255.0 for index in range(20)], - minimum_span=240.0 / 255.0, - total_frames=200, - joint_frame_rate=0.1, - feedback_hz=5.0, - detection_rate=0.1, - ) - assert not failed.passed - assert engine.retry_speed(40.0, 2) == 40.0 - with pytest.raises(ValueError, match="exactly one"): - engine.retry_speed(40.0, 3) - - -def test_o12_effective_endpoint_scale_is_not_an_internal_blind_spot() -> None: - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "o12_right_16" - ) - result = CalibrationEngine(profile).evaluate_sweep( - [index / 255.0 for index in range(20, 256)], - minimum_span=0.85, - total_frames=236, - joint_frame_rate=1.0, - feedback_hz=50.0, - detection_rate=1.0, - ) - assert result.passed - assert result.metrics["gap_scope"] == "observed_feedback_span" - assert result.metrics["maximum_bin_gap"] == 0 - - -def test_rotation_axis_uses_observed_endpoints_for_physical_feedback() -> None: - commands = np.arange(25, 254, dtype=int) - physical_angle = (253.0 - commands.astype(float)) / 228.0 - expected_axis = np.asarray([-0.25, 0.1, -0.96], dtype=float) - expected_axis /= np.linalg.norm(expected_axis) - vectors = physical_angle[:, None] * expected_axis[None, :] - - axis = fit_rotation_axis(vectors, commands) - projections = vectors @ axis - - assert float(np.median(projections[commands <= 48])) > float( - np.median(projections[commands >= 230]) - ) - - - - -def test_directional_lookup_does_not_force_passive_curve_into_polynomial() -> None: - source = tuple(float(value) for value in np.linspace(1.5, 0.0, 256)) - target = tuple(float(value) for value in 1.2 * np.asarray(source) ** 3) - - def curve(values: tuple[float, ...]) -> JointCurveFit: - return JointCurveFit( - angle_rad=values, - decreasing_rad=values, - increasing_rad=tuple(value + 0.05 for value in values), - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=0.05, - quality={}, - ) - - fit = fit_coupling_model( - "source", "target", curve(source), curve(target), - model="direction_aware_knots", - minimum_multiplier=0.1, - maximum_multiplier=3.0, - ) - - assert fit.model == "direction_aware_knots" - assert fit.urdf_mimic_policy == "endpoint_linear_fallback" - assert fit.urdf_mimic_multiplier == pytest.approx(target[0] / source[0]) - assert fit.residual_max_rad == 0.0 - - -def test_o12_internal_blind_spot_larger_than_one_sixteenth_is_rejected() -> None: - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "o12_right_16" - ) - values = [ - index / 255.0 for index in range(256) - if not 100 <= index <= 130 - ] - result = CalibrationEngine(profile).evaluate_sweep( - values, - minimum_span=0.85, - total_frames=len(values), - joint_frame_rate=1.0, - feedback_hz=50.0, - detection_rate=1.0, - ) - assert not result.passed - assert any(value.startswith("maximum_gap=") for value in result.failures) - - -def test_old_checkpoint_policy_is_intentionally_incompatible() -> None: - from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "g20_right_19" - ) - engine = CalibrationEngine(profile) - assert not engine.resume_compatible({"profile_id": profile.key.profile_id}) - assert engine.resume_compatible({ - "profile_id": profile.key.profile_id, - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "capture_schedule_version": engine.capture_schedule_version, - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION, - }) - - -def test_removed_soft_stop_gates_are_not_in_live_nodes() -> None: - package = Path(__file__).resolve().parents[1] / "linkerhand_calibration/runtime" - live = "\n".join( - (package / relative).read_text(encoding="utf-8") - for relative in ("ros/calibration_node.py", "ros/io.py", "coordinator.py", "safety.py") - ) - for removed in ( - "non_target_motor_moved", - "o12_auxiliary_settle_timeout", - "o12_sdk_feedback_coupling_hard_limit", - "o12_roll_coupling_exceeded", - ): - assert removed not in live - - -def test_virtual_new_model_needs_only_adapter_profile_and_raw_urdf( - tmp_path: Path, -) -> None: - joint = "finger_joint" - profile = CalibrationProfile( - key=ProfileKey("VIRTUAL", "right", "one_joint"), - namespace="/virtual/right", - command=CommandLayout( - names=("finger",), - baseline_u8=(255,), - command_index_by_joint={joint: 0}, - urdf_joint_by_joint={joint: joint}, - ), - vision=VisionRigSpec( - views=(ViewSpec("front", ( - TagSpec("base", 0, True), TagSpec("finger", 1), - )),), - common_frame="front", - extrinsic_reference_view="front", - ), - motion=MotionPolicy(tasks=( - TaskSpec( - "finger_sweep", "front", 0, (joint,), - start_u8=255, end_u8=0, formal_speed_u8=40, - ), - )), - measurement=MeasurementPolicy(measurements={ - joint: MeasurementSpec( - joint, "rotation", "front", "base", "finger" - ), - }), - zero=ZeroSolvePolicy( - active_joints=frozenset({joint}), - passive_joints=frozenset(), - direct_zero_joints=(joint,), - axis_joints=(joint,), - mechanical_endpoint_joints=frozenset(), - post_solve_endpoint_joints=frozenset(), - mimic_source_by_joint={}, - cad_frozen_joints=frozenset(), - ), - quality=QualityPolicy( - (0, 1, 2), 3, frozenset({"holdout"}), isolated_holdout=True - ), - scope=ScopePolicy( - calibrate_joints={"full": frozenset({joint})}, - frozen_joints={"full": frozenset()}, - ), - artifacts=ArtifactPolicy( - 1, "virtual.json", "virtual.urdf", frozenset(), - publish_corrected_urdf=True, - ), - ) - engine = CalibrationEngine(profile) - adapter = ProfileSdkAdapter(profile.command) - assert len(engine.scan_units()) == 8 - assert adapter.parse_feedback(("finger",), (127.0,)) == (127.0,) - - source = tmp_path / "virtual_raw.urdf" - source.write_text( - '' - '' - '' - '' - '' - '', - encoding="utf-8", - ) - output = write_urdf_patches( - source_urdf=source, - destination_urdf=tmp_path / "virtual.urdf", - patches=UrdfPatchSet({ - joint: UrdfJointPatch(origin_rpy="0 0 0.01") - }), - ) - corrected = output.read_text(encoding="utf-8") - assert 'rpy="0 0 0.01"' in corrected - assert '' in corrected diff --git a/src/linkerhand_calibration/test/test_unified_launch.py b/src/linkerhand_calibration/test/test_unified_launch.py deleted file mode 100644 index f5c9b52..0000000 --- a/src/linkerhand_calibration/test/test_unified_launch.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Build real launch actions without executing a process or opening hardware.""" - -import hashlib -import importlib.util -from pathlib import Path - -import pytest -import yaml -from launch import LaunchContext -from launch.actions import DeclareLaunchArgument, ExecuteProcess -from launch.utilities import perform_substitutions -from launch_ros.actions import ComposableNodeContainer, Node -from launch_ros.utilities import evaluate_parameters, to_parameters_list -from ros2launch.api.api import parse_launch_arguments - -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.runtime.runner_support import launch_command - - -PACKAGE = Path(__file__).resolve().parents[1] - - -@pytest.fixture(autouse=True) -def isolated_launch_logs(tmp_path, monkeypatch): - monkeypatch.setenv("ROS_LOG_DIR", str(tmp_path / "ros_logs")) - - -def launch_module(): - spec = importlib.util.spec_from_file_location("test_calibration_launch", - PACKAGE / "launch/three_camera_calibration.launch.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"]) -@pytest.mark.parametrize("renamed_views", [False, True]) -def test_actual_launch_builds_from_protected_profile(tmp_path, model, renamed_views): - config = load_product_config(PACKAGE / f"config/{model}_right_product.yaml", - workspace=PACKAGE.parents[1], check_can=False) - arguments = dict(parse_launch_arguments(launch_command(config, tmp_path / "session", - record_bag=False, commands_enabled=False)[4:])) - if renamed_views: - payload = yaml.safe_load(config.profile_config.read_text()) - rename = {view.name: f"inspection_{i}" for i, view in enumerate(config.calibration_contract.typed_profile.vision.views)} - for view in payload["vision"]["views"]: - view["name"] = rename[view["name"]] - payload["vision"]["extrinsic_reference_view"] = rename[payload["vision"]["extrinsic_reference_view"]] - for task in payload["motion"]["tasks"]: - task["view"] = rename[task["view"]] - for measurement in payload["measurement"]["measurements"].values(): - measurement["view"] = rename[measurement["view"]] - path = tmp_path / "renamed.yaml" - path.write_text(yaml.safe_dump(payload)) - arguments["profile_config"] = str(path) - arguments["profile_config_expected_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() - for old, new in rename.items(): - for suffix in ("camera_serial", "camera_name", "camera_info_url"): - arguments[f"{new}_{suffix}"] = arguments.pop(f"{old}_{suffix}") - context = LaunchContext() - context.launch_configurations.update(arguments) - module = launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - actions = module._launch_stack(context) - # Cameras, detector container, border filter, SDK and calibration owner. - assert sum(isinstance(action, Node) for action in actions) == 7 - assert not (tmp_path / "session/raw_samples.jsonl").exists() - - -def test_launch_rejects_missing_profile_before_creating_processes(): - context = LaunchContext() - context.launch_configurations.update(model="O6", hand_type="right", tag_layout="o6_right_8") - module = launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - with pytest.raises(RuntimeError, match="protected YAML Profile"): - module._launch_stack(context) - - -@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"]) -def test_detector_receives_protected_model_settings(tmp_path, monkeypatch, model): - """Resolve the actual component parameters, including YAML/override order.""" - config = load_product_config(PACKAGE / f"config/{model}_right_product.yaml", - workspace=PACKAGE.parents[1], check_can=False) - context = LaunchContext() - context.launch_configurations.update(dict(parse_launch_arguments( - launch_command(config, tmp_path / "session", record_bag=False, - commands_enabled=False)[4:]))) - module = launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - components = [] - - def capture_container(**kwargs): - components.extend(kwargs["composable_node_descriptions"]) - return ComposableNodeContainer(**kwargs) - - monkeypatch.setattr(module, "ComposableNodeContainer", capture_container) - module._launch_stack(context) - protected = yaml.safe_load(config.tag_config.read_text()) - checked = 0 - for component in components: - if perform_substitutions(context, component.node_plugin) != "AprilTagNode": - continue - name = perform_substitutions(context, component.node_name) - namespace = perform_substitutions(context, component.node_namespace) - effective = {parameter.name: parameter.value for parameter in to_parameters_list( - context, name, namespace, evaluate_parameters(context, component.parameters))} - expected = protected[f"{namespace}/{name}"]["ros__parameters"] - assert effective["detector.decimate"] == expected["detector"]["decimate"] - assert tuple(effective["tag.ids"]) == tuple(expected["tag"]["ids"]) - assert tuple(effective["tag.sizes"]) == tuple(expected["tag"]["sizes"]) - checked += 1 - assert checked == len(config.calibration_contract.typed_profile.vision.views) - - -@pytest.mark.parametrize("renamed_views", [False, True]) -def test_thumb_launch_omits_unused_camera_and_all_its_transport(tmp_path, monkeypatch, renamed_views): - config = load_product_config(PACKAGE / "config/o6_right_product.yaml", - workspace=PACKAGE.parents[1], check_can=False) - namespace = config.calibration_contract.typed_profile.namespace - arguments = dict(parse_launch_arguments(launch_command(config, tmp_path / "session", - record_bag=True, commands_enabled=True, diagnostic_capture="o6_thumb")[4:])) - rename = {"front": "front", "side": "side", "top": "top"} - if renamed_views: - rename = {"front": "inspection_a", "side": "inspection_b", "top": "inspection_c"} - payload = yaml.safe_load(config.profile_config.read_text()) - for view in payload["vision"]["views"]: - view["name"] = rename[view["name"]] - payload["vision"]["extrinsic_reference_view"] = rename[payload["vision"]["extrinsic_reference_view"]] - for task in payload["motion"]["tasks"]: - task["view"] = rename[task["view"]] - for measurement in payload["measurement"]["measurements"].values(): - measurement["view"] = rename[measurement["view"]] - path = tmp_path / "renamed.yaml" - path.write_text(yaml.safe_dump(payload)) - arguments["profile_config"] = str(path) - arguments["profile_config_expected_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() - for old, new in rename.items(): - for suffix in ("camera_serial", "camera_name", "camera_info_url"): - arguments[f"{new}_{suffix}"] = arguments.pop(f"{old}_{suffix}") - - # No side-camera launch arguments are supplied. A disconnected unused - # camera must not produce a driver process that could fail this session. - for suffix in ("camera_serial", "camera_name", "camera_info_url"): - arguments.pop(f"{rename['side']}_{suffix}") - context = LaunchContext() - context.launch_configurations.update(arguments) - module = launch_module() - for action in module.generate_launch_description().entities: - if isinstance(action, DeclareLaunchArgument): - action.execute(context) - - components = [] - def capture_container(**kwargs): - components.extend(kwargs["composable_node_descriptions"]) - return ComposableNodeContainer(**kwargs) - monkeypatch.setattr(module, "ComposableNodeContainer", capture_container) - actions = module._launch_stack(context) - nodes = [action for action in actions if isinstance(action, Node)] - assert len(nodes) == 6 # Two cameras, vision, border filter, SDK, coordinator. - assert len(components) == 4 # Rectification and detection for each active view. - component_namespaces = {perform_substitutions(context, component.node_namespace) - for component in components} - assert component_namespaces == {f"{namespace}/{view}/{kind}" - for view in (rename["front"], rename["top"]) - for kind in ("camera", "apriltag")} - - bag, = [action for action in actions - if isinstance(action, ExecuteProcess) and not isinstance(action, Node)] - bag_command = [perform_substitutions(context, part) for part in bag.cmd] - assert all(f"/{rename['side']}/" not in argument for argument in bag_command) - for view in (rename["front"], rename["top"]): - assert f"{namespace}/{view}/camera/image_raw" in bag_command - assert f"{namespace}/{view}/camera/camera_info" in bag_command - assert f"{namespace}/{view}/apriltag/detections" in bag_command - assert not (tmp_path / "session/raw_samples.jsonl").exists() diff --git a/src/linkerhand_calibration/test/test_unified_ros_host.py b/src/linkerhand_calibration/test/test_unified_ros_host.py deleted file mode 100644 index b107624..0000000 --- a/src/linkerhand_calibration/test/test_unified_ros_host.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Actual ROS host, isolated DDS domain; no SDK, camera or hand is launched.""" - -from pathlib import Path - -import numpy as np -import pytest - - -@pytest.mark.parametrize("model,diagnostic_capture", [ - ("l6", ""), ("o6", ""), ("g20", ""), ("o12", ""), ("o6", "o6_thumb"), -]) -def test_ros_host_constructs_and_start_discards_preview(tmp_path, monkeypatch, model, diagnostic_capture): - rclpy = pytest.importorskip("rclpy") - from sensor_msgs.msg import JointState - from std_srvs.srv import Trigger - from linkerhand_calibration.product import load_product_config - from linkerhand_calibration.runtime.runner_support import protected_inputs - from linkerhand_calibration.runtime.ros.calibration_node import UnifiedCalibrationNode - from linkerhand_calibration.runtime.session import CalibrationPhase as Phase - package = Path(__file__).resolve().parents[1] - config = load_product_config(package / f"config/{model}_right_product.yaml", workspace=package.parents[1], check_can=False) - profile = config.calibration_contract.typed_profile - params = {"serial_number": "OFFLINE_HOST", "session_dir": str(tmp_path / model), - "source_urdf_path": str(config.source_urdf), "camera_extrinsics_file": str(config.camera_extrinsics), - **{key.replace("_sha256", "_expected_sha256"): value for key, value in protected_inputs(config).items()}} - if diagnostic_capture: - params["diagnostic_capture"] = diagnostic_capture - args = ["--ros-args", "--params-file", str(config.calibration_config)] - for key, value in params.items(): - args += ["-p", f"{key}:={value}"] - monkeypatch.setenv("ROS_DOMAIN_ID", "221") - monkeypatch.setenv("ROS_LOCALHOST_ONLY", "1") - monkeypatch.setenv("ROS_LOG_DIR", str(tmp_path / "ros_logs")) - rclpy.init(args=args) - node = None - try: - node = UnifiedCalibrationNode(profile) - host = node.coordinator - views = ("front", "top") if diagnostic_capture else profile.vision.view_names - assert all(subscription.callback_group is node.default_callback_group - for subscription in node.subscriptions) - subscriptions = {subscription.topic_name for subscription in node.subscriptions} - expected_state_topic = host.parameters.state_topic + ("_timed" if profile.sdk_adapter == "legacy_byte_sdk" else "") - assert expected_state_topic in subscriptions - if profile.sdk_adapter == "legacy_byte_sdk": - assert host.parameters.state_topic not in subscriptions - for view in profile.vision.view_names: - assert (f"{profile.namespace}/{view}/camera/camera_info" in subscriptions) == (view in views) - assert f"{profile.namespace}/{view}/camera/image_rect" not in subscriptions - assert (f"{profile.namespace}/{view}/apriltag/detections_checked" in subscriptions) == (view in views) - assert host.parameters.tracking.reprojection_tie_px == 0.03 - assert all(tracker.reprojection_tie_px == 0.03 for tracker in host.capture.trackers.values()) - if profile.sdk_adapter == "o12_hcan_sdk": - from linkerhand_calibration.runtime.adapters import HardwareHealth - monkeypatch.setattr(host.sdk_adapter, "_health", lambda: HardwareHealth(True, True)) - writes = [] - monkeypatch.setattr(node.io.command_publisher, "publish", writes.append) - message = JointState() - message.position = list(map(float, profile.command.baseline_values)) - message.name = list(profile.command.names) - message.header.stamp = node.get_clock().now().to_msg() - def feed_message(): - if profile.sdk_adapter == "legacy_byte_sdk": - import json - from std_msgs.msg import String - stamp = message.header.stamp.sec*1_000_000_000+message.header.stamp.nanosec - timed = String() - timed.data = json.dumps(dict(schema="can_feedback_v1", name=list(message.name), - position=list(message.position), stamp_ns=stamp, - channel_stamps_ns=[stamp]*len(message.position))) - node._timed_state_callback(timed) - else: - node._state_callback(message) - feed_message() - host.cameras.matrices = {view: np.eye(3) for view in views} - import time - host.cameras.info_received_at = {view: time.monotonic() for view in views} - host.cameras.detections_received_at = dict(host.cameras.info_received_at) - node._tick() - assert host.execution.session.phase == Phase.READY - assert not writes # Preview never sends an open/baseline command. - assert host.snapshot().as_dict()["overall_progress_01"] == 0 - preview_capture = host.capture - preview_reference = host.reference_lock - # A once-ready camera going silent must revoke readiness, including - # when Start arrives before the next control timer callback. - host.cameras.detections_received_at.clear() - response = node._start(Trigger.Request(), Trigger.Response()) - assert not response.success - assert host.execution.session.phase == Phase.WAIT_DEVICE - assert not writes - assert host.capture is preview_capture - host.cameras.detections_received_at = {view: time.monotonic() for view in views} - node._tick() - response = node._start(Trigger.Request(), Trigger.Response()) - assert response.success - assert host.capture is not preview_capture - assert host.reference_lock is not preview_reference - assert all(tracker.reprojection_tie_px == 0.03 for tracker in host.capture.trackers.values()) - assert not host.state_history - node._tick() - assert not writes # Still waits for a fresh, post-Start feedback frame. - message.header.stamp = node.get_clock().now().to_msg() - feed_message() - node._tick() - assert host.execution.session.phase == Phase.BASELINE - finally: - if node is not None: - node.destroy_node() - rclpy.shutdown() diff --git a/src/linkerhand_calibration/test/test_unified_runner.py b/src/linkerhand_calibration/test/test_unified_runner.py deleted file mode 100644 index 28e1fc0..0000000 --- a/src/linkerhand_calibration/test/test_unified_runner.py +++ /dev/null @@ -1,443 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import replace -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.profiles import load_hand_profile -from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION -from linkerhand_calibration.product import load_product_config -from linkerhand_calibration.runtime import runner -from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CAPTURE_SCHEDULE_VERSION -from linkerhand_calibration.runtime.resume import discover_resume_candidate -from linkerhand_calibration.runtime.status import normalize_status, render_status_zh - - -PACKAGE = Path(__file__).resolve().parents[1] -WORKSPACE = PACKAGE.parents[1] - - -class LifecycleHarness: - def __init__(self, messages, *, step=1.0, publishers=1, response=True): - self.messages = iter(messages) - self.now = 0.0 - self.step = step - self.publisher_count = publishers - self.status = {} - self.last_status_at = 0.0 - self.status_topic = "/virtual/status" - self.start_client = SimpleNamespace(service_is_ready=lambda: True) - self.returncode = None - self.starts = 0 - self.response = response - - def spin(self): - self.now += self.step - value = next(self.messages) - if value is not None: - self.status = value - self.last_status_at = self.now - - def count_publishers(self, _topic): - return self.publisher_count - - def poll(self): - return self.returncode - - def request(self): - self.starts += 1 - return SimpleNamespace(done=lambda: True, - result=lambda: SimpleNamespace(success=self.response, message="fixture response")) - - def run(self, **options): - return runner.drive_session(self, self, spin=self.spin, ok=lambda: True, - request_start=self.request, clock=lambda: self.now, **options) - - -@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"]) -def test_actual_cli_uses_common_runner_not_model_dispatch(monkeypatch, model): - calls = [] - monkeypatch.setattr(runner, "run_online", lambda config, **kw: calls.append((config, kw)) or 0) - # Failure of any old Python CLI strategy must not affect the real entry. - import linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry as registry - monkeypatch.setattr(registry, "get_default_registry", lambda: pytest.fail("model runner dispatch")) - with pytest.raises(SystemExit) as error: - runner.main(["--config", str(PACKAGE / "config" / f"{model}_right_product.yaml"), - "--workspace", str(WORKSPACE), "--commands-disabled", "--no-resume"]) - assert error.value.code == 0 - assert calls[0][0].model == model.upper() - assert calls[0][1] == dict(record_bag=False, commands_enabled=False, allow_resume=False) - - -@pytest.mark.parametrize("ready,terminal", [("WAIT_START", "COMPLETE"), ("READY", "PASSED"), ("READY", "COMPLETE")]) -def test_ready_sent_once_even_when_node_repeats_ready(ready, terminal): - harness = LifecycleHarness([{"state": ready}, {"state": ready}, {"state": "SWEEP"}, {"state": terminal}]) - assert normalize_status(harness.run())["state"] == "COMPLETE" - assert harness.starts == 1 - - -def test_reference_wait_and_slow_fit_do_not_become_false_communication_faults(): - harness = LifecycleHarness([ - {"state": "READY"}, {"state": "REFERENCE_LOCKING"}, None, None, - {"state": "FITTING"}, None, None, {"state": "COMPLETE"}, - ], step=300.0) - assert harness.run()["state"] == "COMPLETE" - assert harness.starts == 1 - - -def test_missing_status_owner_is_not_confused_with_slow_status(): - harness = LifecycleHarness([{"state": "READY"}, None], step=20.0, publishers=0) - status = harness.run() - assert status["state"] == "FAILED" - assert status["reason"] == "calibration_node_process_exited" - - -def test_rejected_start_preserves_exact_reason(): - harness = LifecycleHarness([{"state": "READY"}], response=False) - assert harness.run()["reason"] == "calibration_start_rejected:fixture response" - assert harness.starts == 1 - - -def test_preview_never_calls_start(): - harness = LifecycleHarness([{"state": "READY"}, {"state": "READY"}, {"state": "ABORTED"}]) - assert harness.run(auto_start=False)["state"] == "ABORTED" - assert harness.starts == 0 - - -def test_start_service_response_has_its_own_deadline(): - harness = LifecycleHarness([{"state": "READY"}, None], step=20.0) - harness.request = lambda: SimpleNamespace(done=lambda: False) - assert harness.run()["reason"] == "calibration_start_response_timeout" - - -def test_explicit_startup_wait_allows_slow_checkpoint_then_starts_once(): - messages = [None, None, {"state": "READY"}, {"state": "COMPLETE"}] - default = LifecycleHarness(messages, step=65.0) - assert default.run()["reason"] == "calibration_node_initial_status_timeout" - assert default.starts == 0 - extended = LifecycleHarness(messages, step=65.0) - assert extended.run(startup_timeout=600.0)["state"] == "COMPLETE" - assert extended.starts == 1 - - -def test_explicit_startup_wait_preserves_start_service_deadline(): - harness = LifecycleHarness([None, {"state": "READY"}, None], step=65.0) - harness.request = lambda: SimpleNamespace(done=lambda: False) - assert harness.run(startup_timeout=600.0)["reason"] == "calibration_start_response_timeout" - - -def test_cli_passes_explicit_startup_wait_to_online_runner(monkeypatch): - calls = [] - monkeypatch.setattr(runner, "run_online", lambda config, **kw: calls.append(kw) or 0) - with pytest.raises(SystemExit) as error: - runner.main(["--config", str(PACKAGE / "config/g20_right_product.yaml"), - "--workspace", str(WORKSPACE), "--commands-disabled", - "--startup-timeout-seconds", "600"]) - assert error.value.code == 0 - assert calls[0]["startup_timeout"] == 600.0 - - -@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"]) -def test_cli_rejects_invalid_startup_wait_before_loading_devices(monkeypatch, value): - monkeypatch.setattr(runner, "load_product_config", lambda *a, **kw: pytest.fail("loaded devices")) - with pytest.raises(SystemExit) as error: - runner.main(["--startup-timeout-seconds", value]) - assert error.value.code == 2 - - -def test_progress_uses_profile_tags_including_secondary_view(): - profile = load_hand_profile(PACKAGE / "config/profiles/o12_right_16.yaml") - status = normalize_status({"state": "RUNNING", "task_name": "middle_roll_front", - "phase": "sweep", "cycle": 0, "attempt": 1, - "reference_locked": True}, profile=profile) - assert status["task"]["cycle"] == 1 - assert status["task"]["required_tag_ids_by_view"] == {"front": [0, 12], "side": [4, 8]} - text = render_status_zh(status) - assert "ID0/ID12" in text and "ID4/ID8" in text - assert "请勿移动" in text - - -def test_empty_preview_fields_do_not_break_status_rendering(): - status = normalize_status({"state": "WAIT_DEVICES", "attempt": None, - "cycle": None, "step_index": None}) - assert "等待设备" in render_status_zh(status) - - -def test_unified_progress_keeps_sdk_fault_diagnostics(): - text = render_status_zh(normalize_status({"state": "PAUSED", - "reason": "o12_active_motor_fault:channel=1", "error_faults": ["motor_1:overcurrent"]})) - assert "活动电机故障" in text - assert "motor_1:overcurrent" in text - assert "未分类" not in text - - -def _journal(path, *, version=ACQUISITION_POLICY_VERSION, digest="a" * 64, complete=True): - path.mkdir() - rows = [{"kind": "session_start", "profile_id": "VIRTUAL/right/layout/v1", - "serial_number": "TEST_001", "acquisition_policy_version": version, - "capture_schedule_version": CAPTURE_SCHEDULE_VERSION, - "pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION, - "protected_hashes": {"source": digest}}, - {"kind": "fixed_base_reference_locked"}] - if complete: - rows.append({"kind": "scan_unit_complete", "passed": True}) - (path / "raw_samples.jsonl").write_text("\n".join(json.dumps(row) for row in rows)) - - -def test_discovery_keeps_prior_evidence_and_ignores_new_empty_incompatible_sessions(tmp_path): - _journal(tmp_path / "01") - _journal(tmp_path / "02", complete=False) - _journal(tmp_path / "03", digest="b" * 64) - _journal(tmp_path / "04", version="unified_engine_v1") - _journal(tmp_path / "05", version="unified_engine_v2") - before = (tmp_path / "01/raw_samples.jsonl").read_bytes() - found = discover_resume_candidate(tmp_path, profile_id="VIRTUAL/right/layout/v1", - serial_number="TEST_001", protected_hashes={"source": "a" * 64}) - assert found == tmp_path / "01" - assert found.joinpath("raw_samples.jsonl").read_bytes() == before - - -def test_discovery_never_reuses_released_session_or_external_symlink(tmp_path): - _journal(tmp_path / "01") - (tmp_path / "latest_passed").symlink_to(tmp_path / "01", target_is_directory=True) - (tmp_path / "99").symlink_to(tmp_path / "01", target_is_directory=True) - assert discover_resume_candidate(tmp_path, profile_id="VIRTUAL/right/layout/v1", - serial_number="TEST_001", protected_hashes={"source": "a" * 64}) is None - - -def test_passed_capture_cannot_be_reused_to_fake_an_independent_session(tmp_path): - _journal(tmp_path / "01") - (tmp_path / "01/calibration_summary_zh.json").write_text(json.dumps({"result": "PASS"})) - assert discover_resume_candidate(tmp_path, profile_id="VIRTUAL/right/layout/v1", - serial_number="TEST_001", protected_hashes={"source": "a" * 64}) is None - - -@pytest.mark.parametrize("fresh_restart", [False, True]) -def test_partial_resume_keeps_more_complete_source_but_respects_explicit_restart(tmp_path, fresh_restart): - for name, count, resumed in (("01", 24, False), ("02", 8, not fresh_restart), ("03", 8, True)): - path = tmp_path / name - _journal(path, complete=False) - journal = path / "raw_samples.jsonl" - rows = [json.loads(line) for line in journal.read_text().splitlines()] - rows[0]["resume_checkpoint_requested"] = resumed - rows.extend({"kind": "scan_unit_complete", "task_name": f"task_{i}", - "cycle": 0, "direction": "increasing", "attempt": 1, "passed": True} - for i in range(count)) - # Repeated completion records must not inflate coverage. - if name == "03": - rows.extend([rows[-1]] * 30) - journal.write_text("\n".join(json.dumps(row) for row in rows)) - found = discover_resume_candidate(tmp_path, profile_id="VIRTUAL/right/layout/v1", - serial_number="TEST_001", protected_hashes={"source": "a" * 64}) - assert found == tmp_path / ("03" if fresh_restart else "01") - - -@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"]) -@pytest.mark.parametrize("terminal", ["COMPLETE", "PAUSED", "INTERRUPTED", "ABORT_TIMEOUT"]) -def test_common_online_process_chain_with_virtual_ros_and_no_hardware(monkeypatch, tmp_path, model, terminal): - """Use run_online itself, not just its selector or a schedule list.""" - import rclpy - from rclpy.signals import SignalHandlerOptions - from linkerhand_calibration.runtime import runner_support - from linkerhand_calibration.runtime.artifacts import completion as online_artifacts - import linkerhand_calibration.hikrobot_camera as camera - - config = load_product_config(PACKAGE / "config" / f"{model}_right_product.yaml", - workspace=WORKSPACE, check_can=False) - config = replace(config, output_root=tmp_path) - harness = LifecycleHarness([ - {"state": "READY"}, {"state": "REFERENCE_LOCKING"}, - {"state": "SWEEP"}, {"state": terminal, "reason": "synthetic_terminal"}, - ]) - harness.abort_client = SimpleNamespace(service_is_ready=lambda: True) - harness.start_client.call_async = lambda _request: harness.request() - harness.destroy_node = lambda: calls.append("destroy_monitor") - calls = [] - launched = [] - ros_active = [False] - abort_pending = [False] - abort_acknowledged = [False] - def abort(_request): - assert ros_active[0] - abort_pending[0] = True - calls.append("request_abort") - return SimpleNamespace(done=lambda: abort_acknowledged[0]) - harness.abort_client.call_async = abort - def init(*, signal_handler_options): - assert signal_handler_options == SignalHandlerOptions.SIGTERM - ros_active[0] = True - def shutdown(): - ros_active[0] = False - calls.append("shutdown") - def popen(command, **kwargs): - launched.append((command, kwargs)) - return harness - def spin(_node, **_kwargs): - if abort_pending[0]: - harness.now += 0.1 - if terminal != "ABORT_TIMEOUT": - abort_acknowledged[0] = True - calls.append("abort_acknowledged") - elif launched: - harness.spin() - if harness.status["state"] in {"INTERRUPTED", "ABORT_TIMEOUT"}: - raise KeyboardInterrupt - else: - harness.now += 0.5 - harness.count_publishers = lambda _topic: 1 if launched else 0 - monkeypatch.setattr(rclpy, "init", init) - monkeypatch.setattr(rclpy, "shutdown", shutdown) - monkeypatch.setattr(rclpy, "ok", lambda: ros_active[0]) - monkeypatch.setattr(rclpy, "spin_once", spin) - monkeypatch.setattr(runner.time, "monotonic", lambda: harness.now) - monkeypatch.setattr(runner.subprocess, "Popen", popen) - monkeypatch.setattr(runner_support, "CalibrationMonitor", lambda **kw: harness) - monkeypatch.setattr(runner_support, "stop_stack", lambda process: calls.append("stop_owned_stack")) - monkeypatch.setattr(runner_support, "overlay_environment", lambda path: {"TEST_OVERLAY": str(path)}) - monkeypatch.setattr(camera, "configure_fastdds_large_image_transport", lambda: None) - def finish(product, session, status): - assert product == config and status["state"] == "COMPLETE" - assert (session / "node_status.json").is_file() - calls.append("artifact_validation") - return {"JSON": "virtual_only"} - monkeypatch.setattr(online_artifacts, "finish_online_artifacts", finish) - - code = runner.run_online(config, allow_resume=False) - assert code == {"COMPLETE": 0, "PAUSED": 3, - "INTERRUPTED": 130, "ABORT_TIMEOUT": 130}[terminal] - assert harness.starts == 1 - assert ("artifact_validation" in calls) == (terminal == "COMPLETE") - assert calls[-3:] == ["stop_owned_stack", "destroy_monitor", "shutdown"] - if terminal == "INTERRUPTED": - assert calls[:2] == ["request_abort", "abort_acknowledged"] - elif terminal == "ABORT_TIMEOUT": - assert calls[0] == "request_abort" - assert "abort_acknowledged" not in calls - assert len(launched) == 1 - command, kwargs = launched[0] - assert "unified_calibration.launch.py" in command - assert f"profile_config:={config.profile_config}" in command - assert f"profile_config_expected_sha256:={config.profile_config_sha256}" in command - assert kwargs["start_new_session"] is True - if config.sdk_setup: - assert kwargs["env"]["TEST_OVERLAY"] == str(config.sdk_setup) - - -def test_session_directory_collision_never_sleeps_or_overwrites(monkeypatch, tmp_path): - from linkerhand_calibration.runtime import runner_support - monkeypatch.setattr(runner_support.time, "sleep", lambda _: pytest.fail("blocking collision sleep")) - first = runner_support.create_session_directory(tmp_path) - (first / "keep.txt").write_text("user evidence") - second = runner_support.create_session_directory(tmp_path) - assert first != second - assert (first / "keep.txt").read_text() == "user evidence" - - -@pytest.mark.parametrize("model", ["g20", "l6", "o6", "o12"]) -def test_launch_arguments_parse_with_ros_cli_and_preserve_sdk_config(model, tmp_path): - from ros2launch.api.api import parse_launch_arguments - from linkerhand_calibration.runtime.runner_support import launch_command - - config = load_product_config(PACKAGE / "config" / f"{model}_right_product.yaml", - workspace=WORKSPACE, check_can=False) - session = tmp_path / "session with spaces" - command = launch_command(config, session, record_bag=False, commands_enabled=False) - arguments = dict(parse_launch_arguments(command[4:])) - assert arguments["session_dir"] == str(session) - assert arguments["commands_enabled"] == "false" - for name, value in { - "vendor_sdk_config": config.sdk_config, - "sdk_config_expected_sha256": config.sdk_config_sha256, - "vendor_sdk_python_package": config.sdk_python_package, - "sdk_package_expected_sha256": config.sdk_package_sha256, - }.items(): - if value: - assert arguments[name] == str(value) - else: - assert name not in arguments - - -def test_runner_does_not_override_reviewed_speed_defaults(): - from linkerhand_calibration.runtime.runner_support import launch_command - for model in ("g20", "o6", "l6", "o12"): - config = load_product_config(PACKAGE / "config" / f"{model}_right_product.yaml", - workspace=WORKSPACE, check_can=False) - command = launch_command(config, Path("/tmp/virtual_session"), record_bag=False, commands_enabled=False) - speed = config.calibration_contract.typed_profile.motion.speed_parameters.get("baseline_u8") - if speed is not None: - assert f"calibration_speed:={int(speed)}" in command - else: - assert not any(arg.startswith("calibration_speed:=") for arg in command) - assert "index_roll_calibration_speed:=1" not in command - assert "index_flex_calibration_speed:=1" not in command - - -@pytest.mark.parametrize("model,layout", [("g20", "g20_right_19"), ("l6", "l6_right_8"), - ("o6", "o6_right_8"), ("o12", "o12_right_16")]) -def test_node_profile_handoff_checks_actual_yaml_and_hash(monkeypatch, model, layout): - import hashlib - from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry - from linkerhand_calibration.runtime.ros import entrypoint as calibration - path = PACKAGE / "config/profiles" / f"{layout}.yaml" - profile = load_hand_profile(path) - registered = get_default_registry().get(profile.key) - assert registered.profile == profile - forwarded = [] - monkeypatch.setattr(calibration, "run_profile_node", lambda declared, args: forwarded.append(args)) - assert not hasattr(calibration, "get_default_registry") - args = ["--profile-id", profile.key.profile_id, "--profile-config", str(path)] - with pytest.raises(ValueError, match="Profile changed"): - calibration.main(args + ["--profile-sha256", "0" * 64]) - assert not forwarded - calibration.main(args + ["--profile-sha256", hashlib.sha256(path.read_bytes()).hexdigest(), "--ros-args"]) - assert forwarded == [["--ros-args"]] - - -def test_owned_child_group_receives_shutdown_even_if_launch_parent_exited(monkeypatch): - from linkerhand_calibration.runtime import runner_support - import signal - signals = [] - process = SimpleNamespace(pid=12345, poll=lambda: 2, wait=lambda timeout: 2) - monkeypatch.setattr(runner_support.os, "killpg", lambda pid, sig: signals.append((pid, sig))) - runner_support.stop_stack(process) - assert signals == [(12345, signal.SIGINT)] - - -def test_native_profile_node_handoff_does_not_require_a_model_registration(tmp_path, monkeypatch): - import hashlib - import yaml - from linkerhand_calibration.runtime.ros import entrypoint as calibration - data = yaml.safe_load((PACKAGE / "config/profiles/l6_right_8.yaml").read_text()) - data.update(profile_id="VIRTUAL/right/no_python_model/v1", namespace="/virtual_calibration") - path = tmp_path / "new_profile.yaml" - path.write_text(yaml.safe_dump(data)) - def forbidden(): - raise AssertionError("new Profile must not look up a Python model") - import linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry as registry - monkeypatch.setattr(registry, "get_default_registry", forbidden) - calls = [] - monkeypatch.setattr(calibration, "run_profile_node", lambda profile, args: calls.append((profile, args))) - calibration.main(["--profile-id", data["profile_id"], "--profile-config", str(path), - "--profile-sha256", hashlib.sha256(path.read_bytes()).hexdigest(), "--ros-args"]) - assert calls[0][0].key.model == "VIRTUAL" - assert calls[0][1] == ["--ros-args"] - - -@pytest.mark.parametrize("model", ["o6", "o12"]) -def test_abort_never_commands_baseline_return_for_byte_or_rad(tmp_path, model): - from runtime_host_fixture import coordinator_fixture - host, clock = coordinator_fixture(tmp_path, model=model) - try: - safe = tuple((lo+hi)/2 for lo, hi in zip(host.profile.command.minimum_values, - host.profile.command.maximum_values)) - if host.profile.command.unit == "u8": - safe = tuple(float(round(value)) for value in safe) - host._publish_command(safe) - response = host.abort() - assert host.state == "ABORTED" and response.success - assert clock.positions == [safe, safe] - finally: - host.close() diff --git a/src/linkerhand_calibration/test/test_urdf_command_images.py b/src/linkerhand_calibration/test/test_urdf_command_images.py deleted file mode 100644 index ee0d008..0000000 --- a/src/linkerhand_calibration/test/test_urdf_command_images.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Independent elementary-FK truth for the original-URDF image graph.""" -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from o30_capture_fixture import synthetic_capture, rigid -from linkerhand_calibration.core.domain.capture_plan import configure_training, RAW_JOINT -from linkerhand_calibration.core.geometry.extrinsics import transform_matrix -from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points -from linkerhand_calibration.core.geometry.tag_pose.urdf_command_images import UrdfCommandImages, pose_vector -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles.observations import compile_tag_links - - -@pytest.fixture(scope='module') -def graph(): - profile, source, samples, offsets = synthetic_capture(include_same_finger_tags=True) - profile = configure_training(profile, RAW_JOINT) - model = UrdfKinematicModel(source) - matrix = np.array([[1400., 0., 800.], [0., 1400., 600.], [0., 0., 1.]]) - tags = {t.role: t for v in profile.vision.views for t in v.tags} - rows, selected = {}, set() - for sample in samples: - if sample.get('sample_phase') != 'steady' or sample['cycle'] not in (0, 1): - continue - key = (sample['task_name'], sample['cycle'], sample['direction'], - sample.get('segment_key', ''), sample['steady_index'], sample['joint']) - if key in selected: - continue - selected.add(key) - identity = sample['view'], sample['image_stamp_ns'] - row = rows.setdefault(identity, dict(kind='raw_observation_frame', - task_name=sample['task_name'], cycle=sample['cycle'], direction=sample['direction'], - view=sample['view'], image_stamp_ns=sample['image_stamp_ns'], sample_phase='steady', - accepted_sample_window_open=True, camera_matrix=matrix.tolist(), - command_vector=sample['command_vector_u8'], - command_direction_by_index=sample['command_direction_by_index'], tags={})) - for role, payload in sample['same_finger_poses_common'].items(): - pose = transform_matrix(**payload) - points = square_object_points(tags[role].size_m) @ pose[:3, :3].T + pose[:3, 3] - pixels = points @ matrix.T - row['tags'][role] = dict(corners_xy=(pixels[:, :2]/pixels[:, 2:]).tolist()) - bundle = UrdfCommandImages(profile, model, SimpleNamespace(transform=lambda view: np.eye(4)), - tuple(rows.values()), compile_tag_links(profile, model)) - truth = np.zeros(len(bundle.scale)) - truth[:6] = pose_vector(rigid((.11, -.08, .17), (.11, -.04, .72))) - for name, column in bundle.zero_columns.items(): - truth[column] = offsets[name] - for name, columns in bundle.curve_columns.items(): - channel = bundle.channels[name] - x, z = bundle.knots[name]/255, profile.command.baseline_values[channel]/255 - for direction in range(2): - values = ((1.0+.007*channel)*(x-z)+.09*(x*x-z*z) - +(1 if direction == 0 else -1)*.006*(x-z)*(1-x)) - values *= profile.command.joint_directions[channel] - bundle.set_initial_curve(truth, name, direction, values) - for column in bundle.mount_columns.values(): - truth[column:column+6] = pose_vector(rigid((.12, -.09, .2), (.009, -.025, .016))) - return bundle, truth - - -def test_original_urdf_graph_matches_independent_fk_including_thumb(graph): - bundle, truth = graph - residual = bundle.residual(truth) - assert np.sqrt(np.mean(residual**2)) < .12 - assert set(bundle.zero_offsets(truth)) == set(bundle.joints) - assert all(bundle.zero_offsets(truth)[name] == 0 for name in bundle.profile.zero.cad_zero_assumptions) - assert len(bundle.output_mappings(truth)) == 20 - - -def test_wrong_zero_cannot_be_hidden_without_changing_frozen_model(graph): - bundle, truth = graph - wrong = truth.copy() - wrong[bundle.zero_columns['thumb_cmc_yaw']] += np.radians(5) - assert np.sqrt(np.mean(bundle.residual(wrong)**2)) > 1. - - -def test_training_constructor_rejects_holdout(graph): - bundle, _ = graph - rows = [*bundle.rows, {**bundle.rows[0], 'cycle': 3}] - with pytest.raises(ValueError, match='training_partition_invalid'): - UrdfCommandImages(bundle.profile, bundle.model, bundle.extrinsics, rows, bundle.tag_links) - - -def test_five_root_axes_initialize_palm_without_endpoint_zero(graph): - bundle, truth = graph - parameters = bundle.initialize((0,)*6) - rotation_error = Rotation.from_rotvec(parameters[:3])*Rotation.from_rotvec(truth[:3]).inv() - assert rotation_error.magnitude() < np.radians(5) - # The parallel root lines alone cannot locate translation along their - # common axis. That component must come from the remaining chain images. - axis = bundle.model.axis_line('thumb_cmc_roll', zero_offsets={}, joint_angles={})[0] - axis = Rotation.from_rotvec(truth[:3]).apply(axis) - delta = parameters[3:6]-truth[3:6] - assert np.linalg.norm(delta-axis*(delta@axis)) < .015 - - -def test_analytic_chain_jacobian_matches_independent_central_differences(graph): - bundle, truth = graph - jac = bundle.jacobian(truth) - # Every zero/curve/mount column, including nonparallel thumb derivatives. - for column, scale in enumerate(bundle.scale): - upper, lower = truth.copy(), truth.copy() - step = 1e-5*scale - upper[column] += step; lower[column] -= step - numeric = (bundle.residual(upper)-bundle.residual(lower))/(2*step) - assert np.allclose(jac[:, column], numeric, atol=2e-5, rtol=1e-5), column - - -def test_physical_covariance_derivatives_match_independent_finite_differences(graph): - from linkerhand_calibration.core.fitting.raw_joint_uncertainty import pose_derivatives - bundle, truth = graph - commands, directions = bundle.commands[::51], bundle.directions[::51] - poses, derivatives, frames = pose_derivatives(bundle, truth, commands, directions) - step = 1e-6 - columns = list(range(6))+list(bundle.zero_columns.values())+[ - int(c[c>=0][0]) for c in bundle.curve_columns.values()] - for column in columns: - a, b = truth.copy(), truth.copy() - a[column] += step; b[column] -= step - upper, lower = bundle.link_poses(a, commands, directions), bundle.link_poses(b, commands, directions) - for link, (position, angular) in derivatives.items(): - np.testing.assert_allclose(position[:,:,column], - (upper[link][:,:3,3]-lower[link][:,:3,3])/(2*step), atol=1e-8) - rotated = upper[link][:,:3,:3] @ lower[link][:,:3,:3].transpose(0,2,1) - np.testing.assert_allclose(angular[:,:,column], - Rotation.from_matrix(rotated).as_rotvec()/(2*step), atol=1e-8) diff --git a/src/linkerhand_calibration/test/test_urdf_comparison.py b/src/linkerhand_calibration/test/test_urdf_comparison.py deleted file mode 100644 index efb13ff..0000000 --- a/src/linkerhand_calibration/test/test_urdf_comparison.py +++ /dev/null @@ -1,59 +0,0 @@ -import math -import xml.etree.ElementTree as ET - -import numpy as np -import pytest - -from linkerhand_calibration.urdf_comparison import compare_urdfs, _Model, main - - -def model(tmp_path, name, zero=0.): - path = tmp_path / name - path.write_text(f''' - - - - - - ''') - return path - - -def test_identical_geometry_has_zero_pose_difference(tmp_path): - a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf") - result = compare_urdfs(a, b) - assert result["maximum_origin_distance_mm"] == pytest.approx(0.) - assert result["maximum_orientation_difference_deg"] == pytest.approx(0.) - assert not result["is_accuracy_certificate"] - assert result["pose_count"] == 4 - assert result["scope"] == "same_urdf_joint_angles_not_sdk_feedback" - - -def test_reports_actual_link_frame_shift_not_just_parameter_delta(tmp_path): - a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf", .1) - result = compare_urdfs(a, b) - assert result["maximum_origin_distance_mm"] == pytest.approx(200 * math.sin(.05)) - assert result["maximum_orientation_difference_deg"] == pytest.approx(math.degrees(.1)) - - -def test_mimic_source_on_other_branch_is_resolved_recursively(tmp_path): - path = model(tmp_path, "a.urdf") - root = ET.parse(path).getroot() - ET.SubElement(root, "link", name="follower_link") - j = ET.SubElement(root, "joint", name="follower", type="revolute") - ET.SubElement(j, "parent", link="base") - ET.SubElement(j, "child", link="follower_link") - ET.SubElement(j, "axis", xyz="0 0 1") - ET.SubElement(j, "mimic", joint="motor", multiplier="0.5", offset="0.1") - ET.ElementTree(root).write(path) - poses = _Model(path).fk({"motor": .4}) - assert poses["follower_link"][0, 0] == pytest.approx(math.cos(.3)) - assert np.isfinite(poses["follower_link"]).all() - - -def test_does_not_overwrite_existing_files(tmp_path): - a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf", .1) - before = a.read_bytes() - with pytest.raises(SystemExit): - main(["--reference", str(a), "--candidate", str(b), "--output", str(a)]) - assert a.read_bytes() == before diff --git a/src/linkerhand_calibration/test/test_urdf_patch_engine.py b/src/linkerhand_calibration/test/test_urdf_patch_engine.py deleted file mode 100644 index 6f3cc13..0000000 --- a/src/linkerhand_calibration/test/test_urdf_patch_engine.py +++ /dev/null @@ -1,199 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import xml.etree.ElementTree as ET - -import pytest - -from linkerhand_calibration.core.urdf import ( - MujocoEqualityPatch, - UrdfJointPatch, - UrdfPatchSet, - apply_urdf_patch_text, - validate_urdf_mimic_ranges, - write_urdf_patches, -) - - -SOURCE_TEXT = """ - - - - - - - - - - - - - - - - - - - - - - - -""" - - -def _patches() -> UrdfPatchSet: - return UrdfPatchSet( - joints={ - "active": UrdfJointPatch( - origin_rpy="0 0.2 0", - limit_lower="0", - limit_upper="1.2", - ), - "passive": UrdfJointPatch( - mimic_multiplier="0.9", - mimic_offset="0.01", - ), - }, - mujoco_equalities={ - "couple": MujocoEqualityPatch( - polycoef="0.01 1.1 -0.2 0 0 0", - expected_joint1="passive", - expected_joint2="active", - ) - }, - ) - - -def test_patch_engine_changes_only_declared_attributes() -> None: - corrected = apply_urdf_patch_text( - SOURCE_TEXT, ET.fromstring(SOURCE_TEXT), _patches() - ) - - expected = SOURCE_TEXT - expected = expected.replace('rpy="0 0 0"', 'rpy="0 0.2 0"', 1) - expected = expected.replace('lower="-1"', 'lower="0"', 1) - expected = expected.replace('upper="1"', 'upper="1.2"', 1) - expected = expected.replace('multiplier="1"', 'multiplier="0.9"', 1) - expected = expected.replace('offset="0"', 'offset="0.01"', 1) - expected = expected.replace( - 'polycoef="0 1 0 0 0 0"', - 'polycoef="0.01 1.1 -0.2 0 0 0"', - 1, - ) - assert corrected == expected - assert '' in corrected - - -def test_patch_engine_rejects_missing_fields_and_wrong_topology() -> None: - root = ET.fromstring(SOURCE_TEXT) - with pytest.raises(ValueError, match="has no multiplier"): - apply_urdf_patch_text( - SOURCE_TEXT, - root, - UrdfPatchSet( - joints={"active": UrdfJointPatch(mimic_multiplier="1.1")} - ), - ) - with pytest.raises(ValueError, match="joint2 differs"): - apply_urdf_patch_text( - SOURCE_TEXT, - root, - UrdfPatchSet( - joints={}, - mujoco_equalities={ - "couple": MujocoEqualityPatch( - polycoef="0 1 0 0 0 0", - expected_joint2="wrong", - ) - }, - ), - ) - - -def test_patch_engine_atomically_writes_and_refuses_overwrite( - tmp_path: Path, -) -> None: - source = tmp_path / "source.urdf" - source.write_text(SOURCE_TEXT, encoding="utf-8") - destination = tmp_path / "output/corrected.urdf" - - result = write_urdf_patches( - source_urdf=source, - destination_urdf=destination, - patches=_patches(), - ) - - assert result == destination.resolve() - assert result.is_file() - with pytest.raises(ValueError, match="refusing to overwrite"): - write_urdf_patches( - source_urdf=source, - destination_urdf=destination, - patches=_patches(), - ) - - -def test_patch_engine_copies_referenced_and_complete_mesh_bundle( - tmp_path: Path, -) -> None: - source_dir = tmp_path / "source" - meshes = source_dir / "meshes" - meshes.mkdir(parents=True) - (meshes / "used.stl").write_bytes(b"used") - (meshes / "auxiliary.stl").write_bytes(b"auxiliary") - text = SOURCE_TEXT.replace( - '', - '' - '' - '', - ) - source = source_dir / "source.urdf" - source.write_text(text, encoding="utf-8") - destination = tmp_path / "output/corrected.urdf" - - write_urdf_patches( - source_urdf=source, - destination_urdf=destination, - patches=_patches(), - copy_complete_mesh_directory=True, - ) - - assert (destination.parent / "meshes/used.stl").read_bytes() == b"used" - assert ( - destination.parent / "meshes/auxiliary.stl" - ).read_bytes() == b"auxiliary" - - -def test_mimic_range_validation_resolves_full_chain(tmp_path: Path) -> None: - text = """ - - - -""" - valid = tmp_path / "valid.urdf" - valid.write_text(text, encoding="utf-8") - ranges = validate_urdf_mimic_ranges(valid) - assert ranges["b"] == pytest.approx((0.0, 2.0)) - assert ranges["c"] == pytest.approx((0.0, 1.0)) - - invalid = tmp_path / "invalid.urdf" - invalid.write_text(text.replace('upper="1.1"', 'upper="0.9"'), encoding="utf-8") - with pytest.raises(ValueError, match="c=0.100000000rad"): - validate_urdf_mimic_ranges(invalid) - - -def test_mimic_range_validation_never_enlarges_cad_excess( - tmp_path: Path, -) -> None: - reference = tmp_path / "reference.urdf" - reference.write_text(SOURCE_TEXT, encoding="utf-8") - validate_urdf_mimic_ranges(reference, reference_urdf=reference) - - worsened = tmp_path / "worsened.urdf" - worsened.write_text( - SOURCE_TEXT.replace('multiplier="1"', 'multiplier="1.1"', 1), - encoding="utf-8", - ) - with pytest.raises(ValueError, match="passive"): - validate_urdf_mimic_ranges(worsened, reference_urdf=reference) diff --git a/src/linkerhand_calibration/test/test_urdf_validation.py b/src/linkerhand_calibration/test/test_urdf_validation.py deleted file mode 100644 index 5c88b08..0000000 --- a/src/linkerhand_calibration/test/test_urdf_validation.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from linkerhand_calibration.core.urdf import ( - validate_runtime_curve_limits, - validate_structural_urdf_diff, -) - - -SOURCE = """ - - - - -""" - - -def test_structural_diff_accepts_only_declared_joint_attribute(tmp_path: Path) -> None: - source = tmp_path / "source.urdf" - corrected = tmp_path / "corrected.urdf" - source.write_text(SOURCE, encoding="utf-8") - corrected.write_text(SOURCE.replace('rpy="0 0 0"', 'rpy="0 0 0.1"'), encoding="utf-8") - changes = validate_structural_urdf_diff( - source, corrected, authorized_fields={"finger": ("origin.rpy",)} - ) - assert changes == ("finger.origin.rpy",) - validate_runtime_curve_limits(corrected, {"finger": (0.0, 0.5, 1.0)}) - - -def test_structural_diff_rejects_mesh_inertial_or_undeclared_limit(tmp_path: Path) -> None: - source = tmp_path / "source.urdf" - source.write_text(SOURCE, encoding="utf-8") - for name, changed in { - "mesh": SOURCE.replace("meshes/tip.stl", "meshes/other.stl"), - "mass": SOURCE.replace('mass value="1"', 'mass value="2"'), - "limit": SOURCE.replace('upper="1"', 'upper="1.1"'), - }.items(): - output = tmp_path / f"{name}.urdf" - output.write_text(changed, encoding="utf-8") - with pytest.raises(ValueError, match="unauthorized"): - validate_structural_urdf_diff( - source, output, authorized_fields={"finger": ("origin.rpy",)} - ) - - -def test_runtime_curve_cannot_exceed_corrected_limit(tmp_path: Path) -> None: - urdf = tmp_path / "source.urdf" - urdf.write_text(SOURCE, encoding="utf-8") - with pytest.raises(ValueError, match="exceeds"): - validate_runtime_curve_limits(urdf, {"finger": (0.0, 1.01)}) diff --git a/src/linkerhand_calibration/test/test_urdf_zero.py b/src/linkerhand_calibration/test/test_urdf_zero.py deleted file mode 100644 index 1731717..0000000 --- a/src/linkerhand_calibration/test/test_urdf_zero.py +++ /dev/null @@ -1,2284 +0,0 @@ -import math -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.extrinsics import ( - camera_info_fingerprint, - dump_three_camera_extrinsics, - load_three_camera_extrinsics, -) -from linkerhand_calibration.urdf_zero import ( - AXIS_JOINTS, - DIRECT_ZERO_JOINTS, - INHERITED_ZERO_JOINTS, - JointAxisMeasurement, - PalmOrientationMeasurement, - UrdfKinematicModel, - _angles_from_state, - _zero_sensitive_axis_error_rad, - anchor_right_19_mechanical_endpoint_curves, - baseline_hysteresis_by_cycle_rad, - derive_right_19_mechanical_endpoint_offsets, - fit_joint_axis_measurement, - fit_partial_palm_orientation_measurement, - fit_partial_palm_orientation_measurements, - fit_rotation_joint_curve, - select_cross_view_roll_direction_source, - solve_urdf_zero_offsets, - get_zero_calibration_profile, - get_right_19_thumb_zero_profile, - expand_right_19_thumb_zero_result_with_cad_fingers, - merge_right_19_thumb_zero_result, - write_zero_corrected_urdf, -) -from linkerhand_calibration.full_hand import ( - ACTIVE_JOINTS, - JOINT_SPECS, - MEASURED_JOINTS, - PASSIVE_JOINTS, - JointCurveFit, - get_hand_calibration_profile, -) -from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.urdf_input import ( - build_g20_urdf_input_payload, - load_g20_urdf_input, -) -from linkerhand_calibration.storage import atomic_write_json - - -PACKAGE_ROOT = Path(__file__).resolve().parents[1] -SOURCE_URDF = PACKAGE_ROOT / ( - "urdf/g20_left/linkerhand_g20_left.urdf" -) -RIGHT_SOURCE_URDF = PACKAGE_ROOT / ( - "urdf/g20_right/linkerhand_g20_right.urdf" -) - - -def test_cross_view_roll_direction_is_selected_once_by_group_consensus() -> None: - # Session 20260824_171302: the side residual in cycle 3 was 5.037 deg, - # just outside the 5 deg cone gate, while the other three side cycles - # passed and every front cycle was near 8 deg. Per-cycle selection mixed - # three side axes with one front axis and fabricated a 3.24 deg spread. - source = select_cross_view_roll_direction_source( - [math.radians(value) for value in (8.0078, 8.1710, 8.0712, 8.0236)], - [math.radians(value) for value in (4.9043, 4.9136, 5.0374, 4.9389)], - math.radians(5.0), - ) - - assert source == "secondary" - - -def test_cross_view_roll_direction_keeps_primary_without_consensus() -> None: - source = select_cross_view_roll_direction_source( - [math.radians(value) for value in (5.2, 4.8, 5.1, 4.9)], - [math.radians(value) for value in (4.7, 5.3, 4.8, 5.2)], - math.radians(5.0), - ) - - assert source == "primary" - - -def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch(): - parent = np.asarray([0.0, 0.0, 1.0]) - predicted = np.asarray([1.0, 0.0, 0.0]) - cone_mismatch = np.asarray( - [math.cos(math.radians(10.0)), 0.0, math.sin(math.radians(10.0))] - ) - zero_mismatch = np.asarray( - [math.cos(math.radians(3.0)), math.sin(math.radians(3.0)), 0.0] - ) - - assert _zero_sensitive_axis_error_rad( - predicted, cone_mismatch, parent - ) == pytest.approx(0.0, abs=1.0e-12) - assert math.degrees( - _zero_sensitive_axis_error_rad(predicted, zero_mismatch, parent) - ) == pytest.approx(3.0, abs=1.0e-9) - - -def test_zero_sensitive_axis_error_is_exact_for_an_oblique_cone() -> None: - parent = np.asarray([0.0, 0.0, 1.0]) - cone = math.radians(32.0) - phase = math.radians(7.0) - predicted = np.asarray([math.sin(cone), 0.0, math.cos(cone)]) - observed = Rotation.from_rotvec(parent * phase).apply(predicted) - - error = _zero_sensitive_axis_error_rad(predicted, observed, parent) - - assert math.degrees(error) == pytest.approx(7.0, abs=1.0e-9) - - -def _payload(transform: np.ndarray) -> dict[str, list[float]]: - return { - "translation_xyz_m": transform[:3, 3].tolist(), - "quaternion_xyzw": Rotation.from_matrix( - transform[:3, :3] - ).as_quat().tolist(), - } - - -def _arbitrary_tag_records() -> tuple[list[dict], np.ndarray, np.ndarray]: - axis_parent = np.asarray([0.23, -0.31, 0.922], dtype=float) - axis_parent /= np.linalg.norm(axis_parent) - centre_parent = np.asarray([0.012, -0.008, 0.021]) - radial = np.cross(axis_parent, np.asarray([0.7, 0.1, -0.2])) - radial = 0.035 * radial / np.linalg.norm(radial) - child_tag_mount = Rotation.from_euler( - "xyz", [1.1, -0.7, 0.45] - ) - common_from_parent = np.eye(4) - common_from_parent[:3, :3] = Rotation.from_euler( - "xyz", [-0.8, 0.55, 1.3] - ).as_matrix() - common_from_parent[:3, 3] = [0.41, -0.12, 0.73] - expected_axis = common_from_parent[:3, :3] @ axis_parent - expected_point = ( - common_from_parent[:3, :3] @ centre_parent - + common_from_parent[:3, 3] - ) - - commands = list(range(0, 256, 16)) + [255] - records = [] - for cycle in range(3): - for direction in ("decreasing", "increasing"): - for command in commands: - angle = math.radians(62.0) * (255.0 - command) / 255.0 - motion = Rotation.from_rotvec(axis_parent * angle) - relative_rotation = motion * child_tag_mount - relative_translation = centre_parent + motion.apply(radial) - child_common = common_from_parent.copy() - child_common[:3, :3] = ( - common_from_parent[:3, :3] - @ relative_rotation.as_matrix() - ) - child_common[:3, 3] = ( - common_from_parent[:3, :3] @ relative_translation - + common_from_parent[:3, 3] - ) - state = [255.0] * 20 - state[5] = float(command) - records.append( - { - "cycle": cycle, - "direction": direction, - "command_u8": command, - "relative_translation_xyz_m": relative_translation.tolist(), - "relative_quaternion_xyzw": relative_rotation.as_quat().tolist(), - "parent_pose_common": _payload(common_from_parent), - "child_pose_common": _payload(child_common), - "state_u8": state, - } - ) - return records, expected_axis, expected_point - - -def test_axis_and_curve_ignore_camera_and_tag_mount_rotation() -> None: - records, expected_axis, expected_point = _arbitrary_tag_records() - curve = fit_rotation_joint_curve(records, zero_command_u8=255) - measurement = fit_joint_axis_measurement( - "thumb_cmc_roll", records, cycle=0, zero_command_u8=255 - ) - - observed_axis = np.asarray(measurement.axis_common_xyz) - observed_point = np.asarray(measurement.point_common_xyz_m) - assert float(observed_axis @ expected_axis) > math.cos(math.radians(0.05)) - assert np.linalg.norm( - np.cross(observed_point - expected_point, expected_axis) - ) < 1.0e-6 - assert curve.angle_rad[255] == pytest.approx(0.0, abs=1.0e-9) - assert curve.angle_rad[0] == pytest.approx(math.radians(62.0), abs=1.0e-6) - - -def test_baseline_hysteresis_uses_only_revolute_axis_component() -> None: - off_axis_noise = Rotation.from_rotvec( - np.radians([1.2, 0.0, 0.2]) - ).as_quat().tolist() - records = [] - for cycle in range(3): - records.extend( - ( - { - "cycle": cycle, - "direction": "decreasing", - "command_u8": 255, - "relative_quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - }, - { - "cycle": cycle, - "direction": "increasing", - "command_u8": 255, - "relative_quaternion_xyzw": off_axis_noise, - }, - ) - ) - - full_pose = baseline_hysteresis_by_cycle_rad( - records, zero_command_u8=255 - ) - joint_angle = baseline_hysteresis_by_cycle_rad( - records, zero_command_u8=255, axis_xyz=[0.0, 0.0, 1.0] - ) - - assert math.degrees(full_pose[0]) == pytest.approx( - math.hypot(1.2, 0.2), abs=1.0e-9 - ) - assert math.degrees(joint_angle[0]) == pytest.approx(0.2, abs=1.0e-9) - - -def test_canonical_zero_preserves_opposite_direction_baseline_offset() -> None: - records = [] - commands = sorted({0, 127, 255, *range(0, 256, 16)}) - branch_offset = math.radians(1.0) - for cycle in range(3): - for direction in ("decreasing", "increasing"): - offset = branch_offset if direction == "increasing" else 0.0 - for command in commands: - angle = math.radians(50.0) * (127.0 - command) / 255.0 - angle += offset - rotation = Rotation.from_rotvec([0.0, 0.0, angle]) - records.append( - { - "cycle": cycle, - "direction": direction, - "command_u8": command, - "relative_quaternion_xyzw": rotation.as_quat().tolist(), - } - ) - - curve = fit_rotation_joint_curve( - records, - zero_command_u8=127, - canonical_zero_direction="decreasing", - ) - - assert curve.angle_rad == curve.decreasing_rad - assert curve.decreasing_rad[127] == pytest.approx(0.0, abs=1.0e-9) - assert curve.increasing_rad[127] == pytest.approx( - branch_offset, abs=1.0e-6 - ) - assert curve.maximum_hysteresis_rad == pytest.approx( - branch_offset, abs=1.0e-6 - ) - - -@pytest.mark.parametrize("joint", ["thumb_cmc_pitch", "index_mcp_pitch"]) -def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth( - joint: str, -) -> None: - records, expected_axis, expected_point = _arbitrary_tag_records() - parent_rotation = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) - axis_parent = parent_rotation.inv().apply(expected_axis) - tangent = np.cross(axis_parent, np.asarray([0.4, -0.2, 0.7])) - tangent /= np.linalg.norm(tangent) - # Reproduce monocular planar-PnP depth bias: the centre trajectory remains - # precise in its dominant directions but receives a command-correlated - # component that makes a free 3-D plane normal substantially wrong. - biased_records = [] - for record in records: - biased = dict(record) - point = np.asarray(record["relative_translation_xyz_m"], dtype=float) - depth_bias = 0.30 * float(point @ tangent) - biased["relative_translation_xyz_m"] = ( - point + depth_bias * axis_parent - ).tolist() - biased_records.append(biased) - - measurement = fit_joint_axis_measurement( - joint, biased_records, cycle=0, zero_command_u8=255 - ) - - observed_axis = np.asarray(measurement.axis_common_xyz) - observed_point = np.asarray(measurement.point_common_xyz_m) - assert abs(float(observed_axis @ expected_axis)) > math.cos( - math.radians(0.05) - ) - assert np.linalg.norm( - np.cross(observed_point - expected_point, expected_axis) - ) < 0.003 - assert measurement.rotation_circle_axis_difference_rad > math.radians(5.0) - assert measurement.plane_rms_m < 0.003 - assert measurement.radial_rms_m < 0.003 - - -def test_pose_axis_point_rejects_end_on_optical_depth_bias() -> None: - records, expected_axis, expected_point = _arbitrary_tag_records() - common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) - axis_parent = common_from_parent.inv().apply(expected_axis) - # Exact end-on depth is a gauge along the physical axis and therefore - # cannot alter the observable axis line. An oblique camera has a small - # irreducible coupling between monocular depth and radial position; that - # case must be bounded by the residual/holdout gates, not asserted to be - # exactly recoverable from one view. - view_normal_parent = axis_parent - view_normal_common = common_from_parent.apply(view_normal_parent) - biased = [] - for record in records: - changed = dict(record) - fraction = (255.0 - float(record["command_u8"])) / 255.0 - depth_bias = 0.03 * (fraction - 0.5) - changed["relative_translation_xyz_m"] = ( - np.asarray(record["relative_translation_xyz_m"], dtype=float) - + depth_bias * view_normal_parent - ).tolist() - biased.append(changed) - - measurement = fit_joint_axis_measurement( - "thumb_cmc_pitch", - biased, - cycle=0, - zero_command_u8=255, - view_normal_common_xyz=view_normal_common, - ) - - observed_point = np.asarray(measurement.point_common_xyz_m) - assert measurement.axis_point_source == "pose_trajectory_image_plane" - assert measurement.pose_axis_line_rms_m < 1.0e-6 - assert np.linalg.norm( - np.cross(observed_point - expected_point, expected_axis) - ) < 1.0e-6 - - -def test_side_roll_axis_point_ignores_real_screw_translation() -> None: - records, expected_axis, expected_point = _arbitrary_tag_records() - common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) - axis_parent = common_from_parent.inv().apply(expected_axis) - screw_records = [] - for record in records: - changed = dict(record) - fraction = (255.0 - float(record["command_u8"])) / 255.0 - changed["relative_translation_xyz_m"] = ( - np.asarray(record["relative_translation_xyz_m"], dtype=float) - + 0.008 * fraction * axis_parent - ).tolist() - screw_records.append(changed) - - measurement = fit_joint_axis_measurement( - "middle_mcp_roll_side", - screw_records, - cycle=0, - zero_command_u8=255, - constrained_circle_joints=frozenset({"middle_mcp_roll_side"}), - ) - - observed_point = np.asarray(measurement.point_common_xyz_m) - assert measurement.pose_axis_line_rms_m < 1.0e-6 - assert np.linalg.norm( - np.cross(observed_point - expected_point, expected_axis) - ) < 1.0e-6 - - -def test_splay_zero_interpolates_when_scan_does_not_hit_command_127() -> None: - records, expected_axis, _ = _arbitrary_tag_records() - assert not any(record["command_u8"] == 127 for record in records) - - curve = fit_rotation_joint_curve(records, zero_command_u8=127) - measurement = fit_joint_axis_measurement( - "index_mcp_roll", records, cycle=0, zero_command_u8=127 - ) - - observed_axis = np.asarray(measurement.axis_common_xyz) - assert abs(float(observed_axis @ expected_axis)) > math.cos( - math.radians(0.05) - ) - assert curve.angle_rad[127] == pytest.approx(0.0, abs=1.0e-9) - - -def test_passive_axis_can_use_trusted_upstream_direction_constraint() -> None: - records, expected_axis, expected_point = _arbitrary_tag_records() - common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) - physical_axis_parent = common_from_parent.inv().apply(expected_axis) - wrong_axis_parent = np.cross( - physical_axis_parent, np.asarray([0.2, 0.8, -0.1]) - ) - wrong_axis_parent /= np.linalg.norm(wrong_axis_parent) - mount = Rotation.from_quat(records[0]["relative_quaternion_xyzw"]) - contradictory = [] - for record in records: - changed = dict(record) - angle = math.radians(62.0) * ( - 255.0 - float(record["command_u8"]) - ) / 255.0 - changed["relative_quaternion_xyzw"] = ( - Rotation.from_rotvec(wrong_axis_parent * angle) * mount - ).as_quat().tolist() - contradictory.append(changed) - - measurement = fit_joint_axis_measurement( - "index_dip", - contradictory, - cycle=0, - zero_command_u8=255, - axis_common_constraint=expected_axis, - ) - - observed_axis = np.asarray(measurement.axis_common_xyz) - observed_point = np.asarray(measurement.point_common_xyz_m) - assert abs(float(observed_axis @ expected_axis)) > math.cos( - math.radians(0.05) - ) - assert np.linalg.norm( - np.cross(observed_point - expected_point, expected_axis) - ) < 1.0e-6 - - -def test_extrinsics_round_trip_keeps_camera_identity(tmp_path: Path) -> None: - cameras = { - view: { - "serial_number": f"SERIAL_{view}", - "width": 1624, - "height": 1240, - "intrinsics_sha256": camera_info_fingerprint( - width=1624, - height=1240, - camera_matrix=np.asarray( - [[1100.0, 0.0, 812.0], [0.0, 1099.0, 620.0], [0.0, 0.0, 1.0]] - ), - ), - } - for view in ("front", "side", "top") - } - transforms = {"front": np.eye(4), "side": np.eye(4), "top": np.eye(4)} - transforms["side"][:3, :3] = Rotation.from_euler("y", 0.7).as_matrix() - transforms["side"][:3, 3] = [0.2, 0.0, 0.1] - transforms["top"][:3, :3] = Rotation.from_euler("x", -0.9).as_matrix() - transforms["top"][:3, 3] = [-0.1, 0.3, 0.2] - destination = tmp_path / "extrinsics.yaml" - - dump_three_camera_extrinsics( - destination, - cameras=cameras, - front_from_view=transforms, - quality={ - "passed": True, - "reprojection_rms_px": 0.3, - "maximum_rotation_repeatability_deg": 0.2, - "maximum_translation_repeatability_m": 0.001, - "front_side_captures": 15, - "front_top_captures": 15, - }, - ) - loaded = load_three_camera_extrinsics(destination) - - assert loaded.cameras["front"].serial_number == "SERIAL_front" - assert np.allclose(loaded.transform("side"), transforms["side"]) - assert np.allclose(loaded.transform("top"), transforms["top"]) - assert loaded.camera_matches( - "front", - serial_number="SERIAL_front", - width=1624, - height=1240, - intrinsics_sha256=cameras["front"]["intrinsics_sha256"], - ) - assert not loaded.camera_matches( - "front", - serial_number="WRONG_SERIAL", - width=1624, - height=1240, - intrinsics_sha256=cameras["front"]["intrinsics_sha256"], - ) - - relaxed_destination = tmp_path / "relaxed_extrinsics.yaml" - relaxed_quality_limits = { - "reprojection_rms_px": 1.5, - "maximum_rotation_repeatability_deg": 0.3, - "maximum_translation_repeatability_m": 0.0015, - } - dump_three_camera_extrinsics( - relaxed_destination, - cameras=cameras, - front_from_view=transforms, - quality={ - "passed": True, - "reprojection_rms_px": 1.4, - "maximum_rotation_repeatability_deg": 0.2, - "maximum_translation_repeatability_m": 0.001, - "front_side_captures": 15, - "front_top_captures": 15, - }, - quality_limits=relaxed_quality_limits, - ) - with pytest.raises(ValueError, match="exceeds 1.2"): - load_three_camera_extrinsics(relaxed_destination) - relaxed = load_three_camera_extrinsics( - relaxed_destination, - quality_limits=relaxed_quality_limits, - ) - assert relaxed.quality["reprojection_rms_px"] == pytest.approx(1.4) - - -def _joint_origin(path: Path, name: str) -> tuple[np.ndarray, np.ndarray]: - joint = next( - element - for element in ET.parse(path).getroot().findall("joint") - if element.get("name") == name - ) - origin = joint.find("origin") - axis = joint.find("axis") - xyz = np.asarray([float(value) for value in origin.get("xyz").split()]) - rpy = np.asarray([float(value) for value in origin.get("rpy").split()]) - axis_xyz = np.asarray([float(value) for value in axis.get("xyz").split()]) - return np.block( - [ - [Rotation.from_euler("xyz", rpy).as_matrix(), xyz[:, None]], - [np.asarray([[0.0, 0.0, 0.0, 1.0]])], - ] - ), axis_xyz / np.linalg.norm(axis_xyz) - - -def _joint_limit(path: Path, name: str) -> tuple[float, float]: - joint = next( - element - for element in ET.parse(path).getroot().findall("joint") - if element.get("name") == name - ) - limit = joint.find("limit") - return float(limit.get("lower")), float(limit.get("upper")) - - -def test_urdf_writer_postmultiplies_joint_axis_and_never_overwrites(tmp_path: Path) -> None: - offset = math.radians(7.3) - destination = write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="G20_LEFT_001", - offsets_rad={"thumb_cmc_yaw": offset}, - timestamp="20260806_120000", - ) - original, axis = _joint_origin(SOURCE_URDF, "thumb_cmc_yaw") - corrected, _ = _joint_origin(destination, "thumb_cmc_yaw") - expected = original.copy() - expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec( - axis * offset - ).as_matrix() - - assert destination != SOURCE_URDF - assert np.allclose(corrected, expected, atol=1.0e-12) - with pytest.raises(ValueError, match="refusing to overwrite"): - write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="G20_LEFT_001", - offsets_rad={"thumb_cmc_yaw": offset}, - timestamp="20260806_120000", - ) - with pytest.raises(ValueError, match="original CAD URDF"): - write_zero_corrected_urdf( - source_urdf=destination, - output_directory=tmp_path, - serial_number="G20_LEFT_001", - offsets_rad={"thumb_cmc_yaw": offset}, - timestamp="20260806_120001", - ) - with pytest.raises(ValueError, match="finite and within"): - write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="G20_LEFT_001", - offsets_rad={"thumb_cmc_yaw": math.nan}, - timestamp="20260806_120002", - ) - - -def test_g20_json_handoff_preserves_existing_urdf_bytes(tmp_path: Path) -> None: - offsets = {"thumb_cmc_yaw": math.radians(7.3)} - direct = write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path / "direct", - serial_number="G20_LEFT_JSON_HANDOFF", - offsets_rad=offsets, - timestamp="20260902_140001", - ) - input_path = tmp_path / "g20_urdf_correction_input.json" - atomic_write_json( - input_path, - build_g20_urdf_input_payload( - side="left", - layout_id="legacy_11", - serial_number="G20_LEFT_JSON_HANDOFF", - source_urdf=SOURCE_URDF, - offsets_rad=offsets, - endpoint_anchored_offsets_rad={}, - ), - ) - loaded_offsets, loaded_endpoints = load_g20_urdf_input( - input_path, - source_urdf=SOURCE_URDF, - side="left", - layout_id="legacy_11", - serial_number="G20_LEFT_JSON_HANDOFF", - ) - via_json = write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path / "via_json", - serial_number="G20_LEFT_JSON_HANDOFF", - offsets_rad=loaded_offsets, - endpoint_anchored_offsets_rad=loaded_endpoints, - timestamp="20260902_140001", - ) - - assert direct.read_bytes() == via_json.read_bytes() - - -def test_urdf_writer_changes_only_the_16_active_zero_origins( - tmp_path: Path, -) -> None: - before = SOURCE_URDF.read_bytes() - offsets = { - name: math.radians(0.25 * (index + 1)) - for index, name in enumerate(ACTIVE_JOINTS) - } - destination = write_zero_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="G20_LEFT_001", - offsets_rad=offsets, - timestamp="20260807_180000", - ) - - assert len(offsets) == 16 - assert SOURCE_URDF.read_bytes() == before - for name in ACTIVE_JOINTS: - original, axis = _joint_origin(SOURCE_URDF, name) - corrected, corrected_axis = _joint_origin(destination, name) - expected = original.copy() - expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec( - axis * offsets[name] - ).as_matrix() - assert np.allclose(corrected, expected, atol=1.0e-12) - assert np.allclose(corrected_axis, axis, atol=1.0e-12) - for name in PASSIVE_JOINTS: - original, axis = _joint_origin(SOURCE_URDF, name) - corrected, corrected_axis = _joint_origin(destination, name) - assert np.allclose(corrected, original, atol=1.0e-12) - assert np.allclose(corrected_axis, axis, atol=1.0e-12) - # Numeric limits move with their coordinate zero; physical limits do not. - for name in (*ACTIVE_JOINTS, *PASSIVE_JOINTS): - assert np.asarray(_joint_limit(destination, name)) + offsets.get(name, 0.0) == pytest.approx( - _joint_limit(SOURCE_URDF, name) - ) - - -def _synthetic_curve(zero_command: int, travel: float) -> JointCurveFit: - values = np.asarray( - [travel * (255.0 - command) / 255.0 for command in range(256)] - ) - values -= values[zero_command] - data = tuple(float(value) for value in values) - return JointCurveFit( - angle_rad=data, - decreasing_rad=data, - increasing_rad=data, - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=0.0, - quality={}, - ) - - -def _solve_synthetic_offsets( - side: str, - offset_degrees: list[float], - *, - layout_id: str = "legacy_11", - inject_oblique_optical_depth_bias: bool = False, - inject_secondary_root_axis_bias_degrees: float = 0.0, - inject_secondary_root_point_bias_m: float = 0.0, - inject_observer_cone_bias_degrees: float = 0.0, - palm_orientation_frame_bias_degrees: float = 0.0, - maximum_systematic_axis_cone_bias_degrees: float | None = None, - pose_axis_line_rms_by_joint_m: dict[str, float] | None = None, - joint_maximum_offset_degrees: dict[str, float] | None = None, - validation_offset_bias_degrees: dict[str, float] | None = None, - static_output_offsets_degrees: dict[str, float] | None = None, - fixed_direct_offsets_degrees: dict[str, float] | None = None, - base_euler_xyz_rad: tuple[float, float, float] = (0.5, -0.4, 0.8), - base_translation_xyz_m: tuple[float, float, float] = (0.31, -0.19, 0.72), - solve_thumb_only: bool = False, -): - hand = get_hand_calibration_profile(side, layout_id) - zero = get_zero_calibration_profile(side, layout_id) - source = SOURCE_URDF if side == "left" else RIGHT_SOURCE_URDF - baseline = [255.0] * 20 - baseline[6:10] = [127.0] * 4 - curves = { - name: _synthetic_curve( - int(baseline[hand.joint_specs[name].motor_index]), - math.radians(50.0), - ) - for name in hand.measured_joints - } - if inject_secondary_root_axis_bias_degrees: - # Make the thumb root the higher-travel, directly observed direction, - # matching the real right-hand data where the short pinky splay arc is - # the less reliable root-axis orientation estimate. - curves["thumb_cmc_roll"] = _synthetic_curve( - int(baseline[hand.joint_specs["thumb_cmc_roll"].motor_index]), - math.radians(70.0), - ) - motor_by_joint = { - name: spec.motor_index for name, spec in hand.joint_specs.items() - } - offsets = { - name: math.radians(value) - for name, value in zip(zero.direct_zero_joints, offset_degrees) - } - model = UrdfKinematicModel(source) - base_rotation = Rotation.from_euler("xyz", base_euler_xyz_rad) - base_translation = np.asarray(base_translation_xyz_m) - measurements: list[JointAxisMeasurement] = [] - palm_orientation_measurements: list[PalmOrientationMeasurement] = [] - validation_cycle = 3 if hand.layout_id == "g20_right_19" else 2 - training_cycles = tuple(range(validation_cycle)) - for cycle in range(validation_cycle + 1): - cycle_offsets = dict(offsets) - if cycle == validation_cycle: - cycle_offsets.update( - { - name: cycle_offsets[name] + math.radians(value) - for name, value in ( - validation_offset_bias_degrees or {} - ).items() - } - ) - for joint in zero.axis_joints: - state = list(baseline) - if joint == "thumb_cmc_yaw": - state[5] = 145.0 - angles = _angles_from_state( - state, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=zero.inherited_zero_joints, - ) - axis, point = model.axis_line( - joint, zero_offsets=cycle_offsets, joint_angles=angles - ) - point_common = base_rotation.apply(point) + base_translation - if ( - inject_secondary_root_point_bias_m - and joint == f"{zero.reference_finger}_mcp_roll" - ): - # A repeatable monocular depth error on the second parallel - # root line must affect translation only, never palm rotation - # or the inferred thumb-roll zero. - point_common = point_common + base_rotation.apply( - np.asarray([0.0, 0.0, inject_secondary_root_point_bias_m]) - ) - view_normal_common = None - if ( - inject_oblique_optical_depth_bias - and joint in zero.phase_parent_joint - ): - parent_axis = model.axis_line( - zero.phase_parent_joint[joint], - zero_offsets=offsets, - joint_angles=angles, - )[0] - helper = ( - np.asarray([1.0, 0.0, 0.0]) - if abs(float(parent_axis[0])) < 0.8 - else np.asarray([0.0, 1.0, 0.0]) - ) - tilt_axis = np.cross(parent_axis, helper) - tilt_axis /= np.linalg.norm(tilt_axis) - view_normal = Rotation.from_rotvec( - math.radians(15.0) * tilt_axis - ).apply(parent_axis) - view_normal_common = tuple(base_rotation.apply(view_normal)) - # Simulate an independent planar-PnP depth error on the child - # Tag. It is large enough to drive the old 3-D phase solve to - # a configured offset bound. - point_common = point_common + 0.03 * np.asarray( - view_normal_common - ) - axis_common = base_rotation.apply(axis) - if ( - inject_observer_cone_bias_degrees - and joint == "thumb_cmc_pitch" - ): - parent_axis = model.axis_line( - zero.axis_parent_joint[joint], - zero_offsets=offsets, - joint_angles=angles, - )[0] - cone_normal = np.cross(axis, parent_axis) - cone_normal /= np.linalg.norm(cone_normal) - axis_common = base_rotation.apply( - Rotation.from_rotvec( - math.radians(inject_observer_cone_bias_degrees) - * cone_normal - ).apply(axis) - ) - if ( - inject_secondary_root_axis_bias_degrees - and joint == f"{zero.reference_finger}_mcp_roll" - ): - helper = np.asarray([0.0, 0.0, 1.0]) - if abs(float(axis_common @ helper)) > 0.8: - helper = np.asarray([0.0, 1.0, 0.0]) - bias_axis = np.cross(axis_common, helper) - bias_axis /= np.linalg.norm(bias_axis) - axis_common = Rotation.from_rotvec( - math.radians(inject_secondary_root_axis_bias_degrees) - * bias_axis - ).apply(axis_common) - measurements.append( - JointAxisMeasurement( - joint=joint, - cycle=cycle, - axis_common_xyz=tuple(axis_common), - point_common_xyz_m=tuple(point_common), - condition_state_u8=tuple(state), - plane_rms_m=0.0002, - radial_rms_m=0.0002, - rotation_circle_axis_difference_rad=math.radians(0.1), - view_normal_common_xyz=view_normal_common, - pose_axis_line_rms_m=( - pose_axis_line_rms_by_joint_m or {} - ).get(joint, 0.0), - ) - ) - for source_joint, model_joint in ( - hand.palm_orientation_sources or {} - ).items(): - state = list(baseline) - angles = _angles_from_state( - state, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=zero.inherited_zero_joints, - ) - axis, _ = model.axis_line( - model_joint, - zero_offsets=cycle_offsets, - joint_angles=angles, - ) - axis_common = base_rotation.apply(axis) - if palm_orientation_frame_bias_degrees: - bias_axis = np.asarray([0.3, -0.2, 0.4], dtype=float) - bias_axis /= np.linalg.norm(bias_axis) - axis_common = Rotation.from_rotvec( - math.radians(palm_orientation_frame_bias_degrees) - * bias_axis - ).apply(axis_common) - palm_orientation_measurements.append( - PalmOrientationMeasurement( - source_joint=source_joint, - model_joint=model_joint, - cycle=cycle, - axis_common_xyz=tuple(axis_common), - condition_state_u8=tuple(state), - observed_arc_rad=math.radians(45.0), - rotation_orthogonal_rms_rad=math.radians(0.1), - ) - ) - solve_profile = ( - get_right_19_thumb_zero_profile() if solve_thumb_only else zero - ) - result = solve_urdf_zero_offsets( - source_urdf=source, - measurements=[ - item for item in measurements if item.joint in solve_profile.axis_joints - ], - palm_orientation_measurements=palm_orientation_measurements, - curves=curves, - motor_by_joint=motor_by_joint, - hand_type=side, - tag_layout=layout_id, - joint_maximum_offset_rad={ - name: math.radians(value) - for name, value in (joint_maximum_offset_degrees or {}).items() - }, - training_cycles=training_cycles, - validation_cycle=validation_cycle, - maximum_systematic_axis_cone_bias_rad=( - None - if maximum_systematic_axis_cone_bias_degrees is None - else math.radians( - maximum_systematic_axis_cone_bias_degrees - ) - ), - static_output_zero_offsets_rad={ - name: math.radians(value) - for name, value in (static_output_offsets_degrees or {}).items() - }, - fixed_direct_zero_offsets_rad=( - None - if fixed_direct_offsets_degrees is None - else { - name: math.radians(value) - for name, value in fixed_direct_offsets_degrees.items() - if name in solve_profile.direct_zero_joints - } - ), - zero_profile=solve_profile, - ) - return zero, result - - -def test_right_19_solver_recovers_visual_targets_when_no_endpoint_anchor_is_supplied() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - zero, result = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - - assert len(zero.direct_zero_joints) == 16 - assert len(zero.axis_joints) == 21 - assert result.passed is True - finger_rolls = tuple( - name - for name in zero.direct_zero_joints - if name.endswith("_mcp_roll") and not name.startswith("thumb_") - ) - roll_common = float( - np.median( - [ - injected[zero.direct_zero_joints.index(name)] - for name in finger_rolls - ] - ) - ) - for name, expected in zip(zero.direct_zero_joints, injected): - if name in zero.fixed_direct_zero_offsets_rad: - expected = zero.fixed_direct_zero_offsets_rad[name] - elif name in finger_rolls: - expected -= roll_common - assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx( - expected, abs=0.05 - ) - assert math.degrees(result.all_active_offsets_rad["thumb_mcp"]) == pytest.approx( - -1.5, abs=0.05 - ) - assert zero.fixed_direct_zero_offsets_rad == {} - assert result.training_cycles == (0, 1, 2) - assert result.validation_cycle == 3 - assert all( - value == pytest.approx(0.0, abs=1.0e-12) - for value in result.offset_confidence_half_width_rad.values() - ) - assert result.observability_parameter_count == 22 - assert result.observability_rank == 22 - assert math.isfinite(result.observability_condition_number) - assert set(result.offset_covariance_rad2) == set(zero.direct_zero_joints) - - -def test_right_19_thumb_kernel_is_independent_and_matches_full_hand() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - _, full = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - fixed_direct_offsets_degrees={"thumb_mcp": -1.5}, - static_output_offsets_degrees={"thumb_cmc_roll": 2.0}, - ) - _, thumb = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - solve_thumb_only=True, - fixed_direct_offsets_degrees={"thumb_mcp": -1.5}, - static_output_offsets_degrees={"thumb_cmc_roll": 2.0}, - ) - - assert thumb.passed is True - assert set(thumb.direct_offsets_rad) == { - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - } - for name in thumb.direct_offsets_rad: - assert thumb.direct_offsets_rad[name] == pytest.approx( - full.direct_offsets_rad[name], abs=math.radians(0.05) - ) - - finger_offsets = { - name: full.direct_offsets_rad[name] - for name in get_zero_calibration_profile( - "right", "g20_right_19" - ).direct_zero_joints - if not name.startswith("thumb_") - } - thumb_only = merge_right_19_thumb_zero_result( - thumb_result=thumb, - preserved_offsets_rad=finger_offsets, - ) - full_hand = merge_right_19_thumb_zero_result( - thumb_result=thumb, - companion_result=full, - ) - for name in thumb.direct_offsets_rad: - assert thumb_only.direct_offsets_rad[name] == pytest.approx( - full_hand.direct_offsets_rad[name], abs=1.0e-12 - ) - standalone = expand_right_19_thumb_zero_result_with_cad_fingers(thumb) - assert all( - standalone.direct_offsets_rad[name] == 0.0 - for name in standalone.direct_offsets_rad - if not name.startswith("thumb_") - ) - - -def test_thumb_endpoint_output_uses_published_offset_safety_limit() -> None: - # The visual roll scalar may carry a root-frame gauge beyond the global - # limit. A separately validated mechanical endpoint is the value written - # to the URDF and therefore the value the configured safety gate guards. - injected = [ - 21.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - - _, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - solve_thumb_only=True, - fixed_direct_offsets_degrees={"thumb_mcp": -1.5}, - static_output_offsets_degrees={"thumb_cmc_roll": 3.45}, - ) - - assert result.passed is True - assert "thumb_cmc_roll" not in result.failure_reasons - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_roll"] - ) == pytest.approx(3.45, abs=0.01) - - -def test_right_19_thumb_kernel_is_invariant_to_rigid_hand_movement() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - arguments = { - "layout_id": "g20_right_19", - "solve_thumb_only": True, - "fixed_direct_offsets_degrees": {"thumb_mcp": -1.5}, - "static_output_offsets_degrees": {"thumb_cmc_roll": 2.0}, - } - _, first = _solve_synthetic_offsets("right", injected, **arguments) - _, moved = _solve_synthetic_offsets( - "right", - injected, - base_euler_xyz_rad=(-0.2, 0.7, -0.35), - base_translation_xyz_m=(-0.15, 0.42, 0.55), - **arguments, - ) - - assert first.passed is True - assert moved.passed is True - for name in first.direct_offsets_rad: - assert moved.direct_offsets_rad[name] == pytest.approx( - first.direct_offsets_rad[name], abs=math.radians(0.01) - ) - - -def test_right_19_independent_roll_output_cannot_change_yaw_solution() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - _, visual = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - _, independent = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - static_output_offsets_degrees={"thumb_cmc_roll": 3.4}, - ) - - assert math.degrees( - independent.direct_offsets_rad["thumb_cmc_roll"] - ) == pytest.approx(3.4) - assert independent.direct_offsets_rad["thumb_cmc_yaw"] == pytest.approx( - visual.direct_offsets_rad["thumb_cmc_yaw"], abs=1.0e-10 - ) - assert independent.direct_offsets_rad["thumb_cmc_pitch"] == pytest.approx( - visual.direct_offsets_rad["thumb_cmc_pitch"], abs=1.0e-10 - ) - - -def _partial_orientation_records( - *, - tag_mount: Rotation, - common_rotation: Rotation, - maximum_angle_deg: float = 30.0, - tag_offset_xyz_m: tuple[float, float, float] = (0.01, 0.02, -0.015), - include_child_pose_common: bool = False, -) -> list[dict[str, object]]: - axis_parent = np.asarray([0.0, 1.0, 0.0]) - parent_pose = { - "translation_xyz_m": [0.2, -0.1, 0.7], - "quaternion_xyzw": list(common_rotation.as_quat()), - } - records: list[dict[str, object]] = [] - commands = tuple(range(255, 174, -4)) - for direction, ordered in ( - ("decreasing", commands), - ("increasing", tuple(reversed(commands))), - ): - for command in ordered: - fraction = (255 - command) / (255 - commands[-1]) - angle = math.radians(maximum_angle_deg) * fraction - relative = ( - Rotation.from_rotvec(axis_parent * angle) * tag_mount - ) - relative_translation = Rotation.from_rotvec( - axis_parent * angle - ).apply(tag_offset_xyz_m) - state = [255.0] * 20 - state[1] = float(command) - record: dict[str, object] = { - "cycle": 0, - "direction": direction, - "command_u8": command, - "relative_quaternion_xyzw": list(relative.as_quat()), - "relative_translation_xyz_m": list( - relative_translation - ), - "parent_pose_common": parent_pose, - "state_u8": state, - } - if include_child_pose_common: - child_common = common_rotation * relative - record["child_pose_common"] = { - "translation_xyz_m": list( - np.asarray(parent_pose["translation_xyz_m"]) - + common_rotation.apply(relative_translation) - ), - "quaternion_xyzw": list(child_common.as_quat()), - } - records.append(record) - return records - - -def test_partial_palm_direction_ignores_fixed_tag_mount_pose() -> None: - common_rotation = Rotation.from_euler("xyz", [0.35, -0.2, 0.6]) - first = fit_partial_palm_orientation_measurement( - "index_mcp_pitch_front_axis", - "index_mcp_pitch", - _partial_orientation_records( - tag_mount=Rotation.from_euler("xyz", [0.1, 0.2, -0.4]), - common_rotation=common_rotation, - ), - cycle=0, - zero_command_u8=255, - ) - second = fit_partial_palm_orientation_measurement( - "index_mcp_pitch_front_axis", - "index_mcp_pitch", - _partial_orientation_records( - tag_mount=Rotation.from_euler("xyz", [-0.7, 0.45, 0.9]), - common_rotation=common_rotation, - tag_offset_xyz_m=(-0.035, 0.008, 0.041), - ), - cycle=0, - zero_command_u8=255, - ) - expected = common_rotation.apply([0.0, 1.0, 0.0]) - - assert abs(np.dot(first.axis_common_xyz, expected)) == pytest.approx( - 1.0, abs=1.0e-8 - ) - assert abs( - np.dot(first.axis_common_xyz, second.axis_common_xyz) - ) == pytest.approx(1.0, abs=1.0e-8) - - -def test_partial_palm_direction_is_not_weighted_by_dwell_frame_count() -> None: - records = _partial_orientation_records( - tag_mount=Rotation.from_euler("xyz", [0.2, -0.1, 0.3]), - common_rotation=Rotation.identity(), - ) - baseline = fit_partial_palm_orientation_measurement( - "index_mcp_pitch_front_axis", - "index_mcp_pitch", - records, - cycle=0, - zero_command_u8=255, - ) - source = next( - record - for record in records - if record["direction"] == "decreasing" - and record["command_u8"] == 223 - ) - biased = dict(source) - biased["relative_quaternion_xyzw"] = list( - ( - Rotation.from_rotvec([math.radians(2.0), 0.0, 0.0]) - * Rotation.from_quat(source["relative_quaternion_xyzw"]) - ).as_quat() - ) - fitted = fit_partial_palm_orientation_measurement( - "index_mcp_pitch_front_axis", - "index_mcp_pitch", - [*records, *([biased] * 200)], - cycle=0, - zero_command_u8=255, - ) - - difference = math.acos( - abs( - float( - np.clip( - np.dot(baseline.axis_common_xyz, fitted.axis_common_xyz), - -1.0, - 1.0, - ) - ) - ) - ) - assert difference < math.radians(0.25) - - -def test_partial_palm_direction_ignores_far_stroke_pnp_bias() -> None: - records = _partial_orientation_records( - tag_mount=Rotation.from_euler("xyz", [0.2, -0.1, 0.3]), - common_rotation=Rotation.identity(), - maximum_angle_deg=30.0, - ) - expected = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - records, - cycle=0, - zero_command_u8=255, - minimum_arc_rad=math.radians(5.0), - maximum_command_distance_u8=64, - ) - biased: list[dict[str, object]] = [] - for source in records: - record = dict(source) - if abs(int(record["command_u8"]) - 255) > 64: - rotation = Rotation.from_quat( - record["relative_quaternion_xyzw"] - ) - record["relative_quaternion_xyzw"] = list( - ( - Rotation.from_rotvec([math.radians(12.0), 0.0, 0.0]) - * rotation - ).as_quat() - ) - biased.append(record) - - fitted = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - biased, - cycle=0, - zero_command_u8=255, - minimum_arc_rad=math.radians(5.0), - maximum_command_distance_u8=64, - ) - - assert abs( - float(np.dot(expected.axis_common_xyz, fitted.axis_common_xyz)) - ) == pytest.approx(1.0, abs=1.0e-10) - - -def test_partial_palm_direction_uses_robust_full_stroke_local_motion() -> None: - common_rotation = Rotation.from_euler("xyz", [0.35, -0.2, 0.6]) - records = _partial_orientation_records( - tag_mount=Rotation.from_euler("xyz", [0.2, -0.1, 0.3]), - common_rotation=common_rotation, - maximum_angle_deg=48.0, - include_child_pose_common=True, - ) - expected = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - records, - cycle=0, - zero_command_u8=255, - ) - - biased: list[dict[str, object]] = [] - for source in records: - record = dict(source) - pose = dict(record["child_pose_common"]) - # Simulate a command-correlated planar-PnP branch curvature affecting - # fewer than one quarter of the full-stroke local increments. The - # zero-adjacent quality trajectory remains unchanged. - if int(record["command_u8"]) <= 187: - rotation = Rotation.from_quat(pose["quaternion_xyzw"]) - amount = math.radians(8.0) * ( - (187 - int(record["command_u8"])) / 12.0 - ) - pose["quaternion_xyzw"] = list( - ( - Rotation.from_rotvec([amount, 0.0, 0.0]) * rotation - ).as_quat() - ) - record["child_pose_common"] = pose - biased.append(record) - - fitted = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - biased, - cycle=0, - zero_command_u8=255, - ) - difference = math.acos( - abs( - float( - np.clip( - np.dot(expected.axis_common_xyz, fitted.axis_common_xyz), - -1.0, - 1.0, - ) - ) - ) - ) - - assert expected.axis_estimator == "robust_full_stroke_local_so3_v1" - assert expected.incremental_pair_count >= 24 - assert fitted.axis_estimator == expected.axis_estimator - assert difference < math.radians(0.25) - - -def test_full_stroke_palm_axis_tracks_reestablished_common_frame() -> None: - tag_mount = Rotation.from_euler("xyz", [0.2, -0.1, 0.3]) - first_common = Rotation.from_euler("xyz", [0.35, -0.2, 0.6]) - repositioning = Rotation.from_euler("xyz", [-0.18, 0.27, -0.41]) - second_common = repositioning * first_common - first = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - _partial_orientation_records( - tag_mount=tag_mount, - common_rotation=first_common, - maximum_angle_deg=48.0, - include_child_pose_common=True, - ), - cycle=0, - zero_command_u8=255, - ) - second = fit_partial_palm_orientation_measurement( - "thumb_cmc_roll_top_axis", - "thumb_cmc_roll", - _partial_orientation_records( - tag_mount=tag_mount, - common_rotation=second_common, - maximum_angle_deg=48.0, - include_child_pose_common=True, - ), - cycle=0, - zero_command_u8=255, - ) - expected_second = repositioning.apply(first.axis_common_xyz) - - assert first.axis_estimator == "robust_full_stroke_local_so3_v1" - assert second.axis_estimator == first.axis_estimator - assert abs(float(np.dot(expected_second, second.axis_common_xyz))) == ( - pytest.approx(1.0, abs=1.0e-8) - ) - - -def test_right_19_top_axis_pair_holdout_ignores_shared_frame_bias() -> None: - injected = [0.0] * 16 - _, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - palm_orientation_frame_bias_degrees=8.0, - ) - - assert result.passed is True - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_yaw"] - ) == pytest.approx(0.0, abs=0.05) - - -def test_partial_palm_direction_rejects_too_short_visible_arc() -> None: - records = _partial_orientation_records( - tag_mount=Rotation.identity(), - common_rotation=Rotation.identity(), - maximum_angle_deg=5.0, - ) - - with pytest.raises(ValueError, match="visible rotation arc"): - fit_partial_palm_orientation_measurement( - "index_mcp_pitch_front_axis", - "index_mcp_pitch", - records, - cycle=0, - zero_command_u8=255, - ) - - -def test_partial_palm_direction_uses_three_of_four_visible_sources() -> None: - sources = { - f"{finger}_mcp_pitch_front_axis": f"{finger}_mcp_pitch" - for finger in ("index", "middle", "ring", "pinky") - } - records = { - source: _partial_orientation_records( - tag_mount=Rotation.from_euler( - "xyz", [0.1 * index, -0.2, 0.3] - ), - common_rotation=Rotation.identity(), - maximum_angle_deg=(5.0 if index == 3 else 30.0), - ) - for index, source in enumerate(sources) - } - - fitted, rejected = fit_partial_palm_orientation_measurements( - sources=sources, - records_by_joint=records, - motor_by_source={source: index + 1 for index, source in enumerate(sources)}, - baseline_command_u8=(255,) * 20, - cycles=(0,), - minimum_sources=3, - ) - - assert len(fitted) == 3 - assert len(rejected) == 1 - assert next(iter(rejected)).startswith("pinky_mcp_pitch_front_axis") - - -def test_right_19_offsets_are_invariant_to_rigid_hand_repositioning() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - _, first = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - _, moved = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - base_euler_xyz_rad=(-0.25, 0.55, -0.35), - base_translation_xyz_m=(-0.12, 0.28, 0.91), - ) - - assert moved.direct_offsets_rad == pytest.approx( - first.direct_offsets_rad, abs=1.0e-8 - ) - - -def test_right_19_roll_and_verified_contact_zeros_use_independent_endpoints() -> None: - hand = get_hand_calibration_profile("right", "g20_right_19") - curves = { - name: _synthetic_curve(255, math.radians(50.0)) - for name in hand.measured_joints - } - for finger in ("index", "middle", "ring", "pinky"): - curves[f"{finger}_mcp_pitch"] = _synthetic_curve( - 255, math.radians(71.0) - ) - curves[f"{finger}_pip"] = _synthetic_curve( - 255, math.radians(103.0) - ) - curves["thumb_mcp"] = _synthetic_curve(255, math.radians(70.0)) - curves["thumb_cmc_roll"] = _synthetic_curve( - 255, math.radians(76.0) - ) - curves["thumb_cmc_yaw"] = _synthetic_curve( - 255, math.radians(91.0) - ) - curves["thumb_cmc_pitch"] = _synthetic_curve( - 255, math.radians(47.0) - ) - - offsets = derive_right_19_mechanical_endpoint_offsets( - RIGHT_SOURCE_URDF, curves - ) - - assert set(offsets) == { - "thumb_cmc_roll", - "thumb_mcp", - *( - f"{finger}_{suffix}" - for finger in ("index", "middle", "ring", "pinky") - for suffix in ("mcp_pitch", "pip") - ), - } - assert math.degrees(offsets["thumb_mcp"]) == pytest.approx( - math.degrees(1.25) - 70.0 - ) - assert math.degrees(offsets["thumb_cmc_roll"]) == pytest.approx( - math.degrees(1.39) - 76.0 - ) - assert not {"thumb_cmc_yaw", "thumb_cmc_pitch"}.intersection(offsets) - assert math.degrees(offsets["index_mcp_pitch"]) == pytest.approx( - math.degrees(1.22) - 71.0 - ) - assert math.degrees(offsets["index_pip"]) == pytest.approx( - math.degrees(1.75) - 103.0 - ) - - -def _settled_endpoint_records( - travel_rad: float, - *, - increasing_travel_rad: float | None = None, -) -> list[dict[str, object]]: - fixed_mounting = Rotation.from_euler("xyz", [0.7, -0.4, 1.1]) - mounted_axis = Rotation.from_euler("xyz", [-0.3, 0.8, 0.2]).apply( - [0.0, 0.0, 1.0] - ) - result: list[dict[str, object]] = [] - for direction, branch_travel in ( - ("decreasing", travel_rad), - ( - "increasing", - travel_rad - if increasing_travel_rad is None - else increasing_travel_rad, - ), - ): - endpoint = fixed_mounting * Rotation.from_rotvec( - mounted_axis * branch_travel - ) - for command, rotation in ((0, endpoint), (255, fixed_mounting)): - result.append( - { - "cycle": 0, - "direction": direction, - "requested_command_u8": command, - "feedback_u8": float(command), - "relative_quaternion_xyzw": rotation.as_quat().tolist(), - } - ) - return result - - -def test_right_19_endpoint_curve_scale_uses_direct_rigid_rotation() -> None: - hand = get_hand_calibration_profile("right", "g20_right_19") - names = { - "thumb_cmc_roll", - "thumb_mcp", - *( - f"{finger}_{suffix}" - for finger in ("index", "middle", "ring", "pinky") - for suffix in ("mcp_pitch", "pip") - ), - } - projected_travel = math.radians(105.832) - direct_travel = math.radians(103.820) - curves = { - name: _synthetic_curve(255, projected_travel) - for name in hand.measured_joints - } - records = { - name: _settled_endpoint_records(direct_travel) for name in names - } - - anchored = anchor_right_19_mechanical_endpoint_curves( - curves, - records, - feedback_endpoint_joints=frozenset({"thumb_cmc_roll"}), - ) - - assert curves["middle_pip"].angle_rad[0] == pytest.approx( - projected_travel - ) - for name in names: - assert anchored[name].angle_rad[0] == pytest.approx(direct_travel) - assert anchored[name].angle_rad[255] == pytest.approx(0.0) - assert anchored[name].circle[ - "mechanical_endpoint_direct_travel_rad" - ] == pytest.approx(direct_travel) - assert anchored["thumb_cmc_pitch"] is curves["thumb_cmc_pitch"] - - -def test_right_19_endpoint_curve_scale_rejects_direction_disagreement() -> None: - hand = get_hand_calibration_profile("right", "g20_right_19") - names = { - "thumb_cmc_roll", - "thumb_mcp", - *( - f"{finger}_{suffix}" - for finger in ("index", "middle", "ring", "pinky") - for suffix in ("mcp_pitch", "pip") - ), - } - curves = { - name: _synthetic_curve(255, math.radians(103.0)) - for name in hand.measured_joints - } - records = { - name: _settled_endpoint_records(math.radians(103.0)) - for name in names - } - records["middle_pip"] = _settled_endpoint_records( - math.radians(103.0), - increasing_travel_rad=math.radians(104.5), - ) - - with pytest.raises(ValueError, match="endpoint directions disagree"): - anchor_right_19_mechanical_endpoint_curves( - curves, - records, - feedback_endpoint_joints=frozenset({"thumb_cmc_roll"}), - ) - - -def test_right_15_finger_roll_limit_applies_to_independent_deviation() -> None: - # A shared electrical centre belongs to the four-motor common datum; the - # strict 3 deg assembly guard applies to finger-to-finger deviations. - injected = [ - 2.0, -3.0, 4.0, -1.5, - 6.0, -1.0, 0.7, - 6.4, -0.7, -0.8, - 5.7, 0.6, 0.9, - 6.2, -1.0, -0.6, - ] - - zero, result = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - - assert result.passed is True - finger_rolls = tuple( - name - for name in zero.direct_zero_joints - if name.endswith("_mcp_roll") and not name.startswith("thumb_") - ) - roll_common = float( - np.median( - [ - injected[zero.direct_zero_joints.index(name)] - for name in finger_rolls - ] - ) - ) - for name, expected in zip(zero.direct_zero_joints, injected): - if name in zero.fixed_direct_zero_offsets_rad: - expected = zero.fixed_direct_zero_offsets_rad[name] - elif name in finger_rolls: - expected -= roll_common - assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx( - expected, abs=0.05 - ) - assert np.median( - [result.direct_offsets_rad[name] for name in finger_rolls] - ) == pytest.approx(0.0, abs=1.0e-10) - - -def test_right_19_roll_gauge_at_search_bound_uses_relative_deviation() -> None: - # One raw roll reaches the diagnostic search edge, as in session 160326, - # while all four physical deviations from their shared datum remain safe. - injected = [ - 2.0, -3.0, 4.0, -1.5, - 9.3, -1.0, 0.0, - 8.4, -0.7, 0.0, - 8.7, 0.6, 0.0, - 8.8, -1.0, 0.0, - ] - - zero, result = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - - assert result.passed is True - finger_rolls = tuple( - name - for name in zero.direct_zero_joints - if name.endswith("_mcp_roll") and not name.startswith("thumb_") - ) - assert max( - abs(math.degrees(result.direct_offsets_rad[name])) - for name in finger_rolls - ) < 3.0 - assert not result.failure_reasons - - -def test_right_19_holdout_never_changes_frozen_training_offsets() -> None: - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - zero, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - validation_offset_bias_degrees={"thumb_cmc_roll": 2.0}, - ) - - finger_rolls = tuple( - name - for name in zero.direct_zero_joints - if name.endswith("_mcp_roll") and not name.startswith("thumb_") - ) - roll_common = float( - np.median( - [ - injected[zero.direct_zero_joints.index(name)] - for name in finger_rolls - ] - ) - ) - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_roll"] - ) == pytest.approx(injected[0], abs=0.05) - assert result.validation_cycle not in result.training_cycles - - -def test_failed_thumb_holdout_does_not_blame_fixed_partial_scope_zeros() -> None: - zero = get_zero_calibration_profile("right", "g20_right_19") - injected = [ - 2.0, -3.0, 4.0, -1.5, - 1.0, -1.0, 0.7, - 0.8, -0.7, -0.8, - -0.5, 0.6, 0.9, - 1.1, -1.0, -0.6, - ] - frozen = { - name: injected[index] - for index, name in enumerate(zero.direct_zero_joints) - if not name.startswith("thumb_") - } - - _, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - validation_offset_bias_degrees={"thumb_cmc_yaw": 8.0}, - fixed_direct_offsets_degrees=frozen, - ) - - assert result.passed is False - assert result.failure_reasons == { - "palm_orientation": "palm_orientation_holdout_too_large" - } - assert set(frozen).isdisjoint(result.failure_reasons) - - -def test_right_19_palm_pose_and_16_zero_observation_jacobian_is_full_rank() -> None: - """Guard the reviewed 6-palm-DOF plus 16-static-zero observability.""" - zero = get_zero_calibration_profile("right", "g20_right_19") - model = UrdfKinematicModel(RIGHT_SOURCE_URDF) - parameter_count = 6 + len(zero.direct_zero_joints) - - def line_observations(parameters: np.ndarray) -> np.ndarray: - palm_rotation = Rotation.from_rotvec(parameters[:3]) - palm_translation = parameters[3:6] - offsets = { - name: float(value) - for name, value in zip( - zero.direct_zero_joints, parameters[6:] - ) - } - result: list[float] = [] - for joint in zero.axis_joints: - axis, point = model.axis_line( - joint, zero_offsets=offsets, joint_angles={} - ) - axis = palm_rotation.apply(axis) - point = palm_rotation.apply(point) + palm_translation - # An oriented 3-D line is represented by its direction and - # Pluecker moment. The moment is invariant to choosing a different - # point along the same axis, so no unobservable along-axis Tag - # placement is accidentally counted as information. - result.extend(float(value) for value in axis) - result.extend(float(value) for value in np.cross(point, axis)) - return np.asarray(result, dtype=float) - - origin = np.zeros(parameter_count, dtype=float) - step = 1.0e-6 - jacobian = np.column_stack( - [ - ( - line_observations( - origin + np.eye(parameter_count, dtype=float)[index] * step - ) - - line_observations( - origin - np.eye(parameter_count, dtype=float)[index] * step - ) - ) - / (2.0 * step) - for index in range(parameter_count) - ] - ) - - assert parameter_count == 22 - assert np.linalg.matrix_rank(jacobian, tol=1.0e-7) == parameter_count - - -def test_right_19_urdf_writer_rejects_inconsistent_source_mimic_limits(tmp_path: Path) -> None: - zero = get_zero_calibration_profile("right", "g20_right_19") - offsets = {name: math.radians(1.0) for name in zero.direct_zero_joints} - before = RIGHT_SOURCE_URDF.read_bytes() - with pytest.raises(ValueError, match="mimic reachable range exceeds limits"): - write_zero_corrected_urdf( - source_urdf=RIGHT_SOURCE_URDF, output_directory=tmp_path, - serial_number="G20_RIGHT_019", offsets_rad=offsets, - timestamp="20260818_120000") - assert RIGHT_SOURCE_URDF.read_bytes() == before - assert not list(tmp_path.glob("*.urdf")) - - -def test_small_stable_offsets_are_validated_without_rewriting_urdf_zero() -> None: - zero, result = _solve_synthetic_offsets("right", [0.1] * 7) - assert result.passed is True - static_policy = { - **zero.fixed_direct_zero_offsets_rad, - **zero.static_output_zero_offsets_rad, - } - for name, value in result.direct_offsets_rad.items(): - assert value == pytest.approx(static_policy.get(name, 0.0)) - - -def test_profiles_do_not_contain_hard_coded_thumb_zero_offsets() -> None: - right = get_zero_calibration_profile("right") - right_19 = get_zero_calibration_profile("right", "g20_right_19") - left = get_zero_calibration_profile("left") - - assert "thumb_cmc_roll" not in right.fixed_direct_zero_offsets_rad - assert "thumb_cmc_roll" not in right.static_output_zero_offsets_rad - assert "thumb_cmc_roll" not in left.fixed_direct_zero_offsets_rad - assert "thumb_cmc_roll" not in left.static_output_zero_offsets_rad - assert "thumb_mcp" not in right_19.fixed_direct_zero_offsets_rad - - -def test_reference_finger_roll_static_zero_is_fixed_to_upright_cad() -> None: - zero, result = _solve_synthetic_offsets( - "right", [2.0, -2.0, 2.0, 1.0, 4.0, 1.0, 1.0] - ) - reference_roll = f"{zero.reference_finger}_mcp_roll" - assert result.passed is True - assert math.degrees(result.direct_offsets_rad[reference_roll]) == pytest.approx( - 0.0, abs=1.0e-12 - ) - - -def test_biased_short_root_axis_does_not_tilt_entire_zero_solution() -> None: - zero, result = _solve_synthetic_offsets( - "right", - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - inject_secondary_root_axis_bias_degrees=15.0, - ) - - assert result.passed is True - expected = dict( - zip(zero.direct_zero_joints, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0]) - ) - expected.update( - { - name: math.degrees(value) - for name, value in { - **zero.fixed_direct_zero_offsets_rad, - **zero.static_output_zero_offsets_rad, - }.items() - } - ) - for name, value in expected.items(): - assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx( - value, abs=0.05 - ) - - -def test_root_line_depth_bias_does_not_change_thumb_roll_zero() -> None: - zero, result = _solve_synthetic_offsets( - "right", - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - inject_secondary_root_point_bias_m=0.02, - ) - - assert result.passed is True - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_roll"] - ) == pytest.approx(2.0, abs=0.05) - assert result.direct_offsets_rad[f"{zero.reference_finger}_mcp_roll"] == 0.0 - - -def test_thumb_mcp_is_not_hard_coded_to_original_cad_zero() -> None: - offsets = [2.0, -3.0, 4.0, -40.0, 1.0, -1.0, 2.0, *([0.0] * 9)] - _, result = _solve_synthetic_offsets( - "right", - offsets, - layout_id="g20_right_19", - joint_maximum_offset_degrees={"thumb_mcp": 45.0}, - ) - - assert result.passed is True - assert math.degrees(result.direct_offsets_rad["thumb_mcp"]) == pytest.approx( - -40.0, abs=0.05 - ) - assert all( - math.degrees(value) == pytest.approx(-40.0, abs=0.05) - for value in result.cycle_offsets_rad["thumb_mcp"] - ) - assert result.validation_error_by_joint_rad["thumb_ip"] < math.radians(0.05) - for name in ("thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch"): - assert abs(math.degrees(result.direct_offsets_rad[name])) <= 20.0 - for name in ("pinky_mcp_pitch", "pinky_pip"): - assert abs(math.degrees(result.direct_offsets_rad[name])) <= 3.0 - assert result.direct_offsets_rad["pinky_mcp_roll"] == pytest.approx(0.0) - - -def test_end_on_phase_rejects_oblique_monocular_depth_bias() -> None: - zero, result = _solve_synthetic_offsets( - "right", - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - inject_oblique_optical_depth_bias=True, - ) - - assert result.passed is True - expected = dict( - zip(zero.direct_zero_joints, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0]) - ) - expected.update( - { - name: math.degrees(value) - for name, value in { - **zero.fixed_direct_zero_offsets_rad, - **zero.static_output_zero_offsets_rad, - }.items() - } - ) - for name, value in expected.items(): - assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx( - value, abs=0.05 - ) - - -def test_zero_solver_rejects_axis_cone_geometry_that_a_zero_cannot_fix() -> None: - _, result = _solve_synthetic_offsets( - "right", - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - inject_observer_cone_bias_degrees=8.0, - ) - - assert result.passed is False - assert result.failure_reasons["thumb_cmc_yaw"] == ( - "zero_axis_cone_mismatch_too_large" - ) - - -def test_right_19_audits_stable_cross_view_cone_bias_without_retry() -> None: - injected = [0.0] * 16 - _, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - inject_observer_cone_bias_degrees=8.0, - maximum_systematic_axis_cone_bias_degrees=15.0, - ) - - assert result.passed is True - assert math.degrees( - result.axis_cone_mismatch_by_joint_rad["thumb_cmc_yaw"] - ) == pytest.approx(8.0) - assert result.axis_cone_bias_classification_by_joint[ - "thumb_cmc_yaw" - ] == "stable_cross_view_or_planar_pnp_bias" - - -def test_right_19_thumb_yaw_uses_top_axis_pair_not_front_cone_bias() -> None: - zero = get_zero_calibration_profile("right", "g20_right_19") - injected = [0.0] * len(zero.direct_zero_joints) - injected[zero.direct_zero_joints.index("thumb_cmc_roll")] = 3.611 - injected[zero.direct_zero_joints.index("thumb_cmc_yaw")] = -1.319 - zero, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - inject_observer_cone_bias_degrees=3.803, - maximum_systematic_axis_cone_bias_degrees=15.0, - ) - - assert result.passed is True - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_roll"] - ) == pytest.approx(3.611, abs=0.05) - assert math.degrees( - result.direct_offsets_rad["thumb_cmc_yaw"] - ) == pytest.approx(-1.319, abs=0.05) - assert math.degrees( - result.axis_cone_mismatch_by_joint_rad["thumb_cmc_yaw"] - ) == pytest.approx(3.803, abs=0.01) - - -def test_right_19_still_rejects_gross_cross_view_cone_mismatch() -> None: - injected = [0.0] * 16 - _, result = _solve_synthetic_offsets( - "right", - injected, - layout_id="g20_right_19", - inject_observer_cone_bias_degrees=16.0, - maximum_systematic_axis_cone_bias_degrees=15.0, - ) - - assert result.passed is False - assert result.failure_reasons["thumb_cmc_yaw"] == ( - "zero_axis_cone_mismatch_too_large" - ) - - -def test_right_19_finger_roll_common_gauge_is_removed_before_limits() -> None: - zero = get_zero_calibration_profile("right", "g20_right_19") - injected = [0.0] * len(zero.direct_zero_joints) - deviations = { - "index_mcp_roll": -0.4, - "middle_mcp_roll": 0.7, - "ring_mcp_roll": 0.1, - "pinky_mcp_roll": -0.1, - } - for name, deviation in deviations.items(): - injected[zero.direct_zero_joints.index(name)] = 18.0 + deviation - - _, result = _solve_synthetic_offsets( - "right", injected, layout_id="g20_right_19" - ) - - assert result.passed is True - common = float(np.median(list(deviations.values()))) - for name, deviation in deviations.items(): - expected = deviation - common - assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx( - expected, abs=0.05 - ) - - -def test_zero_solver_rejects_unreliable_parallel_axis_line_phase() -> None: - _, result = _solve_synthetic_offsets( - "right", - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - pose_axis_line_rms_by_joint_m={"thumb_mcp": 0.002}, - ) - - assert result.passed is False - assert result.failure_reasons["thumb_cmc_pitch"] == ( - "zero_phase_axis_line_residual_too_large" - ) - - -def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None: - zero = get_zero_calibration_profile("left") - baseline = [255.0] * 20 - baseline[6:10] = [127.0] * 4 - curves = { - name: _synthetic_curve( - int(baseline[JOINT_SPECS[name].motor_index]), - math.radians(50.0), - ) - for name in MEASURED_JOINTS - } - motor_by_joint = { - name: spec.motor_index for name, spec in JOINT_SPECS.items() - } - true_offsets = { - name: math.radians(value) - for name, value in zip( - DIRECT_ZERO_JOINTS, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0] - ) - } - model = UrdfKinematicModel(SOURCE_URDF) - base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8]) - base_translation = np.asarray([0.31, -0.19, 0.72]) - measurements = [] - yaw_axis_without_clearance = None - yaw_axis_with_clearance = None - for cycle in range(3): - for joint in AXIS_JOINTS: - state = list(baseline) - if joint == "thumb_cmc_yaw": - state[5] = 145.0 - angles = _angles_from_state( - state, curves=curves, motor_by_joint=motor_by_joint - ) - axis, point = model.axis_line( - joint, - zero_offsets=true_offsets, - joint_angles=angles, - ) - if joint == "thumb_cmc_yaw": - yaw_axis_with_clearance = axis.copy() - baseline_angles = _angles_from_state( - baseline, curves=curves, motor_by_joint=motor_by_joint - ) - yaw_axis_without_clearance = model.axis_line( - joint, - zero_offsets=true_offsets, - joint_angles=baseline_angles, - )[0] - measurements.append( - JointAxisMeasurement( - joint=joint, - cycle=cycle, - axis_common_xyz=tuple(base_rotation.apply(axis)), - point_common_xyz_m=tuple( - base_rotation.apply(point) + base_translation - ), - condition_state_u8=tuple(state), - plane_rms_m=0.0002, - radial_rms_m=0.0002, - rotation_circle_axis_difference_rad=math.radians(0.1), - ) - ) - - result = solve_urdf_zero_offsets( - source_urdf=SOURCE_URDF, - measurements=measurements, - curves=curves, - motor_by_joint=motor_by_joint, - ) - - assert math.degrees( - math.acos( - np.clip(yaw_axis_with_clearance @ yaw_axis_without_clearance, -1.0, 1.0) - ) - ) > 1.0 - assert result.passed is True - static_policy = { - **zero.fixed_direct_zero_offsets_rad, - **zero.static_output_zero_offsets_rad, - } - for name, expected in true_offsets.items(): - if name in static_policy: - expected = static_policy[name] - assert result.direct_offsets_rad[name] == pytest.approx( - expected, abs=math.radians(0.05) - ) - assert set(result.all_active_offsets_rad) == set(ACTIVE_JOINTS) - assert result.all_active_offsets_rad["thumb_mcp"] == pytest.approx( - 0.0, abs=math.radians(0.05) - ) - for target in INHERITED_ZERO_JOINTS: - assert result.all_active_offsets_rad[target] == pytest.approx( - 0.0, abs=1.0e-12 - ) - assert result.offset_uncertainty_rad.keys() == result.direct_offsets_rad.keys() - - -def test_right_solver_uses_pinky_and_phase_ignores_length_and_depth_bias() -> None: - hand = get_hand_calibration_profile("right") - zero = get_zero_calibration_profile("right") - baseline = [255.0] * 20 - baseline[6:10] = [127.0] * 4 - curves = { - name: _synthetic_curve( - int(baseline[hand.joint_specs[name].motor_index]), - math.radians(50.0), - ) - for name in hand.measured_joints - } - motor_by_joint = { - name: spec.motor_index for name, spec in hand.joint_specs.items() - } - true_offsets = { - name: math.radians(value) - for name, value in zip( - zero.direct_zero_joints, - [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0], - ) - } - model = UrdfKinematicModel(RIGHT_SOURCE_URDF) - base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8]) - base_translation = np.asarray([0.31, -0.19, 0.72]) - measurements: list[JointAxisMeasurement] = [] - for cycle in range(3): - states: dict[str, list[float]] = {} - lines: dict[str, tuple[np.ndarray, np.ndarray]] = {} - for joint in zero.axis_joints: - state = list(baseline) - if joint == "thumb_cmc_yaw": - state[5] = 145.0 - states[joint] = state - angles = _angles_from_state( - state, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=zero.inherited_zero_joints, - ) - lines[joint] = model.axis_line( - joint, - zero_offsets=true_offsets, - joint_angles=angles, - ) - original_lines = { - name: (axis.copy(), point.copy()) - for name, (axis, point) in lines.items() - } - for observer, parent in zero.phase_parent_joint.items(): - parent_axis, parent_point = lines[parent] - original_parent_axis, original_parent_point = original_lines[parent] - child_axis, original_child_point = original_lines[observer] - radial = original_child_point - original_parent_point - radial -= original_parent_axis * float( - radial @ original_parent_axis - ) - # Preserve angular phase while deliberately corrupting link radius - # and along-axis depth. These components must not move a zero. - lines[observer] = ( - child_axis, - parent_point + 1.25 * radial + 0.02 * parent_axis, - ) - for joint in zero.axis_joints: - axis, point = lines[joint] - measurements.append( - JointAxisMeasurement( - joint=joint, - cycle=cycle, - axis_common_xyz=tuple(base_rotation.apply(axis)), - point_common_xyz_m=tuple( - base_rotation.apply(point) + base_translation - ), - condition_state_u8=tuple(states[joint]), - plane_rms_m=0.0002, - radial_rms_m=0.0002, - rotation_circle_axis_difference_rad=math.radians(0.1), - ) - ) - - result = solve_urdf_zero_offsets( - source_urdf=RIGHT_SOURCE_URDF, - measurements=measurements, - curves=curves, - motor_by_joint=motor_by_joint, - hand_type="right", - ) - - assert result.passed is True - static_policy = { - **zero.fixed_direct_zero_offsets_rad, - **zero.static_output_zero_offsets_rad, - } - for name, expected in true_offsets.items(): - if name in static_policy: - expected = static_policy[name] - assert result.direct_offsets_rad[name] == pytest.approx( - expected, abs=math.radians(0.05) - ) - for target in zero.inherited_zero_joints: - assert result.all_active_offsets_rad[target] == pytest.approx( - 0.0, abs=1.0e-12 - ) - assert set(result.all_active_offsets_rad) == set(hand.active_joints) diff --git a/src/linkerhand_calibration/test/test_virtual_control_contract.py b/src/linkerhand_calibration/test/test_virtual_control_contract.py deleted file mode 100644 index f964df3..0000000 --- a/src/linkerhand_calibration/test/test_virtual_control_contract.py +++ /dev/null @@ -1,84 +0,0 @@ -"""A renamed rig and reordered SDK still use the shared capture/artifact chain.""" - -from dataclasses import replace -import hashlib - -import pytest - -from linkerhand_calibration.core.domain.profile import ProfileKey -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel -from linkerhand_calibration.profiles import dump_hand_profile, load_hand_profile -from linkerhand_calibration.runtime.adapters.base import HardwareHealth -from linkerhand_calibration.runtime.adapters.legacy_byte_sdk import LegacyByteSdkAdapter -from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session -from linkerhand_calibration.runtime.artifacts.reader import load_unified_mapper -from engine_capture_fixture import collect_with_engine -from test_profile_finalization import virtual_capture - - -def test_reordered_channels_views_tasks_and_same_sdk_command_to_exported_fk(tmp_path): - original, source, rows, _ = virtual_capture(tmp_path, "l6_right_8") - order = (4, 2, 0, 5, 1, 3) - channel = {old: new for new, old in enumerate(order)} - views = dict(zip(original.vision.view_names, ("inspection", "oblique", "overhead"))) - def vector(values): - return tuple(values[index] for index in order) if values else () - def groups(values): - return tuple(tuple(channel[index] for index in group) for group in values) - def commands(values): - return tuple((channel[index], value) for index, value in values) - layout = original.command - layout = replace(layout, - **{name: vector(getattr(layout, name)) for name in ( - "names", "baseline_u8", "baseline", "lower_bounds", "upper_bounds", - "feedback_lower_bounds", "feedback_upper_bounds", "sdk_to_joint_direction", "maximum_velocity")}, - command_index_by_joint={joint: channel[index] for joint, index in layout.command_index_by_joint.items()}, - disabled_indices=frozenset(channel[index] for index in layout.disabled_indices), - speed_slot_by_command_index={channel[index]: slot for index, slot in layout.speed_slot_by_command_index.items()}) - profile = replace(original, key=ProfileKey("VIRTUAL", "right", "reordered_interfaces"), - namespace="/virtual_interfaces", command=layout, - vision=replace(original.vision, - views=tuple(replace(view, name=views[view.name]) for view in reversed(original.vision.views)), - extrinsic_reference_view=views[original.vision.extrinsic_reference_view]), - measurement=replace(original.measurement, measurements={name: replace(spec, - view=None if spec.view is None else views[spec.view]) for name, spec in original.measurement.measurements.items()}), - motion=replace(original.motion, tasks=tuple(replace(task, - command_index=channel[task.command_index], view=views[task.view], - auxiliary_commands=commands(task.auxiliary_commands), preparation_groups=groups(task.preparation_groups), - entry_waypoints=tuple(commands(waypoint) for waypoint in task.entry_waypoints)) - for task in reversed(original.motion.tasks)), return_groups=groups(original.motion.return_groups), - resume_verification_waypoints=tuple(replace(waypoint, command=vector(waypoint.command), - tag_ids_by_view={views[view]: ids for view, ids in waypoint.tag_ids_by_view.items()}) - for waypoint in original.motion.resume_verification_waypoints))) - path = tmp_path/"virtual_profile.yaml" - path.write_text(dump_hand_profile(profile)) - profile = load_hand_profile(path) - observations = [{**row, "view": views[row["view"]], - "state_u8": vector(row["state_u8"]), - "command_vector_u8": vector(row["command_vector_u8"])} for row in rows] - _, records = collect_with_engine(profile, observations) - hashes = {name: "a"*64 for name in profile.artifacts.protected_input_fields} - hashes["source_urdf_sha256"] = hashlib.sha256(source.read_bytes()).hexdigest() - payload, _, correction = finalize_profile_session(profile=profile, session_dir=tmp_path/"session", - serial_number="VIRTUAL_ONLY", source_urdf=source, protected_inputs=hashes, - records=records, standard_loader=lambda _: None) - mapper = load_unified_mapper(tmp_path/"session/release_manifest.json") - model = UrdfKinematicModel(correction.path) - writes = [] - sdk = LegacyByteSdkAdapter(profile.command, publish=writes.append, - set_speed_callback=lambda *_: None, health_callback=lambda: HardwareHealth(True, True)) - for values in ((31, 63, 95, 127, 159, 191), (191, 159, 127, 95, 63, 31)): - sdk.publish_position(values) - assert writes[-1] == tuple(values) - feedback = sdk.parse_feedback(tuple(reversed(profile.command.names)), tuple(reversed(values))) - assert feedback == tuple(values) - angles = dict(zip(mapper.urdf_joint_names, mapper.map_positions(values))) - # The identical native SDK vector indexes the certified JSON and FK. - direct = {joint: table["angle_rad"][values[table["sdk_channel"]]] - for joint, table in payload["joints"].items()} - assert angles == pytest.approx(direct, abs=1e-9) - active = {joint: value for joint, value in direct.items() if model.joints[joint].mimic_joint is None} - resolved = model.resolve_angles(active) - assert {joint: resolved[joint] for joint in direct} == pytest.approx(direct, abs=1e-9) - for joint, index in profile.command.command_index_by_joint.items(): - assert payload["joints"][joint]["sdk_channel"] == index diff --git a/src/linkerhand_calibration/test/test_visual_motion.py b/src/linkerhand_calibration/test/test_visual_motion.py deleted file mode 100644 index 3069117..0000000 --- a/src/linkerhand_calibration/test/test_visual_motion.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Explicit vision policy remains supported independently of O30's default.""" - -from dataclasses import replace -from types import SimpleNamespace -import copy -import json - -import numpy as np -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.adapters import HardwareHealth -from linkerhand_calibration.runtime.adapters.command_seed import read_o30_command, load_command_seed -from linkerhand_calibration.runtime.motion_execution import MotionCommand, MotionExecution -from linkerhand_calibration.runtime.safety import SafetyPolicy, SafetySample -from linkerhand_calibration.runtime.visual_motion import VisualMotionObserver, validate_visual_motion_evidence - - -@pytest.fixture -def profile(): - return as_vision(load_bundled_hand_profile("o30_right_18", scope="full")) - - -def as_vision(profile): - return replace(profile, acquisition=replace(profile.acquisition, motion_observation="vision")) - - -def observe(observer, joint, now, value, command, *, version=1): - spec = observer.specs[joint] - parent = np.array([[100.,100.],[120.,100.],[120.,120.],[100.,120.]]) - corners = {spec.parent_role: parent, spec.child_role: parent+[40.+value/5., 0.]} - observer.observe(spec.view, int((now+100)*1e9), corners, - now=now, epoch=1, version=version, command=command) - - -def execution(profile, *, phase="prepare", start_value=0, target_value=80, returns=()): - initial = list(profile.command.baseline_values) - initial[0] = start_value - target = initial.copy() - target[0] = target_value - observer = VisualMotionObserver(profile) - observe(observer, "thumb_cmc_roll", 0., start_value, initial, version=0) - command = MotionCommand(phase, tuple(target), 200., command_index=0, - observed_joints=("thumb_cmc_roll",), visual_return_joints=returns) - assert observer.begin(command, initial, now=0., version=1) - segment = MotionExecution(profile, command, initial_command=initial, - initial_feedback=(), now=0., identity="vision", visual_observer=observer) - return observer, segment - - -@pytest.mark.parametrize("feedback", [(), (0.,)*20, (999.,)*20]) -def test_real_image_motion_completes_with_missing_stuck_or_bad_feedback(profile, feedback): - observer, segment = execution(profile) - safety = SafetyPolicy(profile) - for tick in range(240): - now = tick/30 - command = segment.sample(now) - observe(observer, "thumb_cmc_roll", now, command[0], command) - segment.observe(feedback, stamp=None, now=now) - assert safety.evaluate(SafetySample(now, None, command, feedback, - HardwareHealth(True, True, feedback_fresh=False))).safe - assert not observer.check(segment, now) - assert segment.arrived(now) - - -def test_command_alone_cannot_prove_actual_movement(profile): - observer, segment = execution(profile) - for tick in range(240): - now = tick/30 - command = segment.sample(now) - observe(observer, "thumb_cmc_roll", now, 0., command) - segment.observe((), stamp=None, now=now) - reason = observer.check(segment, now) - if reason: - assert reason == "visual_motion_not_observed:thumb_cmc_roll" - assert not segment.arrived(now) - break - else: - pytest.fail("static images passed a significant motion request") - - -def test_missing_tag_and_device_faults_still_stop(profile): - observer, segment = execution(profile) - assert observer.check(segment, 1.01).startswith("visual_motion_unavailable") - base = SafetySample(3., None, profile.command.baseline_values, None, HardwareHealth(True, True)) - for changed, code in [ - (replace(base, health=HardwareHealth(False, True)), "sdk_disconnected"), - (replace(base, health=HardwareHealth(True, True, active_faults=("overcurrent",))), "hardware_fault"), - (replace(base, competing_controller=True), "duplicate_controller"), - (replace(base, reference_moved=True), "fixed_reference_moved"), - (replace(base, operator_abort=True), "operator_abort"), - (replace(base, command=(999.,)*20), "physical_range_exceeded")]: - assert SafetyPolicy(profile).evaluate(changed).code == code - - -def test_group_witnesses_and_middle_only_segment(profile): - observer = VisualMotionObserver(profile) - start = list(profile.command.baseline_values) - start[2:6] = [0,80,0,0] - target = start.copy() - target[2:6] = [255]*4 - group = next(t for t in profile.motion.tasks if t.key == 'fingers_mcp_roll_front') - motion = MotionCommand("sweep", tuple(target), 200., observed_joints=group.joints) - names = observer.names_for(motion, start) - assert set(names) == set(group.joints) - for name in names: - if name != "ring_mcp_roll": - observe(observer, name, 0., 0., start) - assert not observer.available(names, 0.) - lower = start.copy() - lower[3] = 0 - independent = MotionCommand("sweep", tuple(lower), 200., observed_joints=("middle_mcp_roll",)) - assert observer.names_for(independent, start) == ("middle_mcp_roll",) - - -def test_visual_zero_return_does_not_release_avoidance_early(profile): - observer, segment = execution(profile, phase="task_exit", start_value=255, target_value=0, - returns=("thumb_cmc_roll",)) - pose = segment.command.target - observe(observer, "thumb_cmc_roll", .01, 0., pose) - observer.zeros[("thumb_cmc_roll", pose)] = observer.latest["thumb_cmc_roll"] - for tick in range(1, 470): - now = tick/30 - command = segment.sample(now) - observe(observer, "thumb_cmc_roll", now, max(40, command[0]), command) - segment.observe((), stamp=None, now=now) - observer.check(segment, now) - assert not segment.arrived(now) - for tick in range(470, 490): - now = tick/30 - observe(observer, "thumb_cmc_roll", now, 0., pose) - observer.check(segment, now) - assert segment.arrived(now) - - -def stable_records(profile): - observer, segment = execution(profile, phase="steady") - records = observer.drain() - for tick in range(220): - now = tick/30 - command = segment.sample(now) - observe(observer, "thumb_cmc_roll", now, command[0], command) - segment.observe((), stamp=None, now=now) - assert not observer.check(segment, now) - evidence = observer.settle_record(segment.finished_at, now, epoch=1) - records.extend(observer.drain()) - assert evidence and segment.steady_ready(now) - records.append(dict(kind="joint_sample", joint="thumb_cmc_roll", sample_phase="steady", - image_stamp_ns=int((now+100)*1e9), command_vector_u8=list(command), - visual_settled_evidence_id=evidence, feedback_u8=None, state_u8=None)) - return records - - -def test_stability_evidence_replays_original_pixels_and_rejects_tampering(profile): - rows = stable_records(profile) - validate_visual_motion_evidence(profile, rows) - wrong_image = copy.deepcopy(rows) - wrong_image[-1]["image_stamp_ns"] += 1 - with pytest.raises(ValueError, match="visual_sample_image_changed"): - validate_visual_motion_evidence(profile, wrong_image) - missing = [r for r in rows if r.get("kind") != "visual_motion_observation"] - with pytest.raises(ValueError, match="source_missing"): - validate_visual_motion_evidence(profile, missing) - wrong_command = copy.deepcopy(rows) - wrong_command[-1]["command_vector_u8"][0] = 81 - with pytest.raises(ValueError, match="visual_sample_command_changed"): - validate_visual_motion_evidence(profile, wrong_command) - - -def test_unstable_and_duplicate_images_cannot_open_steady_window(profile): - observer, segment = execution(profile, phase="steady", target_value=0) - for tick in range(100): - now = tick/30 - command = segment.sample(now) - observe(observer, "thumb_cmc_roll", now, 20*(tick % 2), command) - segment.observe((), stamp=None, now=now) - assert not segment.steady_ready(now) - latest = observer.latest["thumb_cmc_roll"] - observer.observe("front", latest.stamp_ns, {}, now=100., epoch=1, version=1, command=command) - assert not observer.available(("thumb_cmc_roll",), 100.) - - -def test_startup_reads_target_register_without_position_or_write_access(profile, tmp_path): - calls = [] - class Controller: - hand_info = {"产品型号": "O30", "左右手": "RIGHT", "设备唯一标识": "fixture"} - def __init__(self, **kwargs): - calls.append(kwargs) - def get_target_position(self): - calls.append("target") - return list(profile.command.baseline_values) - def close(self): - calls.append("close") - seed = read_o30_command(side="right", controller_factory=Controller) - assert calls[1:] == ["target", "close"] - assert calls[0]["probe_sensor"] is False - path = tmp_path/"seed.json" - path.write_text(json.dumps(seed)) - assert load_command_seed(path, profile) == (profile.command.baseline_values, "fixture") - seed["values"][0] = 999 - path.write_text(json.dumps(seed)) - with pytest.raises(ValueError, match="initial_command_seed_invalid"): - load_command_seed(path, profile) - - -def test_coordinator_starts_without_feedback_and_uses_only_target_seed(tmp_path): - from runtime_host_fixture import coordinator_fixture - host, clock = coordinator_fixture(tmp_path, model="o30", profile_transform=as_vision) - try: - for view in host.profile.vision.view_names: - host.cameras.matrices[view] = np.eye(3) - host.cameras.image_sizes[view] = (640, 480) - host.cameras.info_received_at[view] = clock.now - host.cameras.detections_received_at[view] = clock.now - assert not host.state_history - host.tick() - assert host.start().success - host.tick() - clock.now += float(host.parameters.speed_settle_seconds)+.01 - host.tick() - assert clock.positions[-1] == host.parameters.initial_command - assert not host.state_history - # An unavailable/invalid position packet is diagnostic only. - host.receive_feedback(host.command_names, [999.]*20, host.ports.clock_ns()) - host.tick() - assert host.state not in {"PAUSED", "FAILED"} - clock.now += 3.1 - host.tick() - assert host.state == "PAUSED" - assert "sdk_disconnected" in host.reason - finally: - host.close() - - -def test_image_capture_keeps_absent_feedback_explicit(tmp_path): - from runtime_host_fixture import coordinator_fixture - from linkerhand_calibration.runtime.capture import CaptureFrame - from linkerhand_calibration.runtime.image_capture import image_observation_record - host, _ = coordinator_fixture(tmp_path, model="o30", profile_transform=as_vision) - try: - task = host.profile.motion.tasks[0] - frame = CaptureFrame(task.view, 123, np.eye(3), {}, None, - host.profile.command.baseline_values, command_skew_ns=1000) - motion = MotionCommand("sweep", frame.command, 200., task_key=task.key, - command_index=0, cycle=0, direction="increasing") - row = image_observation_record(host.profile, frame, motion) - assert row["feedback_vector"] is None - assert row["state_image_sync_error_ns"] is None - assert row["command_image_skew_ns"] == 1000 - assert row["command_vector"] == list(frame.command) - finally: - host.close() - - -@pytest.mark.parametrize("model", ["o6", "o12", "l6", "g20"]) -def test_existing_models_keep_position_feedback_requirement(model): - # Registered layout names differ, so discover through the product contract. - from pathlib import Path - from linkerhand_calibration.product import load_product_config - package = Path(__file__).resolve().parents[1] - config = load_product_config(package/f"config/{model}_right_product.yaml", - workspace=package.parents[1], check_can=False) - profile = config.calibration_contract.typed_profile - assert not profile.vision_motion - sample = SafetySample(3., None, profile.command.baseline_values, None, HardwareHealth(True, True)) - assert SafetyPolicy(profile).evaluate(sample).code == "feedback_stale" diff --git a/src/linkerhand_calibration/test/test_witness_endpoint.py b/src/linkerhand_calibration/test/test_witness_endpoint.py deleted file mode 100644 index 9462845..0000000 --- a/src/linkerhand_calibration/test/test_witness_endpoint.py +++ /dev/null @@ -1,104 +0,0 @@ -"""A real terminal command gap must delay departure, not fabricate evidence.""" -from copy import deepcopy -from dataclasses import replace -import gzip -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from linkerhand_calibration.core.domain.capture_plan import configure_training -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.profiles.preparation import configure_preparation -from linkerhand_calibration.runtime.motion_execution import MotionCommand -from linkerhand_calibration.runtime.preparation_witness import ( - PreparationWitnessCapture, WitnessInput, stationary_witness_endpoint, select_witness_rows, -) - - -@pytest.fixture -def capture(): - rows = json.loads(gzip.decompress((Path(__file__).parent/'fixtures/o30_witness_terminal_gap.json.gz').read_bytes())) - profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), - 'staged_2_to_3'), 'occlusion_aware_witness_v1') - owner = SimpleNamespace(profile=profile) - witness = PreparationWitnessCapture(owner) - for row in rows: - witness.observe(row) - motion = MotionCommand('zero_approach', profile.motion.preparation_witnesses['pinky_mcp_pitch'].command, - 200., task_key='pinky_mcp_pitch_side', reference_joints=('pinky_mcp_pitch',), - reference_path_index=2, preparation_witness_joint='pinky_mcp_roll') - return profile, rows, witness, motion - - -def extra_frame(rows, index): - """Synthetic later receipt for the acquisition gate only, never release.""" - row = deepcopy(rows[-1]) - row['image_stamp_ns'] += index*33_333_333 - for tag in row['tags'].values(): - tag['candidate_diagnostics']['observation_stamp_ns'] = row['image_stamp_ns'] - return row - - -@pytest.mark.replay -def test_real_three_missing_commands_leave_only_two_terminal_images(capture): - profile, rows, witness, motion = capture - assert len(rows) == 226 - assert all(row['command_vector'] is None for row in rows[-5:-2]) - assert witness.endpoint_error(motion, 1, rows[-1]['image_stamp_ns']) == 'preparation_witness_stationary_endpoint_missing' - with pytest.raises(ValueError, match='stationary_endpoint_missing'): - stationary_witness_endpoint(profile, 'pinky_mcp_pitch', rows) - source = WitnessInput('pinky_mcp_pitch', 'pinky_mcp_roll', tuple(rows), (), (5, 10, 14)) - with pytest.raises(ValueError, match='stationary_endpoint_missing'): - select_witness_rows(profile, source) - - -def test_wait_collects_new_images_and_live_and_replay_use_same_window(capture): - profile, rows, witness, motion = capture - original = deepcopy(rows) - for index in range(1, 9): - row = extra_frame(original, index) - witness.observe(row) - rows.append(row) - if index < 8: - assert witness.endpoint_error(motion, 1, row['image_stamp_ns']) - assert witness.endpoint_error(motion, 1, rows[-1]['image_stamp_ns']) == '' - tail = stationary_witness_endpoint(profile, 'pinky_mcp_pitch', rows) - assert len(tail) == 10 - assert rows[:len(original)] == original - source = WitnessInput('pinky_mcp_pitch', 'pinky_mcp_roll', tuple(rows), (), (5, 10, 14)) - _, replayed_tail = select_witness_rows(profile, source) - assert replayed_tail == tail - - -@pytest.mark.parametrize('fault', ['stale', 'missing_command', 'unstable_feedback', 'epoch', 'tag']) -def test_terminal_gate_cannot_accept_invalid_or_old_evidence(capture, fault): - profile, rows, witness, motion = capture - extra = [extra_frame(rows, index) for index in range(1, 12)] - if fault == 'missing_command': - extra[-1]['command_vector'] = None - elif fault == 'unstable_feedback': - extra[-1]['feedback_vector'][5] += 10 - elif fault == 'tag': - del extra[-1]['tags']['pinky_pip'] - for row in extra: - witness.observe(row) - now = extra[-1]['image_stamp_ns'] - assert witness.endpoint_error(motion, 2 if fault == 'epoch' else 1, - now+2_000_000_000 if fault == 'stale' else now) - assert witness.endpoint_error(replace(motion, reference_path_index=1), 1, now) == '' - - -def test_coordinator_holds_with_fixed_deadline_and_never_starts_next_motion(capture): - from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator - profile, rows, witness, motion = capture - pauses = [] - host = SimpleNamespace(profile=profile, branch_initialization=SimpleNamespace(witness_capture=witness), - ports=SimpleNamespace(clock_ns=lambda: rows[-1]['image_stamp_ns']), - _observation_epoch=1, segment=SimpleNamespace(finished_at=10.), _pause=pauses.append) - assert not CalibrationCoordinator._witness_departure_ready(host, motion, 11.) - assert not pauses - assert not CalibrationCoordinator._witness_departure_ready(host, motion, 10.+profile.acquisition.steady_timeout_seconds) - assert pauses == ['preparation_witness_stationary_endpoint_missing'] - assert host.segment.finished_at == 10. diff --git a/src/linkerhand_calibration/test/test_zero_approach_path.py b/src/linkerhand_calibration/test/test_zero_approach_path.py deleted file mode 100644 index 537031a..0000000 --- a/src/linkerhand_calibration/test/test_zero_approach_path.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Full pre-zero paths retain bounded, source-bound geometry evidence.""" -from copy import deepcopy -from dataclasses import replace -import json - -import pytest - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization -from linkerhand_calibration.runtime.motion_provenance import _initializations -from test_branch_initialization import arrival - - -def path_fixture(): - profile = load_bundled_hand_profile('o6_right_8') - approach, zero, templates = arrival(profile) - name = zero.zero_joints[0] - spec = profile.motion.joint_zero_references[name] - channel = profile.command.command_index_by_joint[name] - def pose(value): - values = list(spec.command); values[channel] = value - return tuple(values) - spec = replace(spec, command=pose(96), approach_commands=(pose(0), pose(255), pose(0)), evidence_scope='path') - references = dict(profile.motion.joint_zero_references); references[name] = spec - profile = replace(profile, motion=replace(profile.motion, joint_zero_references=references)) - zero = replace(zero, target=spec.command) - stages, stamp = [], 1000 - for index, order in ((0, range(len(templates))), (1, range(len(templates))), - (2, reversed(range(len(templates)))), (3, range(len(templates)//3))): - rows = [] - for i in order: - row = deepcopy(templates[i]); stamp += 1 - row.update(image_stamp_ns=stamp, motion_version=10+index, - reference_path_index=index, sample_phase='zero_approach', - command_vector=list(pose(255*i/(len(templates)-1))), - feedback_vector=list(pose(255*i/(len(templates)-1)))) - for item in row['tags'].values(): - item['candidate_diagnostics']['observation_stamp_ns'] = stamp - rows.append(row) - stages.append((replace(approach, target=(*spec.approach_commands, spec.command)[index], - reference_path_index=index), rows)) - return profile, zero, stages - - -def collect(profile, zero, stages): - owner = BranchInitialization(profile, image_geometry=False) - for motion, rows in stages: - owner.begin(motion, session_epoch=1, motion_version=rows[0]['motion_version']) - for row in rows: - owner.observe(row) - owner.begin(zero, session_epoch=1, motion_version=14) - return owner, owner.request(stages[0][1][0]['view'], zero, session_epoch=1, motion_version=14) - - -def test_full_path_keeps_full_motion_and_separate_source_versions_after_short_arrival(): - profile, zero, stages = path_fixture() - owner, request = collect(profile, zero, stages) - assert len(request.frames) <= 129 - assert set(dict(request.source_motion_versions).values()) == {12, 13} - assert not set(frame.stamp_ns for frame in request.frames) & { - row['image_stamp_ns'] for row in stages[0][1]} - result = solve_initialization(request) - assert result.resolved, result.reason - _, report = owner.accept(request, result) - assert report['schema_version'] == 2 - sources = [row for _, rows in stages for row in rows] - # Serialized multi-segment provenance must pass the same final reader. - journal = json.loads(json.dumps([*sources, report])) - assert _initializations(journal) - changed = deepcopy(journal) - source = next(row for row in changed if row.get('image_stamp_ns') in report['source_image_stamps']) - source['motion_version'] = 10 - with pytest.raises(ValueError, match='source_image_missing_or_changed'): - _initializations(changed) - - -@pytest.mark.parametrize('mutation', ['skip_start', 'skip_leg', 'new_epoch', 'held_joint_moved', 'stale_frame']) -def test_path_cannot_mix_incomplete_different_epoch_or_stale_motion(mutation): - profile, zero, stages = path_fixture() - owner = BranchInitialization(profile, image_geometry=False) - selected = stages[1:] if mutation == 'skip_start' else stages - if mutation == 'skip_leg': selected = [stages[0], *stages[2:]] - for motion, rows in selected: - epoch = 2 if mutation == 'new_epoch' and motion.reference_path_index >= 2 else 1 - owner.begin(motion, session_epoch=epoch, motion_version=rows[0]['motion_version']) - for row in rows: - row = deepcopy(row) - if mutation == 'held_joint_moved' and motion.reference_path_index == 1: - row['feedback_vector'][0] += 5 if row['image_stamp_ns'] % 2 else 0 - if mutation == 'stale_frame': row['motion_version'] -= 1 - row['session_epoch'] = epoch - owner.observe(row) - owner.begin(zero, session_epoch=epoch, motion_version=14) - request = owner.request(stages[0][1][0]['view'], zero, session_epoch=epoch, motion_version=14) - assert not solve_initialization(request).resolved - - -def test_new_retry_discards_previous_preparation_evidence(): - profile, zero, stages = path_fixture() - owner, request = collect(profile, zero, stages) - assert request.frames - first, rows = stages[0] - owner.begin(first, session_epoch=2, motion_version=20) - assert owner.accept(request, solve_initialization(request)) is None - assert owner._frames == {} and owner.reports == {} diff --git a/src/linkerhand_calibration/test/test_zero_calibration.py b/src/linkerhand_calibration/test/test_zero_calibration.py deleted file mode 100644 index e457ec2..0000000 --- a/src/linkerhand_calibration/test/test_zero_calibration.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import annotations - -import math - -import cv2 -from linkerhand_calibration.zero_calibration import ( - build_trajectory_zero_angle_payload, - build_trajectory_zero_travel_payload, - circular_median_rad, - detect_reference_alignment_line, - fit_image_circle_trajectory, - measure_zero_from_circle, - signed_angle_difference_rad, - summarize_zero_frames, - validate_zero_angle_payload, - validate_zero_travel_payload, -) -import numpy as np -import pytest - - -def _detect_reference_line(image: np.ndarray, reference_y: float) -> dict | None: - return detect_reference_alignment_line( - image, - reference_y_px=reference_y, - roi_y_min_ratio=0.55, - roi_y_max_ratio=0.98, - minimum_length_ratio=0.30, - maximum_candidate_angle_rad=math.radians(15.0), - ) - - -def _frame( - table_angle_rad: float, - *, - state_u8: float = 255.0, - t0_xy: tuple[float, float] = (500.0, 300.0), - circle_xy: tuple[float, float] = (-120.0, 80.0), - radius_px: float = 90.0, -) -> dict[str, float]: - # table_angle_rad uses a y-up convention while image y grows downwards. - relative = np.asarray(circle_xy) + radius_px * np.asarray( - [math.cos(table_angle_rad), -math.sin(table_angle_rad)] - ) - t0 = np.asarray(t0_xy) - t3 = t0 + relative - return { - "state_u8": state_u8, - "t0_x_px": float(t0[0]), - "t0_y_px": float(t0[1]), - "t3_x_px": float(t3[0]), - "t3_y_px": float(t3[1]), - } - - -def _trajectory(noise_px: float = 0.15) -> list[dict[str, float]]: - rng = np.random.default_rng(7) - observations: list[dict[str, float]] = [] - for states in ( - np.linspace(255.0, 64.0, 70), - np.linspace(64.0, 255.0, 70), - ): - for state in states: - fraction = (255.0 - state) / (255.0 - 64.0) - angle = math.radians(10.0 + 65.0 * fraction) - shift = rng.normal(0.0, 0.35, size=2) - frame = _frame( - angle, - state_u8=float(state), - t0_xy=(500.0 + shift[0], 300.0 + shift[1]), - ) - frame["t3_x_px"] += float(rng.normal(0.0, noise_px)) - frame["t3_y_px"] += float(rng.normal(0.0, noise_px)) - observations.append(frame) - return observations - - -def _fit(observations: list[dict[str, float]] | None = None) -> dict: - return fit_image_circle_trajectory( - _trajectory() if observations is None else observations, - bin_size_u8=8.0, - minimum_frames=45, - minimum_bins=18, - minimum_state_span_u8=160.0, - minimum_radius_px=20.0, - minimum_arc_rad=math.radians(20.0), - maximum_radial_rms_px=2.0, - maximum_p95_radial_error_px=3.5, - ) - - -def _zero_summary(angle_rad: float) -> dict[str, float]: - frames = [ - _frame( - angle_rad + math.radians(index - 14.5) * 1.0e-4, - ) - for index in range(30) - ] - return summarize_zero_frames(frames) - - -def _measurement( - table_rad: float, - *, - radial_error_px: float = 0.2, -) -> dict[str, float]: - return { - "table_rad": table_rad, - "zero_radial_error_px": radial_error_px, - } - - -def _round( - table_rad: float, - *, - return_delta: float = 0.002, -) -> dict[str, dict[str, float]]: - return { - "zero_before": _measurement(table_rad), - "zero_after": _measurement( - table_rad + return_delta, - ), - } - - -def _travel_round( - zero_table_rad: float, - travel_rad: float, - *, - return_delta: float = 0.002, -) -> dict[str, dict[str, float]]: - round_value = _round( - zero_table_rad, - return_delta=return_delta, - ) - round_value["travel_endpoint"] = _measurement( - zero_table_rad + travel_rad, - ) - return round_value - - -def test_circular_statistics_cross_pi_without_jumping() -> None: - values = [math.radians(179.0), math.radians(-179.0), math.pi] - result = circular_median_rad(values) - assert abs(signed_angle_difference_rad(result, math.pi)) < math.radians(1.1) - - -def test_detect_reference_line_reports_signed_angle_and_offset() -> None: - height, width = 720, 1280 - reference_y = 0.90 * (height - 1) - expected_angle = math.radians(2.0) - image = np.zeros((height, width, 3), dtype=np.uint8) - half_span = 560.0 - vertical_change = math.tan(expected_angle) * half_span - cv2.line( - image, - (80, int(round(reference_y + vertical_change))), - (1200, int(round(reference_y - vertical_change))), - (255, 255, 255), - 5, - ) - - result = _detect_reference_line(image, reference_y) - - assert result is not None - assert result["angle_rad"] == pytest.approx(expected_angle, abs=0.004) - assert abs(result["vertical_offset_px"]) <= 5.0 - - -def test_detect_reference_line_rejects_non_horizontal_scene() -> None: - image = np.zeros((720, 1280, 3), dtype=np.uint8) - cv2.line(image, (640, 420), (640, 700), (255, 255, 255), 5) - - assert _detect_reference_line(image, 0.90 * 719.0) is None - - -def test_circle_fit_recovers_center_radius_and_rejects_anchor_translation() -> None: - fit = _fit() - assert fit["centre_relative_xy_px"] == pytest.approx( - [-120.0, 80.0], abs=0.8 - ) - assert fit["radius_px"] == pytest.approx(90.0, abs=0.8) - assert fit["arc_rad"] >= math.radians(60.0) - assert fit["radial_rms_px"] < 0.5 - assert fit["passed"] is True - - -def test_circle_fit_is_robust_to_sparse_bad_tag_centres() -> None: - observations = _trajectory() - for index in (9, 31, 57, 92, 121): - observations[index]["t3_x_px"] += 18.0 - observations[index]["t3_y_px"] -= 15.0 - fit = _fit(observations) - assert fit["centre_relative_xy_px"] == pytest.approx( - [-120.0, 80.0], abs=1.5 - ) - assert fit["radius_px"] == pytest.approx(90.0, abs=1.5) - - -def test_zero_uses_fixed_inward_radius_and_ignores_t3_rotation() -> None: - circle = _fit() - trajectory_angle = math.radians(10.0) - expected_table = math.radians(-170.0) - summary = _zero_summary(trajectory_angle) - summary_with_t3_forward = {**summary, "t3_rad": math.radians(31.0)} - summary_with_t3_flipped = { - **summary, - "t3_rad": math.radians(-149.0), - } - result = measure_zero_from_circle(circle, summary) - forward = measure_zero_from_circle(circle, summary_with_t3_forward) - flipped = measure_zero_from_circle(circle, summary_with_t3_flipped) - assert result["table_rad"] == pytest.approx(expected_table, abs=0.01) - assert forward == result - assert flipped == result - - -def test_circle_fit_rejects_short_state_span() -> None: - observations = [ - _frame(math.radians(10.0 + index * 0.1), state_u8=255.0 - index) - for index in range(50) - ] - with pytest.raises(ValueError, match="trajectory_state_span_too_small"): - _fit(observations) - - -def test_payload_remains_small_and_contains_circle_derived_angles() -> None: - rounds = [ - _round(-0.30), - _round(-0.299), - _round(-0.302), - ] - payload, report = build_trajectory_zero_angle_payload( - serial_number="G20_LEFT_TEST", - rounds=rounds, - trajectory_quality=_fit(), - maximum_round_difference_rad=math.radians(1.0), - maximum_return_error_rad=math.radians(1.0), - maximum_zero_radial_error_px=4.0, - detection_rate=0.99, - minimum_detection_rate=0.95, - ) - validate_zero_angle_payload(payload) - assert payload["schema_version"] == 2 - assert set(payload["zero_angles"]) == {"table_projected_zero_rad"} - assert payload["zero_angles"]["table_projected_zero_rad"] == pytest.approx( - -0.299 - ) - assert payload["quality"]["passed"] is True - assert report["measurement_method"] == ( - "t3_center_to_circle_centre_image_trajectory" - ) - - -@pytest.mark.parametrize(("detection_rate", "radial_error"), [(0.8, 0.2), (0.99, 8.0)]) -def test_quality_fails_for_detection_or_zero_circle_error( - detection_rate: float, radial_error: float -) -> None: - rounds = [ - { - "zero_before": _measurement( - -0.30, radial_error_px=radial_error - ), - "zero_after": _measurement( - -0.299, radial_error_px=radial_error - ), - } - for _ in range(3) - ] - payload, _ = build_trajectory_zero_angle_payload( - serial_number="G20_LEFT_TEST", - rounds=rounds, - trajectory_quality=_fit(), - maximum_round_difference_rad=math.radians(1.0), - maximum_return_error_rad=math.radians(1.0), - maximum_zero_radial_error_px=4.0, - detection_rate=detection_rate, - minimum_detection_rate=0.95, - ) - assert payload["quality"]["passed"] is False - - -def test_roll_payload_contains_zero_and_measured_travel() -> None: - rounds = [ - _travel_round(-0.30, 1.201), - _travel_round(-0.299, 1.199), - _travel_round(-0.302, 1.200), - ] - payload, report = build_trajectory_zero_travel_payload( - serial_number="G20_LEFT_TEST", - joint_name="thumb_cmc_roll", - zero_command_u8=255, - travel_endpoint_command_u8=0, - rounds=rounds, - trajectory_quality=_fit(), - maximum_round_difference_rad=math.radians(1.0), - maximum_travel_difference_rad=math.radians(1.0), - minimum_travel_rad=math.radians(20.0), - maximum_return_error_rad=math.radians(1.0), - maximum_zero_radial_error_px=4.0, - detection_rate=0.99, - minimum_detection_rate=0.95, - ) - - validate_zero_travel_payload(payload) - assert payload["joint"] == "thumb_cmc_roll" - assert payload["schema_version"] == 2 - assert payload["zero_command_u8"] == 255 - assert payload["travel_endpoint_command_u8"] == 0 - assert set(payload["zero_angles"]) == {"table_projected_zero_rad"} - assert payload["travel"]["signed_rad"] == pytest.approx(1.2, abs=0.003) - assert payload["travel"]["range_rad"] == pytest.approx(1.2, abs=0.003) - assert payload["quality"]["passed"] is True - assert report["signed_travel_rounds_rad"] == pytest.approx( - [1.2, 1.198, 1.199], abs=0.003 - ) - - -def test_roll_quality_rejects_inconsistent_or_short_travel() -> None: - rounds = [ - _travel_round(-0.30, travel) - for travel in (0.10, 0.12, 0.14) - ] - payload, _ = build_trajectory_zero_travel_payload( - serial_number="G20_LEFT_TEST", - joint_name="thumb_cmc_roll", - zero_command_u8=255, - travel_endpoint_command_u8=0, - rounds=rounds, - trajectory_quality=_fit(), - maximum_round_difference_rad=math.radians(1.0), - maximum_travel_difference_rad=math.radians(1.0), - minimum_travel_rad=math.radians(20.0), - maximum_return_error_rad=math.radians(1.0), - maximum_zero_radial_error_px=4.0, - detection_rate=0.99, - minimum_detection_rate=0.95, - ) - - assert payload["quality"]["passed"] is False diff --git a/src/linkerhand_calibration/test/test_zero_evidence_coverage.py b/src/linkerhand_calibration/test/test_zero_evidence_coverage.py deleted file mode 100644 index 7fb6378..0000000 --- a/src/linkerhand_calibration/test/test_zero_evidence_coverage.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Missing absolute palm registration cannot be certified by flexion curves.""" -import json -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.profiles import load_bundled_hand_profile -from linkerhand_calibration.core.fitting.session import compile_spatial_profile -from linkerhand_calibration.core.fitting.spatial import solve_urdf_zero_offsets -from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel - -SOURCE = Path(__file__).resolve().parents[1]/'urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf' -FLEXION = ('pinky_mcp_pitch', 'pinky_pip', 'pinky_dip') - - -@pytest.mark.parametrize('validation_cycle', [None, 3]) -def test_missing_axes_report_names_and_do_not_invent_training_or_holdout(validation_cycle): - profile = compile_spatial_profile(load_bundled_hand_profile('o30_right_18')) - # Coverage-only fixtures: execution must reject before touching geometry. - measurements = [SimpleNamespace(joint=j, cycle=c) for j in FLEXION for c in (0, 1)] - with pytest.raises(ValueError, match='axis measurements do not contain') as error: - solve_urdf_zero_offsets(source_urdf=SOURCE, measurements=measurements, - curves={}, motor_by_joint={}, zero_profile=profile, - training_cycles=(0, 1), validation_cycle=validation_cycle) - report = json.loads(str(error.value).split(':', 1)[1]) - assert set(report['training_missing']) == set(profile.axis_joints)-set(FLEXION) - assert set(report['training_missing_root_anchors']) == set(profile.root_anchor_joints) - assert report['training_unexpected'] == [] - assert set(report['validation_missing']) == (set(profile.axis_joints) if validation_cycle else set()) - - -def test_flexion_only_has_exact_mcp_zero_palm_registration_gauge(): - model = UrdfKinematicModel(SOURCE) - joint = FLEXION[0] - axis, point = model.axis_line(joint, zero_offsets={}, joint_angles={}, independent_mimic_angles=True) - offset = np.deg2rad(2.) - change = np.eye(4) - change[:3, :3] = Rotation.from_rotvec(axis*offset).as_matrix() - change[:3, 3] = point-change[:3, :3]@point - for q in (0., -.5, -1.): - angles = dict(zip(FLEXION, (q, q/2, q/3))) - for target in FLEXION: - actual = model.link_transform(target, zero_offsets={}, joint_angles=angles, - independent_mimic_angles=True) - shifted = model.link_transform(target, zero_offsets={joint: -offset}, joint_angles=angles, - independent_mimic_angles=True) - # Unknown palm registration absorbs an incorrect MCP zero exactly. - assert np.allclose(change@shifted, actual, atol=1e-12, rtol=0) - # With an independently fixed palm frame the same zero is detectable. - assert not np.allclose(shifted, actual, atol=1e-5, rtol=0) - # A fixed palm Tag is not a measured CAD mount. An unknown mount can absorb - # the registration change without changing its camera image either. - mount = np.eye(4) - mount[:3, 3] = [.02, .01, .03] - assert np.allclose(change@(np.linalg.inv(change)@mount), mount, atol=1e-12) diff --git a/src/linkerhand_calibration/test/test_zero_recovery.py b/src/linkerhand_calibration/test/test_zero_recovery.py deleted file mode 100644 index 2af489f..0000000 --- a/src/linkerhand_calibration/test/test_zero_recovery.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Bounded fresh preparation, no replacement of already frozen joint zeros.""" - -from dataclasses import replace -from types import SimpleNamespace - -import numpy as np -import pytest - -from linkerhand_calibration.core.geometry.tag_pose.image_motion_model import ImageMotionResolution -from linkerhand_calibration.runtime.branch_initialization import solve_initialization -from linkerhand_calibration.runtime.zero_recovery import ZeroRecovery -from runtime_host_fixture import coordinator_fixture, ready -from test_diagnostic_coordinator import run_until - - -def test_recovery_is_bounded_and_rejects_frozen_or_faulty_inputs(): - motion = SimpleNamespace(task_key="chain", zero_joints=("a", "b"), reference_reuse=False) - policy = ZeroRecovery() - reports = {"front": {"status": "unresolved", "reason": "image_motion_families_not_distinguishable"}} - kwargs = dict(geometry_error="ambiguous", reports=reports, referenced_joints={}) - assert policy.take(motion, **kwargs) is None # Identical motion adds no independent discriminator. - kwargs["reports"] = {"front": {"status": "unresolved", "reason": "image_motion_insufficient_independent_frames"}} - assert policy.take(motion, **{**kwargs, "referenced_joints": {"a": object()}}) is None - assert policy.take(motion, **{**kwargs, "reports": {"front": {"status": "unresolved", "reason": "motion_held_feedback_changed"}}}) is None - assert policy.take(motion, **kwargs) == "repeat_approach" - assert policy.take(motion, **kwargs) is None - assert policy.take(motion, geometry_error="", reports={}, referenced_joints={}) is None - - -@pytest.mark.parametrize("persistent", [False, True]) -@pytest.mark.parametrize("failure", ["model_frames", "source_frames", "source_candidates"]) -def test_transient_preparation_retries_locally_and_persistent_failure_stops(tmp_path, persistent, failure): - 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.]]) - requests = [] - def solver(request): - requests.append(request) - if persistent or len(requests) == 1: - if failure == "source_frames": - # Exercise the actual pre-solver validator's error vocabulary. - return solve_initialization(replace(request, frames=request.frames[:2], - image_frames=request.image_frames[:2])) - if failure == "source_candidates": - return ImageMotionResolution(False, "motion_candidates_missing_or_invalid") - return ImageMotionResolution(False, "image_motion_insufficient_independent_frames") - return solve_initialization(request) - host.initialization_solver = solver - assert host.start().success - try: - run_until(host, clock, lambda: host.state == "PAUSED" or bool(host.execution.zero_references)) - assert len(requests) == 2 - assert max(frame.stamp_ns for frame in requests[0].frames) < min(frame.stamp_ns for frame in requests[1].frames) - recoveries = [r for r in host.raw_records if r.get("kind") == "joint_zero_recovery"] - assert len(recoveries) == 1 and recoveries[0]['failure_category'] == 'missing_samples' - if persistent: - assert host.state == "PAUSED" and not host.execution.zero_references - else: - assert host.state == "RUNNING" and "rh_thumb_cmc_yaw" in host.execution.zero_references - assert not host.finalization.started - finally: - host.close() - - -def test_zero_image_recovery_keeps_frozen_geometry_and_pose(tmp_path, monkeypatch): - 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.]]) - original = host._lock_joint_zeros - def delay_images(motion): - recovered = any(r.get("kind") == "joint_zero_recovery" for r in host.raw_records) - return original(motion) if recovered else False - monkeypatch.setattr(host, "_lock_joint_zeros", delay_images) - assert host.start().success - try: - run_until(host, clock, lambda: bool(host.execution.zero_references)) - recoveries = [r for r in host.raw_records if r.get("kind") == "joint_zero_recovery"] - assert len(recoveries) == 1 and recoveries[0]["action"] == "retry_zero_images" - assert len([r for r in host.raw_records if r.get("kind") == "motion_branch_initialization"]) == 1 - finally: - host.close() diff --git a/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-V2_0819.urdf b/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-V2_0819.urdf new file mode 100644 index 0000000..8304633 --- /dev/null +++ b/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-V2_0819.urdf @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-update.urdf b/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-update.urdf new file mode 100644 index 0000000..6508de6 --- /dev/null +++ b/src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-update.urdf @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/hand_base_link.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/hand_base_link.STL new file mode 100644 index 0000000..5b2192c Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/hand_base_link.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/index_distal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/index_distal.STL new file mode 100644 index 0000000..f9e29d1 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/index_distal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/index_metacarpals.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/index_metacarpals.STL new file mode 100644 index 0000000..d3de7a3 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/index_metacarpals.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/index_middle.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/index_middle.STL new file mode 100644 index 0000000..4c0649f Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/index_middle.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/index_proximal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/index_proximal.STL new file mode 100644 index 0000000..911c629 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/index_proximal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/middle_distal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_distal.STL new file mode 100644 index 0000000..f9e29d1 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_distal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/middle_metacarpals.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_metacarpals.STL new file mode 100644 index 0000000..d3de7a3 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_metacarpals.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/middle_middle.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_middle.STL new file mode 100644 index 0000000..4c0649f Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_middle.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/middle_proximal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_proximal.STL new file mode 100644 index 0000000..911c629 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/middle_proximal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_distal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_distal.STL new file mode 100644 index 0000000..f9e29d1 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_distal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_metacarpals.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_metacarpals.STL new file mode 100644 index 0000000..d3de7a3 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_metacarpals.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_middle.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_middle.STL new file mode 100644 index 0000000..4c0649f Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_middle.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_proximal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_proximal.STL new file mode 100644 index 0000000..911c629 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/pinky_proximal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/ring_distal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_distal.STL new file mode 100644 index 0000000..f9e29d1 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_distal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/ring_metacarpals.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_metacarpals.STL new file mode 100644 index 0000000..d3de7a3 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_metacarpals.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/ring_middle.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_middle.STL new file mode 100644 index 0000000..4c0649f Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_middle.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/ring_proximal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_proximal.STL new file mode 100644 index 0000000..911c629 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/ring_proximal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_distal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_distal.STL new file mode 100644 index 0000000..0dec965 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_distal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base1.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base1.STL new file mode 100644 index 0000000..af0e866 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base1.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base2.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base2.STL new file mode 100644 index 0000000..0a1a390 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base2.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_proximal.STL b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_proximal.STL new file mode 100644 index 0000000..c517c21 Binary files /dev/null and b/src/linkerhand_calibration/urdf/o30_left/meshes/thumb_proximal.STL differ diff --git a/src/linkerhand_calibration/urdf/o30_right/o30_right_draft.urdf b/src/linkerhand_calibration/urdf/o30_right/o30_right_draft.urdf new file mode 100644 index 0000000..07febe4 --- /dev/null +++ b/src/linkerhand_calibration/urdf/o30_right/o30_right_draft.urdf @@ -0,0 +1,1187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file