From 9952dbf4a71f4ccdeecd2af5f21ae068c2d3f79e Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Tue, 22 Sep 2026 11:03:57 +0800 Subject: [PATCH] =?UTF-8?q?O30urdf=E6=A0=87=E5=AE=9A=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ree_camera_extrinsics_20260922_101452.yaml | 60 +++++++++++ .../O30_RIGHT_CALIBRATION.md | 36 ++++++- .../O30_STABILITY_REVIEW_20260922.md | 81 ++++++++++++++ src/linkerhand_calibration/TESTING.md | 9 ++ .../config/o30_right_product.yaml | 4 +- .../core/domain/capture_plan.py | 24 ++++- .../core/fitting/session.py | 6 +- .../profiles/diagnostics.py | 7 ++ .../profiles/preparation.py | 8 +- .../profiles/validator.py | 4 +- .../runtime/coordinator.py | 6 ++ .../runtime/diagnostic_analysis.py | 5 +- .../runtime/diagnostic_quality.py | 3 +- .../linkerhand_calibration/runtime/engine.py | 6 +- .../linkerhand_calibration/runtime/runner.py | 4 +- .../runtime/training.py | 9 +- .../test/o30_formal_fixture.py | 6 +- .../test/test_diagnostic_analysis.py | 34 ++++++ .../test/test_diagnostic_presets_cli.py | 15 +++ .../test/test_diagnostic_quality.py | 16 ++- .../test/test_preparation_contract.py | 3 +- .../test/test_taskwise_two_round.py | 102 ++++++++++++++++++ 22 files changed, 418 insertions(+), 30 deletions(-) create mode 100644 config/o30_three_camera_extrinsics_20260922_101452.yaml create mode 100644 src/linkerhand_calibration/O30_STABILITY_REVIEW_20260922.md create mode 100644 src/linkerhand_calibration/test/test_taskwise_two_round.py diff --git a/config/o30_three_camera_extrinsics_20260922_101452.yaml b/config/o30_three_camera_extrinsics_20260922_101452.yaml new file mode 100644 index 0000000..16be743 --- /dev/null +++ b/config/o30_three_camera_extrinsics_20260922_101452.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.8080948548192018 + - -0.008581421683932288 + - 1.0532250983464067 + quaternion_xyzw: + - -0.021327054698668555 + - 0.6912906965521914 + - 0.01197673364064859 + - 0.7221626461189797 + top: + translation_xyz_m: + - 0.04741691749425549 + - -0.5804548270182806 + - 1.0631239748696568 + quaternion_xyzw: + - -0.7015214400964395 + - -0.04845091102701706 + - -0.028754624245378058 + - 0.710417729149672 +quality: + passed: true + reprojection_rms_px: 1.0243965778937039 + maximum_rotation_repeatability_deg: 0.07214266243246935 + maximum_translation_repeatability_m: 0.0008227910293034463 + 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_RIGHT_CALIBRATION.md b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md index 55148a7..13b08b3 100644 --- a/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md +++ b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md @@ -2,11 +2,43 @@ 入口为 `calibrate_hand --config src/linkerhand_calibration/config/o30_right_product.yaml`。 Profile 为 `O30/right/o30_right_18/v1`,20 个关节全部主动;原始 URDF 与外部 SDK 不被标定器改写。 -当前产品配置绑定 2026-09-20 重新采集的 `config/o30_three_camera_extrinsics_20260920_123651.yaml`。 -该外参的 SHA256 为 `e21d21eaaf198d4daba44a44e0aa3bc46a86376213b84c3ddd30ae571714c701`; +当前产品配置绑定 2026-09-22 重新采集的 `config/o30_three_camera_extrinsics_20260922_101452.yaml`。 +该外参的 SHA256 为 `d28d3e8771f5bc20d4ca6c9bfd11977f26fefb8478d125a113c013fb7a208120`; 三相机序列号及内参指纹均与现有文件一致。 软件测试包含合成数据和已记录的真实图像回归,不能作为整手现场精度报告。 +## 2026-09-22:从头按每任务两轮训练+一轮验证采集 + +必须显式选择 `taskwise_2_plus_1`;不带参数的旧 `fixed` 仍是三轮训练+一轮验证。 +新策略按每个任务连续完成训练 1、训练 2、独立验证,再进入下一个任务, +不先扫完整手、不自动补第三轮训练。内部验证轮编号仍为 3,不能当作训练数据。 +17 个任务共 108 个方向/分段扫描单元(旧 fixed 为 144 个),不含准备与有限辅助观测动作。 + +```bash +cd /home/lxp/projects/linkerhand_retarget_ros2 +source /opt/ros/jazzy/setup.bash +source install/setup.bash +export ROS_DOMAIN_ID=99 +ros2 run linkerhand_calibration calibrate_hand \ + --config src/linkerhand_calibration/config/o30_right_product.yaml \ + --training-policy taskwise_2_plus_1 \ + --preparation-policy occlusion_aware_trajectory_v3 \ + --no-resume +``` + +先加 `--validate-only` 可只检查配置而不启动硬件。实机运行前关闭 MVS 的相机连接, +确保只有一个控制者,保留安全运动空间。新计划/调度版本与旧断点隔离;相机已移动, +本次应使用 `--no-resume`,不能沿用旧几何证据。 + +上面启用已有 v3 有界辅助观测;辅助运动不是额外训练轮。若本任务仍不可观测, +明确暂停而不跨任务延后或强选候选;两轮数据不能通过最终验收时也不会发布产物。 +完整训练拟合、独立图像验证以及 JSON→URDF 一致性检查仍保留。 + +**现场限制:**本次 thumb roll、侧面小指 MCP/PIP 的单次往返诊断通过, +小指 DIP 原始采集完成但姿态双解仍无法区分。新策略的完整实机 2+1 尚未验收, +以上命令不是“一次跑通”的保证。详见 [本次稳定性分析](O30_STABILITY_REVIEW_20260922.md)。 +下文关于四轮和 144 单元的描述指旧 fixed 策略。 + ## 正常摆放与固定基准 O30 使用 `acquisition.fixed_reference_mode: stationary_image`。机械手可正常摆放, diff --git a/src/linkerhand_calibration/O30_STABILITY_REVIEW_20260922.md b/src/linkerhand_calibration/O30_STABILITY_REVIEW_20260922.md new file mode 100644 index 0000000..4e57dcd --- /dev/null +++ b/src/linkerhand_calibration/O30_STABILITY_REVIEW_20260922.md @@ -0,0 +1,81 @@ +# O30 稳定性分析与实测:2026-09-22 + +## 结论与边界 + +新外参已绑定产品配置;相机占用已解除。当前问题不能统一归因为 Tag 检测不稳定。 +本次已修复诊断覆盖统计和压缩证据读取,并实现逐任务固定两轮训练加一轮独立验证。 +小指 DIP 在两次现场准备中均无法区分运动图像候选,是尚未解决的正式标定阻塞。 +未发布本批实机标定 JSON/URDF,没有完成整手连续标定或正式精度验收。 + +## 实机证据 + +会话均在工作区 `calibration_output/O30_RIGHT_001/`,历史原始证据未改写。 + +| 会话 | 任务 | 结果 | +| --- | --- | --- | +| `20260922_104148` | 正面 thumb roll | 相机被 MVS 占用,access denied;未开始扫描 | +| `20260922_104415` | 正面 thumb roll | 单轮往返完整完成,各方向第一次通过,428/426 帧,无暂停重扫 | +| `20260922_104609` | 侧面小指 MCP/PIP/DIP | MCP/PIP 往返通过;DIP 准备双解未消除,诊断覆盖统计又误报零帧,重扫后暂停 | +| `20260922_105323` | 修复统计后的侧面小指 | 六个方向均一次完成、无需重扫;MCP/PIP 姿态覆盖通过,DIP 只完成原始观测采集 | + +最新会话有效原始帧(正向/反向):MCP 428/429、PIP 429/422、DIP 430/423。 +三个准备窗口各有 129 帧;DIP 原因是 `image_motion_families_not_distinguishable`。 +报告明确 `accuracy_validated: false`、`publication_allowed: false`,DIP 无可信零位。 +不能把 `DIAGNOSTIC_COMPLETE` 或原始覆盖通过解释为正式标定通过。 +本批实机是诊断预设的单轮往返,不是完整 2+1,也没有测试 v3 辅助动作的实际效果。 +最新会话正常完成安全收尾,检查时没有遗留标定控制进程。 + +thumb roll 采集到终态约 96.9 秒,其中准备运动约 64.6 秒、扫描约 28.8 秒、求解约 1.5 秒。 +说明效率还受准备动作影响,扫描轮数减少 25% 不等于总时长减少 25%。 + +## 暂停原因分层 + +9 月 21 日最近 18 个状态会话的分类:6 次 MOTION-STALL-303、2 次通信断开、 +2 次反馈过期、1 次重复控制者、1 次父参考失败、1 次零位失败、5 次启动/节点退出失败。 +这是历史状态分类,不证明这些问题都在当前版本复现或已经修复。 +SDK 使用 libcanbus 的直接 USB CANFD 接口,缺少 Linux can0 网卡本身不构成故障证据。 + +本次实际分离出三个问题: + +1. 相机资源冲突:MVS 持有设备,关闭连接后恢复采集。 +2. 诊断统计缺陷:临时覆盖记录只带反馈、不带实际指令;O30 按指令域检查覆盖, + 因而在没有可信姿态、只保留原始图像时误报零帧。现使用原始同步指令填入统计, + 不制造姿态、不把反馈冒充指令,不改变正式发布门限。 +3. 几何不可观测:DIP 标签持续可见、原始运动覆盖充分,但两组运动模型仍不能区分。 + “正对相机、检测清楚”和“转轴/零位可唯一测量”不是同一件事。 + +另外,离线诊断此前把压缩证据索引当原始记录,出现 `zero_joints` 错误; +已改用公共证据解码入口,并覆盖损坏压缩包拒绝测试。 + +## 为什么不能仅增加帧数 + +`software_review_20260922_stability/replay_dip_budget.json` 保存只读离线反事实比较。 +使用 `104609` 原会话 DIP 准备,恢复原父模型、父参考及时间戳: + +- 原 128 预算选得 127 帧,复现不能区分,候选比较 p=0.01019848。 +- 同一运动改用 256 预算选得 249 帧,仍不能区分,p=0.01832461。 + +这不是新的实机采集,也不是正式发布证据。没有降低原 p=0.01 等门限。 +结论仅是这段已录运动的加密采样不足以消除双解;下一步应验证有界独立观测 +和父子几何约束能否提供真正不同的信息,而不是重复采同一轨迹直到偶然通过。 +是否需要改变标签/视角,应以该验证结果判断;相机若再移动,必须重新绑定外参。 + +## 新逐任务策略 + +`taskwise_2_plus_1` 固定每任务训练轮 0、1 和独立验证轮 3,再进入下一任务。 +共 108 个扫描单元;不补训练轮,不把不可观测任务移到整手末尾。 +准备模型不足时,已有有限辅助恢复可先执行;仍失败则停止,不能以效率目标代替精度约束。 +新计划 v4 / 调度 v15 与旧断点隔离。旧 fixed 和 staged 策略保持原语义。 +正式启动命令见 [O30 标定说明](O30_RIGHT_CALIBRATION.md)。 + +## 软件验证与未完成事项 + +- 新调度及诊断覆盖组:28 项通过,16.08 秒;包括合成 20 关节正式产物测试。 +- 合成产物验证两轮训练/独立留出、零位真值、JSON 重建相同 URDF、正式 mapper 和最终图像验收。 +- 压缩诊断分析:19 项通过,2.24 秒。 +- 原 quick 契约组中一个旧测试直接读取压缩索引失败,改用公共解码入口后, + 针对性复查 30 项通过、1 项未选,3.32 秒;不是放宽运行契约。 +- 新策略加 v3 的 `--validate-only` 通过,仅代表配置及受保护输入一致。 + +各组有重复且跨开发版本,不累计成一次全量回归。尚需 DIP 可观测性改进验证、 +全手实机逐任务 2+1 连续运行及最终文件精度验收;现阶段不能承诺一次跑通。 diff --git a/src/linkerhand_calibration/TESTING.md b/src/linkerhand_calibration/TESTING.md index b6bb12e..fb95e06 100644 --- a/src/linkerhand_calibration/TESTING.md +++ b/src/linkerhand_calibration/TESTING.md @@ -1,5 +1,14 @@ # 标定测试的执行范围 +2026-09-22 严格逐任务 2+1 用 `test_taskwise_two_round.py`:quick 部分检查 +逐任务顺序、轮间不重复准备、禁止补轮、断点分区及不可观测不跨任务推迟; +integration 部分复用正式 O30 夹具,验证 20 关节拟合、独立留出及 JSON 重建相同 URDF。 +与 `test_diagnostic_quality.py` 共 28 项通过,16.08 秒。诊断压缩证据读取用 +`test_diagnostic_analysis.py`,19 项通过、2.24 秒。旧准备契约测试直接读取 +压缩索引导致一次失败,改用公共 `load_jsonl` 后只复查受影响组,30 项通过、 +1 项未选,3.32 秒。各组不累计为全量通过;实机边界见 +`O30_STABILITY_REVIEW_20260922.md`。 + 辅助侧摆端点离开条件使用 `test_witness_endpoint.py`。真实夹具 `o30_witness_terminal_gap.json.gz` 来自 `20260920_183257` 的 226 帧完整侧摆窗口, 末尾连续三帧缺命令,仅余两帧有效尾部。真实记录必须继续拒绝;追加的模拟后续帧 diff --git a/src/linkerhand_calibration/config/o30_right_product.yaml b/src/linkerhand_calibration/config/o30_right_product.yaml index e1faf70..848fec0 100644 --- a/src/linkerhand_calibration/config/o30_right_product.yaml +++ b/src/linkerhand_calibration/config/o30_right_product.yaml @@ -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_20260920_123651.yaml - camera_extrinsics_sha256: e21d21eaaf198d4daba44a44e0aa3bc46a86376213b84c3ddd30ae571714c701 + camera_extrinsics: config/o30_three_camera_extrinsics_20260922_101452.yaml + camera_extrinsics_sha256: d28d3e8771f5bc20d4ca6c9bfd11977f26fefb8478d125a113c013fb7a208120 calibration_config: package://linkerhand_calibration/config/o30_three_camera_calibration.yaml calibration_config_sha256: 95a8fd3299151755ea3b5209b74b9ec9de1c2d07a05d557e9018e742fb66ce30 tag_config: package://linkerhand_calibration/config/o30_right_18_tags.yaml 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 ba05063..796d0c3 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/capture_plan.py @@ -14,7 +14,9 @@ FIXED = "fixed" ADAPTIVE = "adaptive_2_to_3" STAGED = "staged_2_to_3" TASKWISE_STAGED = "taskwise_staged_2_to_3" +TASKWISE_TWO_ROUND = "taskwise_2_plus_1" STAGED_POLICIES = frozenset((STAGED, TASKWISE_STAGED)) +PREPARATION_POLICIES = STAGED_POLICIES | {TASKWISE_TWO_ROUND} LEGACY_TRAINING = (0, 1, 2) LEGACY_HOLDOUT = 3 @@ -69,7 +71,8 @@ class CapturePlan: return next(task for task in self.tasks if task.task_key == key) def as_dict(self): - version = ("capture_plan_v3" if self.policy == TASKWISE_STAGED else + version = ("capture_plan_v4" 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) result = {"version": version, "policy": self.policy, "command_capture_mode": self.command_capture_mode, @@ -88,6 +91,8 @@ class CapturePlan: result["schedule"] = ( "first_whole_hand_pass_then_taskwise_second_pass_with_immediate_supplement_then_validation" ) + if self.policy == TASKWISE_TWO_ROUND: + result["schedule"] = "each_task_two_training_rounds_then_independent_validation" return result @property @@ -109,7 +114,7 @@ def task_partition(profile, task=None, *, joint=None): if joint is not None: task = task_for_joint(profile, joint) key = getattr(task, "key", task) - default = (LEGACY_TRAINING[:2] if profile.quality.training_policy in STAGED_POLICIES + default = (LEGACY_TRAINING[:2] if profile.quality.training_policy in PREPARATION_POLICIES else profile.quality.training_cycles) training = tuple(profile.quality.task_training_cycles.get(key, default)) holdout = profile.quality.holdout_cycle @@ -128,6 +133,10 @@ def select_task_training(profile, task_key, cycles): raise ValueError("capture_plan_unknown_task") if cycles not in (LEGACY_TRAINING, LEGACY_TRAINING[:2]): raise ValueError("capture_plan_invalid_training_partition") + if profile.quality.training_policy == TASKWISE_TWO_ROUND: + if cycles != LEGACY_TRAINING[:2]: + raise ValueError("taskwise_two_round_cannot_add_training") + return profile if (cycles != tuple(profile.quality.training_cycles) and profile.quality.training_policy not in {ADAPTIVE, *STAGED_POLICIES}): raise ValueError("capture_plan_fixed_training_cannot_change") @@ -136,17 +145,22 @@ def select_task_training(profile, task_key, cycles): def configure_training(profile, policy): - if policy not in {FIXED, ADAPTIVE, *STAGED_POLICIES}: + if policy not in {FIXED, ADAPTIVE, *STAGED_POLICIES, TASKWISE_TWO_ROUND}: raise ValueError("unknown_training_policy") if profile.artifacts.output_schema_version in {2, 3} and ( tuple(profile.quality.training_cycles) != LEGACY_TRAINING or profile.quality.holdout_cycle != LEGACY_HOLDOUT): raise ValueError("production_partition_requires_versioned_training_policy") - if policy in {ADAPTIVE, *STAGED_POLICIES} and (profile.artifacts.output_schema_version < 3 + if policy in {ADAPTIVE, *STAGED_POLICIES, TASKWISE_TWO_ROUND} and (profile.artifacts.output_schema_version < 3 or not profile.command_based_release or profile.acquisition.command_capture_mode != "interleaved" or tuple(profile.quality.training_cycles) != LEGACY_TRAINING or profile.quality.holdout_cycle != LEGACY_HOLDOUT): raise ValueError("adaptive_training_requires_measured_interleaved_command_release") + # Fixed two-round collection uses the ordinary task-contiguous scheduler. + # Bind every task's partition up front; neither runtime nor replay can + # supplement it or reinterpret validation cycle 3 as training cycle 2. + selections = ({task.key: LEGACY_TRAINING[:2] for task in profile.motion.tasks} + if policy == TASKWISE_TWO_ROUND else {}) return replace(profile, quality=replace(profile.quality, - training_policy=policy, task_training_cycles={})) + training_policy=policy, task_training_cycles=selections)) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py index 394fe8b..e5913ca 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py @@ -9,7 +9,7 @@ from typing import Mapping from ..domain.profile import CalibrationProfile, validate_profile from ..domain.result import CalibrationResult, JointMapping -from ..domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED, TASKWISE_STAGED, +from ..domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND, training_cycles) from ..urdf.kinematics import UrdfKinematicModel from .motion_fit import channel_for_joint, fit_profile_motion, joint_input_direction @@ -100,7 +100,7 @@ def build_axis_observations(profile, model, geometry_records, motion, zero_profi parent = zero_profile.phase_parent_joint.get(joint) if parent is not None and parent not in by_joint: if (cycle != plan.task(task.key).holdout - and profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED}): + and profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND}): continue # No same-cycle parent observation: do not fabricate a geometric sample. raise ValueError("spatial axis order must place phase parents first") task = next(task for task in profile.motion.tasks if joint in task.joints) @@ -184,7 +184,7 @@ def fit_profile_calibration(profile: CalibrationProfile, source_urdf: Path, reco zero = solve_urdf_zero_offsets(source_urdf=source_urdf, measurements=observations, curves=spatial_curves, motor_by_joint={joint: channel_for_joint(profile, joint) for joint in curves}, zero_profile=zero_profile, training_cycles=geometry_training, validation_cycle=geometry_holdout, - allow_partial_training_cycles=profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED}, + allow_partial_training_cycles=profile.quality.training_policy in {ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND}, frozen_training=None if frozen_training is None else frozen_training["zero"], maximum_offset_rad=math.radians(20), finger_maximum_offset_rad=math.radians(20), maximum_validation_mae_rad=math.radians(1), maximum_validation_p95_rad=math.radians(2), diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/diagnostics.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/diagnostics.py index be205c4..01754c0 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/profiles/diagnostics.py +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/diagnostics.py @@ -15,6 +15,13 @@ class DiagnosticCapturePreset: DIAGNOSTIC_CAPTURE_PRESETS = { + "o30_pinky_side": DiagnosticCapturePreset( + ProfileKey("O30", "right", "o30_right_18", 1), + (("pinky_mcp_pitch_side", ("pinky_mcp_pitch",)), + ("pinky_pip_side", ("pinky_pip",)), + ("pinky_dip_side", ("pinky_dip",))), + label_zh="小指侧面 MCP/PIP/DIP 诊断", + ), "o30_thumb_roll": DiagnosticCapturePreset( ProfileKey("O30", "right", "o30_right_18", 1), (("thumb_cmc_roll_front", ("thumb_cmc_roll",)),), diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py index 2b3d5fe..a40b97d 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py @@ -15,9 +15,9 @@ def preparation_image_budget(profile, *, joint_count=1): from ..core.geometry.tag_pose.production_image_motion import ( DENSE_PREPARATION_SAMPLING, image_parameters_for_sampling, ) - from ..core.domain.capture_plan import STAGED_POLICIES + from ..core.domain.capture_plan import PREPARATION_POLICIES dense = (profile.key.model == 'O30' - and profile.quality.training_policy in STAGED_POLICIES + and profile.quality.training_policy in PREPARATION_POLICIES and bool(profile.motion.preparation_witnesses) and joint_count == 1) return ((256, image_parameters_for_sampling(DENSE_PREPARATION_SAMPLING)) if dense else (128, image_parameters_for_sampling())) @@ -106,9 +106,9 @@ def configure_preparation(profile, policy='occlusion_aware_witness_v2'): if policy not in {'occlusion_aware_witness_v1', 'occlusion_aware_witness_v2', 'occlusion_aware_trajectory_v3'}: raise ValueError('unknown_preparation_policy') - from ..core.domain.capture_plan import STAGED_POLICIES + from ..core.domain.capture_plan import PREPARATION_POLICIES if (profile.key.profile_id != 'O30/right/o30_right_18/v1' - or profile.quality.training_policy not in STAGED_POLICIES): + or profile.quality.training_policy not in PREPARATION_POLICIES): raise ValueError('preparation_witness_requires_o30_staged_session') from ..core.fitting.motion_fit import channel_for_joint witnesses = {} diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py index bd67697..3e7b765 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py @@ -44,10 +44,10 @@ def validate_executable_profile(profile, source_urdf): if spec.observer_pose_policy not in {"frozen_parent_v1", "rigid_pair_v1", "rigid_pair_trajectory_v3"}: raise ValueError(f"unknown_observer_pose_policy:{name}") - from ..core.domain.capture_plan import STAGED_POLICIES + from ..core.domain.capture_plan import PREPARATION_POLICIES if is_rigid_observer(spec.observer_pose_policy) and ( spec.kind != "relative_rotation" or profile.vision_motion - or profile.quality.training_policy not in STAGED_POLICIES): + or profile.quality.training_policy not in PREPARATION_POLICIES): raise ValueError(f"rigid_pair_requires_staged_relative_measurement:{name}") if is_rigid_observer(spec.observer_pose_policy) and name not in observer_dependencies: raise ValueError(f"rigid_pair_requires_independent_parent_hinge:{name}") diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py index 335f291..c80c26b 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py @@ -1320,6 +1320,12 @@ class CalibrationCoordinator: def _defer_unobservable_task(self, motion, geometry_error): """Continue independent collection after bounded v3 observation fails.""" + from ..core.domain.capture_plan import TASKWISE_TWO_ROUND + if self.profile.quality.training_policy == TASKWISE_TWO_ROUND: + # This policy promises a complete task before the next one. + # Existing local observer recovery runs first; an unresolved + # task must pause instead of silently changing the visit order. + return False from .task_deferral import recoverable_observation_reason from ..profiles.observer_policy import TRAJECTORY_OBSERVER refresh = bool(motion.parent_model_refresh_for) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_analysis.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_analysis.py index dc805ea..25d9520 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_analysis.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_analysis.py @@ -17,7 +17,7 @@ from pathlib import Path from ..core.geometry.tag_pose.motion_evidence import MotionEvidenceFrame, MotionRelation, RoleCandidates from ..core.geometry.tag_pose.types import SquareTagPose -from ..storage import atomic_write_json +from ..storage import atomic_write_json, load_jsonl from .motion_provenance import source_frame_sha256 from .scan_quality import observation_streams @@ -231,8 +231,7 @@ def analyze_capture(profile, raw_path: Path, *, output_dir: Path | None = None, output = raw_path.parent if output_dir is None else output_dir.resolve() _check_output_targets(raw_path, output) before = _file_sha256(raw_path) - with raw_path.open(encoding="utf-8") as source: - rows = [json.loads(line) for line in source if line.strip()] + rows = load_jsonl(raw_path) header, stages = _stages(profile, rows) analyzed = [] for index, (identity, relations, records) in enumerate(stages): diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_quality.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_quality.py index 0e39766..9e2833f 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_quality.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/diagnostic_quality.py @@ -77,7 +77,8 @@ def evaluate_diagnostic_unit(profile: CalibrationProfile, unit: ScanUnit, attemp coverage.append({field: name, "view": view, "task_name": unit.task_key, "cycle": unit.cycle, "direction": unit.direction, "attempt": attempt, "image_stamp_ns": row["image_stamp_ns"], "sample_phase": "sweep", - f"feedback_{profile.command.unit}": feedback[task.command_index]}) + f"feedback_{profile.command.unit}": feedback[task.command_index], + f"command_{profile.command.unit}": row["command_vector"][task.command_index]}) observed = evaluate_capture_unit(profile, unit, attempt, coverage, first_cycle_spans={}, include_steady=False) return DiagnosticUnitQuality(observed, accepted, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py index 022c6f5..6cc7ba7 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py @@ -16,7 +16,7 @@ from .diagnostic_capture import DiagnosticCapturePlan from ..core.domain.profile import ACQUISITION_POLICY_VERSION from ..core.domain.motion_path import task_segments, scan_identity from ..core.domain.capture_plan import (CapturePlan, ADAPTIVE, STAGED, - TASKWISE_STAGED, STAGED_POLICIES, LEGACY_TRAINING, LEGACY_HOLDOUT) + TASKWISE_STAGED, TASKWISE_TWO_ROUND, STAGED_POLICIES, LEGACY_TRAINING, LEGACY_HOLDOUT) TRAINING_CYCLES = LEGACY_TRAINING HOLDOUT_CYCLE = LEGACY_HOLDOUT CAPTURE_SCHEDULE_VERSION = "unified_schedule_v7_single_pass_endpoint_images" @@ -29,8 +29,10 @@ IMAGE_REFERENCE_SCHEDULE_VERSION = "unified_schedule_v11_stationary_image_refere 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_v15_taskwise_two_round" SUPPORTED_CAPTURE_SCHEDULE_VERSIONS = frozenset((CAPTURE_SCHEDULE_VERSION, STAGED_CAPTURE_SCHEDULE_VERSION, TASKWISE_STAGED_CAPTURE_SCHEDULE_VERSION, + TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION, ADAPTIVE_CAPTURE_SCHEDULE_VERSION, SEPARATE_CAPTURE_SCHEDULE_VERSION, *(f"{SEGMENTED_CAPTURE_SCHEDULE_VERSION}_{mode}" for mode in ("interleaved", "separate")), *(f"{PATH_CAPTURE_SCHEDULE_VERSION}_{mode}" for mode in ("interleaved", "separate")), *(f"{VISUAL_CAPTURE_SCHEDULE_VERSION}_{mode}" @@ -41,6 +43,8 @@ SUPPORTED_CAPTURE_SCHEDULE_VERSIONS = frozenset((CAPTURE_SCHEDULE_VERSION, def capture_schedule_version(profile): + if profile.quality.training_policy == TASKWISE_TWO_ROUND: + return TASKWISE_TWO_ROUND_CAPTURE_SCHEDULE_VERSION if profile.quality.training_policy == STAGED: return STAGED_CAPTURE_SCHEDULE_VERSION if profile.quality.training_policy == TASKWISE_STAGED: diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py index c6166e1..f69f82d 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py @@ -324,8 +324,8 @@ def main(args: list[str] | None = None) -> None: parser.add_argument("--commands-disabled", action="store_true") parser.add_argument("--no-resume", action="store_true") parser.add_argument("--training-policy", choices=("fixed", "adaptive_2_to_3", "staged_2_to_3", - "taskwise_staged_2_to_3"), default=None, - help="试验采集策略:两轮训练,必要时补第三轮;保留独立验证,默认沿用配置") + "taskwise_staged_2_to_3", "taskwise_2_plus_1"), default=None, + help="taskwise_2_plus_1:每任务连续两轮训练加一轮验证、不补轮;其他策略保留原语义,默认沿用配置") parser.add_argument("--startup-timeout-seconds", type=float, default=None, help="等待节点初始化及设备就绪的秒数(默认 120);大断点逐帧校验可显式延长,不改变运动保护") diagnostic_options = parser.add_mutually_exclusive_group() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/training.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/training.py index 4d23a92..72f9965 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/training.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/training.py @@ -6,8 +6,8 @@ import threading import time import traceback -from ..core.domain.capture_plan import (ADAPTIVE, STAGED, TASKWISE_STAGED, - CapturePlan, configure_training, evidence_digest, select_task_training) +from ..core.domain.capture_plan import (ADAPTIVE, STAGED, TASKWISE_STAGED, TASKWISE_TWO_ROUND, + CapturePlan, LEGACY_HOLDOUT, configure_training, evidence_digest, select_task_training) from ..core.domain.reference import JointZeroReference from ..core.fitting.training_quality import (TRAINING_DECISION_VERSION, assess_training, training_snapshot) @@ -27,6 +27,11 @@ def resolve_capture_plan(profile, records, *, source_urdf=None, verify_decisions raise ValueError("adaptive_decisions_in_fixed_capture") if headers and "capture_plan" in headers[0] and headers[0]["capture_plan"] != plan.as_dict(): raise ValueError("capture_plan_header_changed") + if base.quality.training_policy == TASKWISE_TWO_ROUND and any( + row.get("kind") in {"joint_sample", "secondary_joint_sample", "scan_unit_complete"} + and row.get("cycle") not in (0, 1, LEGACY_HOLDOUT) + for row in records): + raise ValueError("taskwise_two_round_unexpected_cycle") return base if len(headers) != 1 or headers[0].get("capture_plan") != plan.as_dict(): raise ValueError("adaptive_capture_requires_matching_plan_header") diff --git a/src/linkerhand_calibration/test/o30_formal_fixture.py b/src/linkerhand_calibration/test/o30_formal_fixture.py index b8ae1f7..4c791e8 100644 --- a/src/linkerhand_calibration/test/o30_formal_fixture.py +++ b/src/linkerhand_calibration/test/o30_formal_fixture.py @@ -24,8 +24,12 @@ from linkerhand_calibration.runtime.image_capture import IMAGE_OBSERVATION_POLIC from linkerhand_calibration.runtime.motion_provenance import source_frame_sha256 -def formal_capture(capture, directory, *, adaptive=False): +def formal_capture(capture, directory, *, adaptive=False, training_policy=None): profile, source, original, truth = capture + if training_policy is not None: + if adaptive: + raise ValueError("select exactly one training policy") + profile = configure_training(profile, training_policy) if adaptive: profile = configure_training(profile, ADAPTIVE) records = deepcopy(original) diff --git a/src/linkerhand_calibration/test/test_diagnostic_analysis.py b/src/linkerhand_calibration/test/test_diagnostic_analysis.py index fd640d8..64d92d9 100644 --- a/src/linkerhand_calibration/test/test_diagnostic_analysis.py +++ b/src/linkerhand_calibration/test/test_diagnostic_analysis.py @@ -33,6 +33,40 @@ def mock_image_analysis(monkeypatch, callback=None): return calls +@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() diff --git a/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py b/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py index 8f0408a..ec53b8e 100644 --- a/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py +++ b/src/linkerhand_calibration/test/test_diagnostic_presets_cli.py @@ -14,6 +14,21 @@ from linkerhand_calibration.runtime.engine import CalibrationEngine PACKAGE = Path(__file__).resolve().parents[1] +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') diff --git a/src/linkerhand_calibration/test/test_diagnostic_quality.py b/src/linkerhand_calibration/test/test_diagnostic_quality.py index b112ffa..55c8fd5 100644 --- a/src/linkerhand_calibration/test/test_diagnostic_quality.py +++ b/src/linkerhand_calibration/test/test_diagnostic_quality.py @@ -32,7 +32,7 @@ def raw_frames(profile, unit): return rows -@pytest.mark.parametrize("layout", ["o6_right_8", "l6_right_8", "o12_right_16", "g20_right_19"]) +@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] @@ -101,6 +101,20 @@ def test_visible_tags_and_full_endpoint_span_cannot_hide_a_synchronization_gap() 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") diff --git a/src/linkerhand_calibration/test/test_preparation_contract.py b/src/linkerhand_calibration/test/test_preparation_contract.py index 8708287..700f3d1 100644 --- a/src/linkerhand_calibration/test/test_preparation_contract.py +++ b/src/linkerhand_calibration/test/test_preparation_contract.py @@ -127,7 +127,8 @@ 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: - row = json.loads(host.raw_path.read_text().splitlines()[0]) + 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: diff --git a/src/linkerhand_calibration/test/test_taskwise_two_round.py b/src/linkerhand_calibration/test/test_taskwise_two_round.py new file mode 100644 index 0000000..121a454 --- /dev/null +++ b/src/linkerhand_calibration/test/test_taskwise_two_round.py @@ -0,0 +1,102 @@ +"""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 configure_training(load_bundled_hand_profile('o30_right_18'), TASKWISE_TWO_ROUND) + + +def test_every_task_finishes_two_training_rounds_and_validation_before_next(profile): + 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_v4' + 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): + from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_image_budget + prepared = configure_preparation(profile, 'occlusion_aware_trajectory_v3') + assert preparation_image_budget(prepared)[0] == 256 + assert CalibrationEngine(prepared).scan_units() == CalibrationEngine(profile).scan_units() + + +def test_unresolved_task_cannot_be_moved_into_another_tasks_training_stage(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() + 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']