From 93e654578a5671b53d556cc4c1d58963fbb6456f Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Thu, 24 Sep 2026 12:23:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E6=8B=9F=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ree_camera_extrinsics_20260923_181333.yaml | 60 + .../O30_AUTOMATIC_URDF_FIX.md | 111 + .../O30_CURRENT_FLOW_REVIEW.md | 180 ++ .../O30_OFFLINE_LOGIC_REVIEW.md | 148 ++ .../O30_RAW_PROTOCOL_IMPLEMENTATION.md | 170 +- .../O30_REMAINING_ISSUES.md | 83 + src/linkerhand_calibration/README.md | 107 +- src/linkerhand_calibration/TESTING.md | 197 ++ .../config/o30_right_product.yaml | 6 +- .../config/profiles/o30_right_18.yaml | 13 +- .../core/domain/capture_plan.py | 17 +- .../core/domain/status.py | 2 + .../core/fitting/command_support.py | 77 + .../core/fitting/raw_joint_draft.py | 322 +++ .../core/fitting/raw_joint_uncertainty.py | 12 +- .../core/fitting/raw_joint_update.py | 88 + .../core/fitting/raw_joint_validation.py | 10 +- .../geometry/tag_pose/finger_chain_images.py | 24 +- .../core/geometry/tag_pose/motion_evidence.py | 64 +- .../geometry/tag_pose/urdf_command_images.py | 106 +- .../core/urdf/limits.py | 10 +- .../core/urdf/linear_drive.py | 45 + .../linkerhand_calibration/core/urdf/plan.py | 4 +- .../linkerhand_calibration/profiles/scope.py | 4 +- .../runtime/adapters/o30_ros.py | 5 +- .../runtime/artifacts/capture_validation.py | 11 +- .../runtime/artifacts/raw_joint_draft.py | 348 +++ .../artifacts/raw_joint_finalization.py | 11 +- .../runtime/artifacts/raw_joint_report.py | 81 +- .../runtime/artifacts/replay.py | 12 + .../artifacts/serializers/measured_v3.py | 12 +- .../linkerhand_calibration/runtime/capture.py | 72 +- .../runtime/coordinator.py | 152 +- .../runtime/direction_progress.py | 83 + .../linkerhand_calibration/runtime/engine.py | 4 +- .../runtime/motion_execution.py | 16 +- .../runtime/observation_coverage.py | 9 +- .../runtime/raw_completion.py | 119 + .../runtime/raw_evidence.py | 28 +- .../runtime/raw_quality.py | 7 +- .../runtime/raw_remount.py | 143 ++ .../runtime/raw_resume.py | 45 +- .../runtime/reporting/reasons_zh.py | 11 + .../linkerhand_calibration/runtime/runner.py | 79 +- .../linkerhand_calibration/runtime/safety.py | 2 +- .../runtime/scan_quality.py | 32 +- .../linkerhand_calibration/runtime/session.py | 17 +- .../runtime/snapshot.py | 3 +- .../linkerhand_calibration/runtime/status.py | 15 +- ...o30_recorded_motion_initialization.json.gz | Bin 0 -> 36837 bytes .../o30_recorded_root_directions.json.gz | Bin 0 -> 29073 bytes .../test/o30_capture_fixture.py | 2 +- .../test/test_acquisition.py | 432 ---- .../test/test_adaptive_command_sampling.py | 109 - .../test/test_adaptive_training.py | 159 -- .../test/test_affine_mimic_export.py | 103 - .../test/test_alignment_view.py | 39 - .../test/test_all_model_artifact_pipeline.py | 99 - .../test/test_all_view_image_acceptance.py | 103 - .../test/test_all_view_image_capture.py | 74 - .../test/test_architecture.py | 307 --- .../test/test_artifact_publication.py | 68 - .../test/test_axis_line_gauge.py | 60 - .../test/test_bounded_mimic_approximation.py | 44 - .../test/test_branch_initialization.py | 225 -- .../test/test_branch_worker.py | 63 - .../test/test_cad_image_geometry.py | 163 -- .../test_calibrated_joint_state_bridge.py | 177 -- .../test/test_calibrated_motion_limits.py | 161 -- .../test/test_calibration_coordinator.py | 294 --- .../test/test_camera_preflight.py | 222 -- .../test/test_camera_timing.py | 206 -- .../test/test_capture_journal.py | 87 - .../test/test_capture_metadata_concurrency.py | 111 - .../test/test_capture_plan.py | 156 -- .../test/test_capture_provenance.py | 183 -- .../test/test_capture_runtime_index.py | 69 - .../test/test_chain_candidate_audit.py | 50 - .../test/test_chain_capture.py | 294 --- .../test/test_chain_capture_runtime.py | 124 - .../test/test_checkpoint_extension.py | 77 - .../test/test_checkpoint_observer_upgrade.py | 98 - .../test/test_checkpoint_startup.py | 66 - .../test/test_command_motion.py | 71 - .../test/test_compact_calibration_json.py | 182 -- .../test/test_conditioned_witness.py | 84 - .../test/test_config.py | 398 --- src/linkerhand_calibration/test/test_core.py | 244 -- .../test/test_declared_byte_direction.py | 25 - .../test/test_declared_witness_path.py | 49 - .../test/test_detection_dispatch.py | 109 - .../test/test_diagnostic_analysis.py | 255 -- .../test/test_diagnostic_coordinator.py | 401 --- .../test/test_diagnostic_observation_scope.py | 216 -- .../test/test_diagnostic_presets_cli.py | 128 - .../test/test_diagnostic_provenance.py | 98 - .../test/test_diagnostic_quality.py | 159 -- .../test/test_diagnostic_reference_flow.py | 218 -- .../test/test_diagnostic_report.py | 230 -- .../test/test_diagnostic_session.py | 203 -- .../test/test_diagnostic_thumb_cli.py | 246 -- .../test_diagnostic_yaw_hold_coordinator.py | 139 - .../test/test_diagnostic_yaw_hold_plan.py | 59 - .../test/test_diagnostics.py | 105 - .../test/test_directional_command_mapping.py | 156 -- .../test/test_dual_mapping.py | 134 - .../test/test_extrinsics_node.py | 326 --- .../test/test_feedback_capture_timing.py | 37 - .../test/test_feedback_reversal_progress.py | 131 - .../test/test_final_image_holdout.py | 85 - .../test/test_final_image_sources.py | 64 - .../test/test_finalization_worker.py | 58 - .../test/test_finger_chain_diagnostic.py | 251 -- .../test/test_fixed_reference_pose.py | 234 -- .../test/test_frozen_candidate_selection.py | 228 -- .../test/test_frozen_chain_validation.py | 135 - .../test/test_frozen_evidence_storage.py | 84 - .../test/test_frozen_rotation_reference.py | 47 - .../test/test_frozen_tag_replay.py | 138 - .../test/test_full_hand.py | 1092 -------- .../test/test_g20_right_product.py | 985 ------- .../test/test_golden_regression.py | 72 - .../test/test_held_joint_replay.py | 86 - .../test/test_hikrobot_camera.py | 136 - .../test/test_image_bundle_optimizer.py | 179 -- .../test/test_image_error_budget.py | 124 - .../test/test_image_model_selection.py | 126 - .../test_image_motion_capture_evidence.py | 223 -- .../test/test_image_motion_live_flow.py | 79 - .../test/test_image_motion_provenance.py | 270 -- .../test/test_image_motion_tracker_commit.py | 150 -- .../test/test_image_projection_worker.py | 53 - .../test/test_independent_urdf_export.py | 206 -- .../test_interleaved_motion_efficiency.py | 89 - .../test/test_joint_pair_diagnostic.py | 168 -- .../test/test_joint_zero_runtime.py | 291 --- .../test/test_joint_zero_timing.py | 102 - .../test/test_json_urdf_rebuild.py | 92 - .../test/test_kinematic_chain_diagnostic.py | 90 - .../test/test_l6_right_profile.py | 345 --- .../test/test_mapping_input_contract.py | 74 - .../test/test_measured_feedback_time.py | 68 - .../test/test_measured_finalization.py | 132 - .../test/test_measured_transfer.py | 278 -- .../test_measured_transfer_finalization.py | 117 - .../test/test_measured_zero_mapping.py | 47 - .../test/test_motion_branch_evidence.py | 257 -- .../test/test_motion_capture_tracking.py | 221 -- .../test/test_motion_capture_transitions.py | 45 - .../test/test_motion_confirmed_tracking.py | 273 -- .../test/test_motion_image_diagnostics.py | 169 -- .../test/test_motion_reference_provenance.py | 277 -- .../test/test_motion_transfer.py | 315 --- .../test/test_native_position_feedback.py | 387 --- .../test/test_native_rotation_curve.py | 72 - .../test/test_o12_axis_residual_policy.py | 60 - .../test/test_o12_full_hand_zero.py | 243 -- .../test/test_o12_observation_resolution.py | 131 - .../test/test_o12_readonly_bridge.py | 37 - .../test/test_o12_recorded_replay.py | 49 - .../test/test_o12_right_profile.py | 465 ---- .../test/test_o12_thumb_pnp.py | 61 - .../test/test_o30_artifacts.py | 124 - .../test/test_o30_launch.py | 158 -- .../test/test_o30_profile.py | 359 --- .../test/test_o30_side_observer.py | 69 - .../test/test_o6_right_profile.py | 377 --- .../test/test_o6_tag_installation.py | 25 - .../test/test_observation_capture.py | 133 - .../test_observation_feedback_dependencies.py | 74 - .../test/test_observation_strategy.py | 171 -- .../test/test_observation_timing.py | 134 - .../test/test_observer_policy.py | 71 - .../test/test_observer_pose.py | 136 - .../test/test_observer_preparation.py | 352 --- .../test/test_observer_reference.py | 215 -- .../test_offline_motion_initialization.py | 89 + .../test/test_offline_replay.py | 86 - .../test/test_optical_verification.py | 69 - .../test/test_package_rename.py | 56 - .../test/test_parallel_image_replay.py | 49 - .../test/test_parent_reference_provenance.py | 169 -- .../test/test_parent_reference_reuse.py | 262 -- .../test/test_partial_o30_calibration.py | 161 -- .../test/test_passed_resume.py | 225 -- .../test/test_paused_snapshot.py | 106 - src/linkerhand_calibration/test/test_pnp.py | 895 ------- .../test/test_pnp_branch_confirmation.py | 309 --- .../test/test_pnp_metadata_concurrency.py | 93 - .../test/test_pnp_parameter_wiring.py | 50 - .../test/test_pnp_runtime_policy.py | 76 - .../test/test_pose_capture_evidence.py | 125 - .../test/test_pose_tracking_resume_policy.py | 112 - .../test/test_preparation_admission.py | 96 - .../test/test_preparation_contract.py | 157 -- .../test/test_preparation_density.py | 141 - .../test/test_preparation_sync.py | 176 -- .../test/test_preparation_transition.py | 216 -- .../test/test_preparation_witness.py | 343 --- .../test/test_prepared_resume.py | 221 -- .../test/test_process_finalizer.py | 149 -- .../test/test_production_image_motion.py | 218 -- .../test/test_profile_finalization.py | 244 -- .../test/test_profile_loader.py | 136 - .../test/test_progress_status.py | 107 - .../test/test_raw_draft.py | 552 ++++ .../test/test_raw_failure_regression.py | 28 - .../test/test_raw_joint_candidate_gates.py | 38 - .../test/test_raw_joint_capture.py | 227 -- .../test/test_raw_joint_release.py | 91 - .../test/test_raw_joint_update.py | 64 + .../test/test_raw_lightweight.py | 581 +++++ .../test/test_raw_partial_scope.py | 68 + .../test/test_raw_remount.py | 195 ++ .../test/test_raw_resume.py | 112 - .../test/test_recorded_diagnostic_replay.py | 147 -- .../test/test_recorded_side_preparation.py | 49 - .../test/test_rectified_camera_contract.py | 73 - .../test/test_reference_lock.py | 45 - .../test/test_reference_waypoints.py | 62 - .../test/test_relative_standard_mimic.py | 53 - .../test/test_resume_storage.py | 256 -- .../test/test_resume_v2.py | 48 - .../test/test_resumed_image_reference.py | 177 -- .../test/test_rigid_pair_observer.py | 186 -- .../test/test_rotation_holdout_diagnostics.py | 143 -- .../test/test_runtime_gc_lifecycle.py | 77 - .../test/test_runtime_pose_branch_guard.py | 297 --- .../test/test_runtime_readiness.py | 104 - .../test/test_safety_policy.py | 117 - .../test/test_sample_schema.py | 69 - .../test/test_separate_mapping_capture.py | 139 - .../test/test_session_execution.py | 185 -- .../test/test_shared_pose_conditioning.py | 170 -- .../test/test_shared_pose_handoff.py | 328 --- .../test/test_shared_tag_pose_bridge.py | 296 --- .../test/test_shared_zero_conditioning.py | 169 -- .../test/test_single_pass_capture.py | 151 -- .../test/test_source_mimic_export.py | 200 -- .../test/test_spatial_solve_order.py | 33 - .../test/test_staged_artifacts.py | 105 - .../test/test_staged_runtime.py | 99 - .../test/test_staged_training.py | 210 -- .../test/test_stall_retry.py | 87 - .../test/test_standard_correction_plan.py | 84 - .../test/test_standard_loader.py | 26 - .../test/test_standard_urdf_acceptance.py | 124 - .../test/test_stationary_image_reference.py | 271 -- .../test/test_steady_feedback_window.py | 143 -- .../test/test_storage.py | 69 - .../test/test_tag_border.py | 110 - .../test/test_task_input_support.py | 97 - .../test/test_taskwise_recovery.py | 192 -- .../test/test_taskwise_two_round.py | 131 - .../test/test_three_camera_diagnostics.py | 697 ----- .../test/test_trajectory.py | 297 --- .../test/test_trajectory_observation_v3.py | 207 -- .../test/test_trajectory_policy.py | 39 - .../test/test_transfer_artifact_contract.py | 239 -- .../test/test_transferred_hinge_geometry.py | 126 - .../test/test_turnaround_support.py | 191 ++ .../test/test_unified_artifact_pair.py | 81 - .../test/test_unified_engine.py | 308 --- .../test/test_unified_launch.py | 181 -- .../test/test_unified_ros_host.py | 110 - .../test/test_unified_runner.py | 443 ---- .../test/test_urdf_command_images.py | 129 - .../test/test_urdf_comparison.py | 59 - .../test/test_urdf_patch_engine.py | 199 -- .../test/test_urdf_validation.py | 53 - .../test/test_urdf_zero.py | 2284 ----------------- .../test/test_virtual_control_contract.py | 84 - .../test/test_visual_motion.py | 266 -- .../test/test_witness_endpoint.py | 104 - .../test/test_zero_approach_path.py | 105 - .../test/test_zero_calibration.py | 340 --- .../test/test_zero_evidence_coverage.py | 58 - .../test/test_zero_recovery.py | 83 - .../linkerhand_O30i_left-V2_0819.urdf | 605 +++++ .../o30_left/linkerhand_O30i_left-update.urdf | 605 +++++ .../urdf/o30_left/meshes/hand_base_link.STL | Bin 0 -> 1110284 bytes .../urdf/o30_left/meshes/index_distal.STL | Bin 0 -> 1033884 bytes .../o30_left/meshes/index_metacarpals.STL | Bin 0 -> 599484 bytes .../urdf/o30_left/meshes/index_middle.STL | Bin 0 -> 807484 bytes .../urdf/o30_left/meshes/index_proximal.STL | Bin 0 -> 1069584 bytes .../urdf/o30_left/meshes/middle_distal.STL | Bin 0 -> 1033884 bytes .../o30_left/meshes/middle_metacarpals.STL | Bin 0 -> 599484 bytes .../urdf/o30_left/meshes/middle_middle.STL | Bin 0 -> 807484 bytes .../urdf/o30_left/meshes/middle_proximal.STL | Bin 0 -> 1069584 bytes .../urdf/o30_left/meshes/pinky_distal.STL | Bin 0 -> 1033884 bytes .../o30_left/meshes/pinky_metacarpals.STL | Bin 0 -> 599484 bytes .../urdf/o30_left/meshes/pinky_middle.STL | Bin 0 -> 807484 bytes .../urdf/o30_left/meshes/pinky_proximal.STL | Bin 0 -> 1069584 bytes .../urdf/o30_left/meshes/ring_distal.STL | Bin 0 -> 1033884 bytes .../urdf/o30_left/meshes/ring_metacarpals.STL | Bin 0 -> 599484 bytes .../urdf/o30_left/meshes/ring_middle.STL | Bin 0 -> 807484 bytes .../urdf/o30_left/meshes/ring_proximal.STL | Bin 0 -> 1069584 bytes .../urdf/o30_left/meshes/thumb_distal.STL | Bin 0 -> 1536384 bytes .../meshes/thumb_metacarpals_base1.STL | Bin 0 -> 600684 bytes .../meshes/thumb_metacarpals_base2.STL | Bin 0 -> 723484 bytes .../urdf/o30_left/meshes/thumb_proximal.STL | Bin 0 -> 736984 bytes .../urdf/o30_right/o30_right_draft.urdf | 1187 +++++++++ 302 files changed, 7094 insertions(+), 38863 deletions(-) create mode 100644 config/o30_three_camera_extrinsics_20260923_181333.yaml create mode 100644 src/linkerhand_calibration/O30_AUTOMATIC_URDF_FIX.md create mode 100644 src/linkerhand_calibration/O30_CURRENT_FLOW_REVIEW.md create mode 100644 src/linkerhand_calibration/O30_OFFLINE_LOGIC_REVIEW.md create mode 100644 src/linkerhand_calibration/O30_REMAINING_ISSUES.md create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_support.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_draft.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_update.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/linear_drive.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_draft.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/direction_progress.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/raw_completion.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/raw_remount.py create mode 100644 src/linkerhand_calibration/test/fixtures/o30_recorded_motion_initialization.json.gz create mode 100644 src/linkerhand_calibration/test/fixtures/o30_recorded_root_directions.json.gz delete mode 100644 src/linkerhand_calibration/test/test_acquisition.py delete mode 100644 src/linkerhand_calibration/test/test_adaptive_command_sampling.py delete mode 100644 src/linkerhand_calibration/test/test_adaptive_training.py delete mode 100644 src/linkerhand_calibration/test/test_affine_mimic_export.py delete mode 100644 src/linkerhand_calibration/test/test_alignment_view.py delete mode 100644 src/linkerhand_calibration/test/test_all_model_artifact_pipeline.py delete mode 100644 src/linkerhand_calibration/test/test_all_view_image_acceptance.py delete mode 100644 src/linkerhand_calibration/test/test_all_view_image_capture.py delete mode 100644 src/linkerhand_calibration/test/test_architecture.py delete mode 100644 src/linkerhand_calibration/test/test_artifact_publication.py delete mode 100644 src/linkerhand_calibration/test/test_axis_line_gauge.py delete mode 100644 src/linkerhand_calibration/test/test_bounded_mimic_approximation.py delete mode 100644 src/linkerhand_calibration/test/test_branch_initialization.py delete mode 100644 src/linkerhand_calibration/test/test_branch_worker.py delete mode 100644 src/linkerhand_calibration/test/test_cad_image_geometry.py delete mode 100644 src/linkerhand_calibration/test/test_calibrated_joint_state_bridge.py delete mode 100644 src/linkerhand_calibration/test/test_calibrated_motion_limits.py delete mode 100644 src/linkerhand_calibration/test/test_calibration_coordinator.py delete mode 100644 src/linkerhand_calibration/test/test_camera_preflight.py delete mode 100644 src/linkerhand_calibration/test/test_camera_timing.py delete mode 100644 src/linkerhand_calibration/test/test_capture_journal.py delete mode 100644 src/linkerhand_calibration/test/test_capture_metadata_concurrency.py delete mode 100644 src/linkerhand_calibration/test/test_capture_plan.py delete mode 100644 src/linkerhand_calibration/test/test_capture_provenance.py delete mode 100644 src/linkerhand_calibration/test/test_capture_runtime_index.py delete mode 100644 src/linkerhand_calibration/test/test_chain_candidate_audit.py delete mode 100644 src/linkerhand_calibration/test/test_chain_capture.py delete mode 100644 src/linkerhand_calibration/test/test_chain_capture_runtime.py delete mode 100644 src/linkerhand_calibration/test/test_checkpoint_extension.py delete mode 100644 src/linkerhand_calibration/test/test_checkpoint_observer_upgrade.py delete mode 100644 src/linkerhand_calibration/test/test_checkpoint_startup.py delete mode 100644 src/linkerhand_calibration/test/test_command_motion.py delete mode 100644 src/linkerhand_calibration/test/test_compact_calibration_json.py delete mode 100644 src/linkerhand_calibration/test/test_conditioned_witness.py delete mode 100644 src/linkerhand_calibration/test/test_config.py delete mode 100644 src/linkerhand_calibration/test/test_core.py delete mode 100644 src/linkerhand_calibration/test/test_declared_byte_direction.py delete mode 100644 src/linkerhand_calibration/test/test_declared_witness_path.py delete mode 100644 src/linkerhand_calibration/test/test_detection_dispatch.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_analysis.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_coordinator.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_observation_scope.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_presets_cli.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_provenance.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_quality.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_reference_flow.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_report.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_session.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_thumb_cli.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_yaw_hold_coordinator.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostic_yaw_hold_plan.py delete mode 100644 src/linkerhand_calibration/test/test_diagnostics.py delete mode 100644 src/linkerhand_calibration/test/test_directional_command_mapping.py delete mode 100644 src/linkerhand_calibration/test/test_dual_mapping.py delete mode 100644 src/linkerhand_calibration/test/test_extrinsics_node.py delete mode 100644 src/linkerhand_calibration/test/test_feedback_capture_timing.py delete mode 100644 src/linkerhand_calibration/test/test_feedback_reversal_progress.py delete mode 100644 src/linkerhand_calibration/test/test_final_image_holdout.py delete mode 100644 src/linkerhand_calibration/test/test_final_image_sources.py delete mode 100644 src/linkerhand_calibration/test/test_finalization_worker.py delete mode 100644 src/linkerhand_calibration/test/test_finger_chain_diagnostic.py delete mode 100644 src/linkerhand_calibration/test/test_fixed_reference_pose.py delete mode 100644 src/linkerhand_calibration/test/test_frozen_candidate_selection.py delete mode 100644 src/linkerhand_calibration/test/test_frozen_chain_validation.py delete mode 100644 src/linkerhand_calibration/test/test_frozen_evidence_storage.py delete mode 100644 src/linkerhand_calibration/test/test_frozen_rotation_reference.py delete mode 100644 src/linkerhand_calibration/test/test_frozen_tag_replay.py delete mode 100644 src/linkerhand_calibration/test/test_full_hand.py delete mode 100644 src/linkerhand_calibration/test/test_g20_right_product.py delete mode 100644 src/linkerhand_calibration/test/test_golden_regression.py delete mode 100644 src/linkerhand_calibration/test/test_held_joint_replay.py delete mode 100644 src/linkerhand_calibration/test/test_hikrobot_camera.py delete mode 100644 src/linkerhand_calibration/test/test_image_bundle_optimizer.py delete mode 100644 src/linkerhand_calibration/test/test_image_error_budget.py delete mode 100644 src/linkerhand_calibration/test/test_image_model_selection.py delete mode 100644 src/linkerhand_calibration/test/test_image_motion_capture_evidence.py delete mode 100644 src/linkerhand_calibration/test/test_image_motion_live_flow.py delete mode 100644 src/linkerhand_calibration/test/test_image_motion_provenance.py delete mode 100644 src/linkerhand_calibration/test/test_image_motion_tracker_commit.py delete mode 100644 src/linkerhand_calibration/test/test_image_projection_worker.py delete mode 100644 src/linkerhand_calibration/test/test_independent_urdf_export.py delete mode 100644 src/linkerhand_calibration/test/test_interleaved_motion_efficiency.py delete mode 100644 src/linkerhand_calibration/test/test_joint_pair_diagnostic.py delete mode 100644 src/linkerhand_calibration/test/test_joint_zero_runtime.py delete mode 100644 src/linkerhand_calibration/test/test_joint_zero_timing.py delete mode 100644 src/linkerhand_calibration/test/test_json_urdf_rebuild.py delete mode 100644 src/linkerhand_calibration/test/test_kinematic_chain_diagnostic.py delete mode 100644 src/linkerhand_calibration/test/test_l6_right_profile.py delete mode 100644 src/linkerhand_calibration/test/test_mapping_input_contract.py delete mode 100644 src/linkerhand_calibration/test/test_measured_feedback_time.py delete mode 100644 src/linkerhand_calibration/test/test_measured_finalization.py delete mode 100644 src/linkerhand_calibration/test/test_measured_transfer.py delete mode 100644 src/linkerhand_calibration/test/test_measured_transfer_finalization.py delete mode 100644 src/linkerhand_calibration/test/test_measured_zero_mapping.py delete mode 100644 src/linkerhand_calibration/test/test_motion_branch_evidence.py delete mode 100644 src/linkerhand_calibration/test/test_motion_capture_tracking.py delete mode 100644 src/linkerhand_calibration/test/test_motion_capture_transitions.py delete mode 100644 src/linkerhand_calibration/test/test_motion_confirmed_tracking.py delete mode 100644 src/linkerhand_calibration/test/test_motion_image_diagnostics.py delete mode 100644 src/linkerhand_calibration/test/test_motion_reference_provenance.py delete mode 100644 src/linkerhand_calibration/test/test_motion_transfer.py delete mode 100644 src/linkerhand_calibration/test/test_native_position_feedback.py delete mode 100644 src/linkerhand_calibration/test/test_native_rotation_curve.py delete mode 100644 src/linkerhand_calibration/test/test_o12_axis_residual_policy.py delete mode 100644 src/linkerhand_calibration/test/test_o12_full_hand_zero.py delete mode 100644 src/linkerhand_calibration/test/test_o12_observation_resolution.py delete mode 100644 src/linkerhand_calibration/test/test_o12_readonly_bridge.py delete mode 100644 src/linkerhand_calibration/test/test_o12_recorded_replay.py delete mode 100644 src/linkerhand_calibration/test/test_o12_right_profile.py delete mode 100644 src/linkerhand_calibration/test/test_o12_thumb_pnp.py delete mode 100644 src/linkerhand_calibration/test/test_o30_artifacts.py delete mode 100644 src/linkerhand_calibration/test/test_o30_launch.py delete mode 100644 src/linkerhand_calibration/test/test_o30_profile.py delete mode 100644 src/linkerhand_calibration/test/test_o30_side_observer.py delete mode 100644 src/linkerhand_calibration/test/test_o6_right_profile.py delete mode 100644 src/linkerhand_calibration/test/test_o6_tag_installation.py delete mode 100644 src/linkerhand_calibration/test/test_observation_capture.py delete mode 100644 src/linkerhand_calibration/test/test_observation_feedback_dependencies.py delete mode 100644 src/linkerhand_calibration/test/test_observation_strategy.py delete mode 100644 src/linkerhand_calibration/test/test_observation_timing.py delete mode 100644 src/linkerhand_calibration/test/test_observer_policy.py delete mode 100644 src/linkerhand_calibration/test/test_observer_pose.py delete mode 100644 src/linkerhand_calibration/test/test_observer_preparation.py delete mode 100644 src/linkerhand_calibration/test/test_observer_reference.py create mode 100644 src/linkerhand_calibration/test/test_offline_motion_initialization.py delete mode 100644 src/linkerhand_calibration/test/test_offline_replay.py delete mode 100644 src/linkerhand_calibration/test/test_optical_verification.py delete mode 100644 src/linkerhand_calibration/test/test_package_rename.py delete mode 100644 src/linkerhand_calibration/test/test_parallel_image_replay.py delete mode 100644 src/linkerhand_calibration/test/test_parent_reference_provenance.py delete mode 100644 src/linkerhand_calibration/test/test_parent_reference_reuse.py delete mode 100644 src/linkerhand_calibration/test/test_partial_o30_calibration.py delete mode 100644 src/linkerhand_calibration/test/test_passed_resume.py delete mode 100644 src/linkerhand_calibration/test/test_paused_snapshot.py delete mode 100644 src/linkerhand_calibration/test/test_pnp.py delete mode 100644 src/linkerhand_calibration/test/test_pnp_branch_confirmation.py delete mode 100644 src/linkerhand_calibration/test/test_pnp_metadata_concurrency.py delete mode 100644 src/linkerhand_calibration/test/test_pnp_parameter_wiring.py delete mode 100644 src/linkerhand_calibration/test/test_pnp_runtime_policy.py delete mode 100644 src/linkerhand_calibration/test/test_pose_capture_evidence.py delete mode 100644 src/linkerhand_calibration/test/test_pose_tracking_resume_policy.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_admission.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_contract.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_density.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_sync.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_transition.py delete mode 100644 src/linkerhand_calibration/test/test_preparation_witness.py delete mode 100644 src/linkerhand_calibration/test/test_prepared_resume.py delete mode 100644 src/linkerhand_calibration/test/test_process_finalizer.py delete mode 100644 src/linkerhand_calibration/test/test_production_image_motion.py delete mode 100644 src/linkerhand_calibration/test/test_profile_finalization.py delete mode 100644 src/linkerhand_calibration/test/test_profile_loader.py delete mode 100644 src/linkerhand_calibration/test/test_progress_status.py create mode 100644 src/linkerhand_calibration/test/test_raw_draft.py delete mode 100644 src/linkerhand_calibration/test/test_raw_failure_regression.py delete mode 100644 src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py delete mode 100644 src/linkerhand_calibration/test/test_raw_joint_capture.py delete mode 100644 src/linkerhand_calibration/test/test_raw_joint_release.py create mode 100644 src/linkerhand_calibration/test/test_raw_joint_update.py create mode 100644 src/linkerhand_calibration/test/test_raw_lightweight.py create mode 100644 src/linkerhand_calibration/test/test_raw_partial_scope.py create mode 100644 src/linkerhand_calibration/test/test_raw_remount.py delete mode 100644 src/linkerhand_calibration/test/test_raw_resume.py delete mode 100644 src/linkerhand_calibration/test/test_recorded_diagnostic_replay.py delete mode 100644 src/linkerhand_calibration/test/test_recorded_side_preparation.py delete mode 100644 src/linkerhand_calibration/test/test_rectified_camera_contract.py delete mode 100644 src/linkerhand_calibration/test/test_reference_lock.py delete mode 100644 src/linkerhand_calibration/test/test_reference_waypoints.py delete mode 100644 src/linkerhand_calibration/test/test_relative_standard_mimic.py delete mode 100644 src/linkerhand_calibration/test/test_resume_storage.py delete mode 100644 src/linkerhand_calibration/test/test_resume_v2.py delete mode 100644 src/linkerhand_calibration/test/test_resumed_image_reference.py delete mode 100644 src/linkerhand_calibration/test/test_rigid_pair_observer.py delete mode 100644 src/linkerhand_calibration/test/test_rotation_holdout_diagnostics.py delete mode 100644 src/linkerhand_calibration/test/test_runtime_gc_lifecycle.py delete mode 100644 src/linkerhand_calibration/test/test_runtime_pose_branch_guard.py delete mode 100644 src/linkerhand_calibration/test/test_runtime_readiness.py delete mode 100644 src/linkerhand_calibration/test/test_safety_policy.py delete mode 100644 src/linkerhand_calibration/test/test_sample_schema.py delete mode 100644 src/linkerhand_calibration/test/test_separate_mapping_capture.py delete mode 100644 src/linkerhand_calibration/test/test_session_execution.py delete mode 100644 src/linkerhand_calibration/test/test_shared_pose_conditioning.py delete mode 100644 src/linkerhand_calibration/test/test_shared_pose_handoff.py delete mode 100644 src/linkerhand_calibration/test/test_shared_tag_pose_bridge.py delete mode 100644 src/linkerhand_calibration/test/test_shared_zero_conditioning.py delete mode 100644 src/linkerhand_calibration/test/test_single_pass_capture.py delete mode 100644 src/linkerhand_calibration/test/test_source_mimic_export.py delete mode 100644 src/linkerhand_calibration/test/test_spatial_solve_order.py delete mode 100644 src/linkerhand_calibration/test/test_staged_artifacts.py delete mode 100644 src/linkerhand_calibration/test/test_staged_runtime.py delete mode 100644 src/linkerhand_calibration/test/test_staged_training.py delete mode 100644 src/linkerhand_calibration/test/test_stall_retry.py delete mode 100644 src/linkerhand_calibration/test/test_standard_correction_plan.py delete mode 100644 src/linkerhand_calibration/test/test_standard_loader.py delete mode 100644 src/linkerhand_calibration/test/test_standard_urdf_acceptance.py delete mode 100644 src/linkerhand_calibration/test/test_stationary_image_reference.py delete mode 100644 src/linkerhand_calibration/test/test_steady_feedback_window.py delete mode 100644 src/linkerhand_calibration/test/test_storage.py delete mode 100644 src/linkerhand_calibration/test/test_tag_border.py delete mode 100644 src/linkerhand_calibration/test/test_task_input_support.py delete mode 100644 src/linkerhand_calibration/test/test_taskwise_recovery.py delete mode 100644 src/linkerhand_calibration/test/test_taskwise_two_round.py delete mode 100644 src/linkerhand_calibration/test/test_three_camera_diagnostics.py delete mode 100644 src/linkerhand_calibration/test/test_trajectory.py delete mode 100644 src/linkerhand_calibration/test/test_trajectory_observation_v3.py delete mode 100644 src/linkerhand_calibration/test/test_trajectory_policy.py delete mode 100644 src/linkerhand_calibration/test/test_transfer_artifact_contract.py delete mode 100644 src/linkerhand_calibration/test/test_transferred_hinge_geometry.py create mode 100644 src/linkerhand_calibration/test/test_turnaround_support.py delete mode 100644 src/linkerhand_calibration/test/test_unified_artifact_pair.py delete mode 100644 src/linkerhand_calibration/test/test_unified_engine.py delete mode 100644 src/linkerhand_calibration/test/test_unified_launch.py delete mode 100644 src/linkerhand_calibration/test/test_unified_ros_host.py delete mode 100644 src/linkerhand_calibration/test/test_unified_runner.py delete mode 100644 src/linkerhand_calibration/test/test_urdf_command_images.py delete mode 100644 src/linkerhand_calibration/test/test_urdf_comparison.py delete mode 100644 src/linkerhand_calibration/test/test_urdf_patch_engine.py delete mode 100644 src/linkerhand_calibration/test/test_urdf_validation.py delete mode 100644 src/linkerhand_calibration/test/test_urdf_zero.py delete mode 100644 src/linkerhand_calibration/test/test_virtual_control_contract.py delete mode 100644 src/linkerhand_calibration/test/test_visual_motion.py delete mode 100644 src/linkerhand_calibration/test/test_witness_endpoint.py delete mode 100644 src/linkerhand_calibration/test/test_zero_approach_path.py delete mode 100644 src/linkerhand_calibration/test/test_zero_calibration.py delete mode 100644 src/linkerhand_calibration/test/test_zero_evidence_coverage.py delete mode 100644 src/linkerhand_calibration/test/test_zero_recovery.py create mode 100644 src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-V2_0819.urdf create mode 100644 src/linkerhand_calibration/urdf/o30_left/linkerhand_O30i_left-update.urdf create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/hand_base_link.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/index_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/index_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/index_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/index_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/middle_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/middle_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/middle_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/middle_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/pinky_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/pinky_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/pinky_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/pinky_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/ring_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/ring_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/ring_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/ring_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/thumb_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base1.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/thumb_metacarpals_base2.STL create mode 100644 src/linkerhand_calibration/urdf/o30_left/meshes/thumb_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o30_right/o30_right_draft.urdf 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 0000000000000000000000000000000000000000..4e65ba36a3a950ae1c4a694229c8d9d91cd4226d GIT binary patch literal 36837 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..394626aa26026c81016fbeb6e954ba483f248ba5 GIT binary patch literal 29073 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 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 0000000000000000000000000000000000000000..5b2192ce40493b6ba3692558b6731f54f258dcee GIT binary patch literal 1110284 zcmb@v37k&l|Nno8$WA3|30bn68Dr*NPNlMj3T2NHlC8)-GlhsOiALG8iID&GI_JFSeqA%?{`UDj9{(PX$@RG3@8|Wt_T_zD=N!ZM|M$Oe z7mhE@>@xP6?~Z>mLnRL^_aQv%p>OSQ&y_d&C9D&FCt5zE{9R!X?Vc#2g@=uCe%FaL zA9V}A^Fpd0MB^WagwNLMY=@gKtQQ_oqNN?yiA%F5g(jwcZQIz^v_xqB;|J{U+03$` zU84rtVV&qYzK7rJGS!wWe`A z|9PW^9Y%e$y6%K^8@=A#6PkM4d$!j5KU@gKAIP-ByY4C$mfv-PB}}aO>DKV*ers(y zHo;owS~Lvrd(g>~PPFV@+CR1DQ+8bYTNLyUJNSehMn1fA4y{QAMukyUpyy8-Ylpd> zbQ>&TV%c96{9{YKWYe(;*4i|-ia&0l(|UA*B_Ph$on^<1+ML((Jr5f!VFD?vc=7dr zAy}(+`AxZn;?UB$=o~dF9 z6X+|}X|J+k2@^$sxFPIaA55?odRKulUUiOsE;&S=a&zA4>yVU)URE3z+d!}F-p#A+ zvV@7TSIdN@+;kgEu-3l0mBLc$I&tQ<+ryGxXh}XW8%X|if+bAAo{SQ)3D&~M=WT-} zOrR8GRMKrQ!CG8TI>8bq&|u6H?)%7*M4}Qfxk+_s&*J-rT2W! z5+;7$7Voe5eoc>F%Ql$4{#k`wdwQx^!bI#<1{17>Ing^+m^tU3ZfK_$gseqC6snWt zkq?$Ifw1=qATJ?WNNtJL_juS~3CD{v9MP|Z2Z44aX|Mltxv=E+qr1z5C4YM9vIK`BYlbIuYb z5ccjLc?r=%3Zh-DelWZ-vt=nJ;6IeNSDm%`@1{17>nZbK+z!D~4 z@A#jCJ!~++S|}0Om+1Mx=q%PzKC)xM%2IyU2`)v@vfIK+*1JAf!USvxt=nLNwXpV; zy3q+vJ7~#m?0m3<3E1#1T_#uyt8?!<$4W+Wh}Davy^UJEdJ|abiFK?QMGNDjcYSbN zupu;TdlM{S0&6WPH@$S3U@eUE-Zogm1Xh63#&jD@uvYAxvxEt(WWDpj1Z#1r>#5>8 zfR@x?Y##tin1DU+{=o!mp+uzh=&51}6DS4m5snGg;ufmgU`hT#&#Nn=^efyr? z;nCVTRjkGN({s*y1KxRI1ozGdOPGMK|DI>PPGy*2EiMr~RV-lwp8K5L&bd)+g0;BZ zbb?neyheF=Vq)&emQJvQi5Kg>=)d&Z9Uk?;1Z%}!m#~Bhoc!(jt%HXRCRmHtD|$Xy z!UWDHpPf9`!v+(q#pi~)4V*z<`*M30_WE&;&+?CH-oy?grX#}|+h?~r!4f9;G*c(A z-VrY#Xz}l%M7(`6EMbDrRdpLouoi08+snxkCQzG2GZVu3y&p`l7Ho@GHGir^!US67 zuS^cC$L(Qlwb}w+7GhRj{6#i zzL@5b5A5P4El9%Bg;tcBmb z$19dF5!+AA1Z&}UIj7Kb&XT-@oCxRyuSG%BO+RA$P7ss#N}nZ6AW!!d8DZ194JKF% zzyEuEFoC)$`Ojhd=1_hc5*KT6ThIyi!?C{%EmZcnI>8bq&~MJWx07NMti?CEbb{CF zxUp5{0cX`8d)>|wCNNfB9ptqNXM(lZ+oq?AB}`y+eImixC&VULi|zm!VFGiz(6I^DLRd;f zCs@J++O)R~CRht$ZyPLO0=>Z71{18+@%qjfHdw+$O0}OuQvZ6+S;7Q7Y~HD2g0;Gw zz8>;!B}kRrNd?{MbTzvV!k%{@WC;_n=S{E{QX>64Ho+1m_E#$u-t3MTdRZ~STKL_& z53+;_g#R}}v_M$boSVRUs^II9F$yauxsd=bmC!oD5+*Pc{X4;0h{?N^Kq50eYk~GA z#=bbimvi_oyJeK0_KvU2__}r&ecrpyF-A#S;(O1r36?N{-t29I3D)A&>ZQvPCbs?d zRBoPh0$Q@JeKn_{Z~TdS?3^>5pOExwM9+Y)ai2Jwj!m>9t=OsJI(Vd0I5%D{5#0t$ zm{>dK&OCRhtGNr~hqM8X8@?HJhJBj+3!YvFgV$Y z1ZyGe-4a>C1oGrc~TzcCDYaIe%F(!CEN88AD(8 z$OlW9_~oAq8N050;bDUb)_QSXg`_`*d|{WmULQ;#EcMK()d`j`fv|U-=Osi7zsDXK zM8feRJYnBgcB{;vDkek=Z9#t536^lYt+K2vp<@%QHK2Yt>yPpmJxZ7FQ)9I(rwa&s z-(g`16G*Mlda9UUErh*ou!ITJhqnzTSPNlq8!TZ0vxK(|CRht$ZyPLOBK946CRhtA z0J)8=mo7gU22Y`k?d)gN36?N{dJ;M|!CDB*e5DgCVIuZ!n+ew9oxW~^@1!ChGIApP z@AqDrfNi058ypvFA?$5~B}|}&dfQ-vwGj5U!4f9WLcMJ;!CDA=+h7S3XrbOVm|!i0 zy=}0B30^bm^}z&dA?$5~B}~M=Pt62t@p~KeROP*gEGa?j@g7T9lDCZst=nLNTMWY9 zHdw+0w*}n>6Rd@>w+)ss!EHge!31j|>}`W3OmJJ!Z7{)F2z%RL2@~8FbQ?^t7Q)^( zSi%I_nD;GjCRmG~h|p8TH&1X%BCBwOy~lQzFoE_av~Gh5)Qx!rrq1OPFBW`ExFDu@=JeyH2o#3Dj)tvXZ!13t{Ruce;Mn)lJ4$p~jp`A=5o&ob>WZlo1IP{V$*IF~S> zB@!><)&t9vH5>T|(9i;%ep{E#u$P}eO3v(f#s@8=@bg{m?6BC*PlzNh(ewGM|3f}R zOK6lxej6ok-=8EYLE0sSVmm(}63HzS(?1&dFQp3_TA-yw^4pM7gcedLwJEmq6C%k= zOc_}|IbWUIt%M2D5*j4}8~88H!vD6MnUS^Ui8W!Qq|B)rb{O@1_*!ZPeis{2f+b#r zv@35yXj|*k8p*IB?J7!0yr=_7A<`~%zEl}3VWP~@<#wL(5ot`YmeA6!a#Lk&J~YEl zuU(36+L1zz3EKG2DP2wCK}fsGCA5494J|Ga+kn_G3@SxQA<{1N|DAItitbr%=kNa} zSW9TFUD;z6B=EAb$-c9oA18d@kTv6ov`Hi5E&1hpn9 zly;SC+pY{5dnI0^mkE@DMxZ|OrHZwL&TUs|LZHPVE~G@#F74{yZFmt{yULd;$&;-` zC8C#=#EV*!6iU1L_f#=~-u3SUYY8pw%I-~e@*13J7M58CVHs-?mXQyNIw2A-Ldq&O z0UBDMWiH8YLuO=X$xQvfr-})g;c{)HA;fVZELvhuE2~^WW*KP7?87l3=GZyUOGqD# zO`xox1shT#`E5ukLQ7`q|2RG4BeFfL}jyD_^#_xu~1WUXK(1vkKtyX5)MR!=R!GwgNRj}zNzHKiJbZs!f z5--BB@nEeZ!Tl?rwrwyWVQ7^*cg|NLqql2=36^*fjt!Kr+fve)kTA6Fc&(&AYJ&-u zcoB|`gjz=;-#Pg(n2<2E*8Fm(KU!8yu*8dSY^c(;-1;z>kTA4fxv{Z7TIWo##EWoj zs8(XR?aE+6!q93^D#3oAWp2C5C0OD`Xf}cm}nY)fe&a8aew!wsip@rJ)-D{a^g9(;+5snSF zCE7NakTA5+Dx)@-V2Kyu*tmArk>DveA52IXTIgNTvSNZIUW8*qm98oE!GwgNg;7Fk z)-af0i5KD6a7Q@1l`tV;Xvt`o+jcRE+5}6y2*<_`YTQk8$K5C1ZDzk~5^+fwTG`8+ zgrj|s36^*fj*YUatZsG3T_z+9t;5r6grj2#6D;u}92)^O?jCT*T_z+9tw$S{4oAmC zCRpM{I5ymtXpikoNEljUYyTFC+F*huUPNv_B5pnmCL|240nhFTMaxR%BbQ)_7vb38 zQIrV@L+c&?d!cBZGr&P-P)Ouu;e4! z2VEOBfp|sYML0Hi6lFrf&_XFh#}X!3;zc+%E~s($XLr0}Lc-8OZAQmLCRpM{I5v0` zWkSNxLaU70V1gxHL~cHUk`E>%3@!AoXj!Rzyn1Cq!qCF1CR$cZu*8dSZ15<`goL4m)tS_jy<^E;A+yAb zaBQenV!7?gUhhg=LPHCyOlfa+yUHb4;zc+%zE?IZx3x1NVadn7BK_PtX9Dqx#EWoj z+@;pLR~AOsyFv&JEtJB#?vJ`Qm|%$);n?8SD-#li7HaeAtF2udOt8d@aBT1>%7lcW zg;p80!30aZ2*(DmUYU?Ew9vbvWyJ(bya>mJDqU0Rg9!;k3!{Y8lVLEy5--BBp<0P4 z?TQHrLrX?GXWV6iC0>MMcJgrSAiK-2~kEb$_8^O3uHWkSNx!m1`( zR(aREBJm;|8$9lEToQ&BR%g*VXM!bOgkwXs63cB@_Ig+15*k`qWlDRq+f^>X5--BB zfw6tAJMJEyxMdC#`Hh2_eLc-8ODMZH-CRpM{I5v3o%7lcWh1!gciA=D> zi*Ri4D9VI{p@mi%wZQ~SyolU<4cfBhTFT$}=U5&ff-FcAXk}$L| zN=VHb1`{mtA{-l7SxUQNLc-9J(asrnnP7<*;n)aXj5m)|_{6{OQoJ?lm2~?w9C67z zwxG51=5+s*iz&&Djq2$M=JwCu_xI@;Z?S}lcW<2K|8z=?WXHyd-f`y2t}g~w{gR*& ztaW|ZJpbG0I{F+N>)SRlcXfX?aM%7sizQ4PdhAvI)?;Zt$403q;>>yDJ`RKmBxnR{ zZ5Te+U)P%K+PHDj-R9G6pAY_gJ>DwPeWE|S>3(}P(dz9f{%u+H?eLn+RR4wPwe3~E zVGK3vn{7As3ZAtfiOks9n{}p<5>hLvQ=E(&cg+oIJ*# zQC-%erH9uuv#L%Ewi?mYVhIyF=MDD9U+v=7$F`hm=EFS~2lsr~Oe0w9*YVHz!_!`L z>!WJNJI#~z76-Ya=Gu7ojyudg3*HN+7D~_v)_UdX{{E+)oaEa0 zxk(kXVAV~*udl{iEMa28rf&W!3A0?H-N*{&o`zY$vQH#hEMek$*Zch=`o89->VxjL znVkyk3if$1P9s=r+;^@0J2o$HZEUDm++1?=q2PZ{Hn&*9#I-T;lY{Wy~$V z9SnA#7H_eHi3vmM``g7Wb5qsgTp{z9Hh%^`sh^+`td&%{mfyd1oonNncM6!Rs$UEi zGZQVAFwy(DoBa!WtaWX?wBSncs=uJw|6;sGuvY4l+x_bv&v0!#J@9-mb$Ai;;osve zmN2oX)6M=#-7{Sx{&-HX&5oPQyJ|MKSi;03IluTue3t2^>RRRRgRg&I+FbNzyhgCr z?NhG%YUXTlZ9MYfmSEYxDws9zO|V$P#8=N9@SSh;spO+VX0YFy3TDG`i55$kSle@x zufBS7s52%eO?o%Danv1V_sj7b!CKQ=ZucEd+3DJtws=9X;lvu|FBjr1mM}4A^jhDA zM|Ziz^haI^E-PEp+*GZ(#S$iZd^^f_|Fb*XRGqr-#bBTLb+_iX(V69uu6!hiv*yq|fIXf$`e|ntx*OhpSB~1ME?aJgHHTS#3h4$S8 zLpn7v>#MS22@~=LDkoK=YL^RiI~8wEITf!FtcCElkl6U_&nM!Omh?04>2WOBs8zZ@ zP~d#l^vQ*ME8D00zgIWU65P9Sa+YowohE&;zWVTE!JFUeXUfei+-JCXSi1k3y0sfF+G%?^=JEa>V)^F^r^llCQb@I{op8#U@gQX=iKT{*q@Z}N1+50e~^gvys3(w zhL0(f;2&4It$Fn)X1X7@2o@Z{-N8~F(>5Jax$Zq_QjjG~AT0L|48ynf-ozKnjx&$m z*ea5j5Um7NPdzXFnpH2Zg|gB3$W&i@W#g7flOinPc*UM`pJD5hed1T7_cL!8cPt-) zl7^O)+ZFqETiFMSB%G?;)_ibZWs4;oFUsxvvAeRaZ0)I1b$r=}KA);R^=Gk!34~FD zxR+xX=AoYP^A1%u+m&ydj}R@Xk3%+bwa$+9RVE(`)|%JPVhP7vRkg%xUDLC!OdP6g zRO#ik#QqPCvRJ|d!g4#s*-Lc%sb%oSo5$rNL`&Aea_hw~{`*tQNI^xggyV(nlNZ`& zI;kqv(@s@HQY8{55SAMrDj#zOXQ-S#~b-w-dmJO-~t+uKLIWE@HQ+3vS z#n(^OMQ=4aNUG+k5eFmDjOtgCFcRT+xoct=H8iMeVbMAJJ&|zPa2w=r`{Cl zdo)o?6>DKHy6vx`VW+Hq`=E*W;MmHMd+v(2SPMJI^Iw$slD^2XYP*-R(ofTi?y&fEVLourfQ(tpU>OWD}wzwYhi!B?}z*B zcy+?{0Yu;fUf7RU(S-hvYy%UzWu*Y51>Mc87op?v>Yo95wEAoWe*Rq5O>|Q(WTIHr{`Xi;x zo9;Of>GPWM6p?k-!fx{G2j6p3Rd!e@vt;L-$nW8JdnYV$VUOGX@AY=PI#EmQYfrcN zGjdq%YgxhscCXv^h1^u7s(tOe>K7vg)V`Jp*1~SG*04-BRViv;dq<$4)xq7@N?h2n zCO&J~@#@3@wXYpKyoi;g_O&cw0=w5zL$|r9x?k;UTkW{Xs;>66Ot2Pqlh?oB=BDbR z+ShLUzO*&T-PcN7*s;F%$LDsuI_?rSaVYgxhscCQoqA8=E( zRPAe*maS=JsJ$8!tcBg=7wY@&PHVrR#f!ln^Xppu-F>aZg&phKltXsBIx$%7YwN9V zXklN=5+<;Fy=nLnH&t6wYX_hEy}nhTcyo> zdvff!5XM{Hp@qHZsYK^@!`RcYd!TKnCRRIlmnsq_bQ^Q#W(BrQkFzeTxt8N%E$oo{ z4?F7SWA9G|gT>1>wH{TwZI&>>c`}SY*8LIu{!EMbZ8iItd)2cGeXX4EJhe7hTI80j z4j<=)_or;sp3O0g;yu%Y>8A^XUYR+<#65j^iiQa(k$Ly6RI7}jTD4xTyC7t$-4_$A zb=ZC8$GPRbY=2cV5~`kYZANP?RZLfF^=ww(RXO2=JHJsj%B%I^iuZSiuHBGevV;lT z?3d?_45RdtcIM$zLo+6QQ&A&WYpNoq&Qdn)b>d6mRI}x;kHxM1HYvbQ?I3()WKOuA zdQRi*U-q3GwXqPGhCRmH@8AkVKMwv}t935Kp!o(m;m>5uI zUeviCP&S%Pd(!-1**9@37N6D#*3$FwW0@55zRP7Z#@v4;o72uI9Cm(jR^jnE z;WIlEm5r4Tj53>j_(kY(wKrf16OvkIZ?L50VACIXJ$`TFEgHdEk}7BKtUSTy`XTc} z{ngAVwT2z<(j5J{TBZ@TIJo(^IVLZNJ%ESoQOF$B$Hooj(70iIKU_ zU^)BR8}9ELTv%gFXwkc4ENRt{FtKIqXPIw*o)bQ_b+}5Ep<2f+m9~d=KQKxoSc^-< zFj_R(8=UQ*7vJ@Vfm&HHG3+O&H-~H=Kcn>V|P$wT{U>H*Y+p{>4*G=+9_zMOAoC50$Fj>M29@ToHbE{3XY- zI$4ofZ-z&7E~Vvzi2(&(O8(^8oN(y9&Z<6|Hf(6swhD)`7x*le?0qpV`T4hU!oHpZ z^4NH@>5bOJ9idd2$P%O4U=< zRnC7<`~Tz;{gVqS9~l#@CAOV@{?%<0tcDNN4j-CaF~Sli2E4u?InXL6+;&_**%-ZY zpyfZ=GGTJyOO0SHt{dfnRCRsZx{aYGYJ`xwg(tP+4davl7_R21>qnBsHcG@W_LqIc z8hcyZ#?c+hnJi&KY;@iVqQ~0))(3AKi0}5rcUtMP7MGi0^xg4tV7<{L{_OBnQ~EaM zyTjK8+ASP2ne=~WR(sR@GW*`!S7j7klVY-ji7mHHPMZ5sPI%*et5vE>t3LSr>)S$m z)%eSVrscMFl!#$iDOr&bFBVSzeBcnRte7~zrcUz2CCWx}ND*VRYFXEAz9q|Oai2!8 z7MGi0^!g>OT%YA zYGSd3iT$_D3SX;rCd*x4r8cr|8k?TIXkd~?u-3sHbHhtkz2n*#ay-u3{PN4$(|ROW zEMX#P=Bweydari92G>_NwZ@FQoZWP7yhgCreXCvzKbCUdwUN-ckyY(<;mAEJ5-pZ6 zF>}k5@KyExb|)Y2f8N-dv!`-oqw*Rs!CHf}W`(zXaFe@++gI;)>%*$+B0nh4#D|^x zgvVR+Y)MARf#LmoXWHSK3)MGxexK&rIMMP>YwMU5kq*iROPF|}$#db!2PeBWwtrpC zs@V9=$Y+NVHG;M3e?26e_WU5%#_p}PtS{bwJ+f$ZoW&9*Zrh#~E>u*WCEHQBj8(EuQy)W`* zsd$ZGEiP%pDAA#=)uKwP$OnCsES4})ZP1KxtB0$&Hkxm0Wc7ToVPu*rT_#vdufYN5 zZnd795Q_YGdy-azTsPC=9uCK?dfT;e*QzSk%3)cNQ`vDAOPCnA^?~rD(c%NRe|A~x z%L)4;UA{`t2-eDa;GVEyu61oZQ?QIRv*7+n%Bf}+OPIJGY#8p-Q+D(P9x7%vY5Yqh zG%-#iSZm|k^}|P3X1X?>oLkTuKjrtx_0JM5mN2n(|E=M|FUkJgH_otD)w~`lr}j8Z zuvW>t?hH2`>g?6kdiTtqkt#PuArjQ+??)xc%PBc&d?$tv45(P9Y` zjWRBT&iyX?^Ew~=80mJUxYct(f<~~`<7W$pYaIQ|wei9CpGJCrdYff*jJH_AM5FmX zgkJmlOP5F<6N;ROPJ``V^iqWwz5AjyK#D?>EasJ!dseKEMel2)ssR?CdmH0 z%$A9fKkC-C4yVRz1Z!0Zz8b3Q?$r&WWlMi#^XP_Fy-o=hOPF|e|2?7apOgLhihg%T zp7^Z3b<2&-EtW8GKCwh-bngDV-|<3`_LrMjGgVnJ!CDs&PRBjYUG zFJTE2vv2r1E3=X8==0zLNFlx-G$D?c*($Fo8Mv zojK{QjT^rxV}07_n_zWy&yoq&!VLFqYq_!Y^$TUJkB@y7Y*<%$&q=}r=HSuwmb*4m zY8SB<-*_eX)5}dXg0(PTjUOU6wpL~rvPvvE9(-bLvc(c6Fr#*wv(~lon3`+5)w~|8 z=+3nw!7TbtcjsOg$7C4$PX8G(j}|fys2XGm6PQs)^xNdx7@_9cTOYc~yh+WqOt2Pa zxTlWF9j}IJt{rlvxY^d7Yb7qsqVqqu?09wJXJ@W8&v#JsAjPE-YOXE1WxJay<&MEz zYZk*?O9W<7(ZUS3qPg7h`dH1izxKb~l(|;4FpFLo=iKYki9u?vJ@R>V^KDhSEMWpO zYN6(G$E$^!Yj0m%!(6H6S|(TvGhBsdoHj@(#|fF~%p}L6I;aWeDG*VZZ}9 zy5LL6@IZ20tOXz0cy(6|o=L~c`S|g6aqx1&n`w;S?}v{SUe5bp-0z1k6#i1!cDy+7 zq`q)x37>dUMZyHUgZD2}F)2T+VZdAa&XD;&cxyQ>*5VQ|40s?r^j{t?9!S^_|E|Of zZ{)Y{?)N)BGI%*>_~&Jams1GV;&M|rER?^!#icjn3oHK>eA6KC_9TQm^;J1nVNR`K zR4$fc7C+n7U--o<*(_lK9`Er3_WK=QI6T|&f!8zO*=B;ZxYP|}q)OG^iZA$fsMaC` z+7CSR)kf_1!;jCYRX#Fx3UH>vAYTo23cwO3a3aw7Nk!Wy4LF-<@#|w7}h(oL{^eK`MEzU-R~a{hswqjXt|%Psc=%ZC#z z&N-)0dRO!W>yNq%d^o{k2@}#c9bX^3ga0wQWQcbVW1{07bVdWmJ2)_7Z{~h~)H~?A zeO0{U9gIpiUU>0JsCtTe2M4~s%?IxwOPG+ij%nV(EO-Z*U@iFlE=^Z9Z0{ia?K6v= zOHlrH+mkA#3%{t0iA<{tTW21&st%pzKQpqN?N0;&>rB9(_-rrL-jwIo@j(7_^DW8Z zffRzZ^i-XAb)0qQEW^L=Oh6l{d31&cdUI_xme`){W6HBV?cT@X*$%RV34UXm@}yRo zV6FV8XjW#giV-GQ3qI?N`2BvzvmH^hz?@OreNQW|ElZdXd(OBEUwHGAEjPj!&ID^o zx!ETh#=+yAtV!RDkAJFODT^gc;LO6`Rps11%^0g{@WpBie0|lwEMY=w&^a-|`A1c& zuuslEa^s@+wc%vr?92Q8_F0I5bDY#Rziq%d4$nn6$3Y9n+$XKlnFn!J6dd~BcsVOV zojY1l0_Pu9-rMiTNt1IP)u8QeAI_sVE=`M4Xc#zS8MUs+Mmb}F4djpsoVV1UDKiev zamFQAjgxa6Ay`Y#dAUmyGWHn1M1Fjyuld%{^e}FcqP4Fcksdxdvs`Av2OI47k;%t;77rF12V4_@mMXOu0o$ZnOyLnqP{!pz@{n`1D zxLAwxXBeB-CWXhWZDTF!UPa3}6JvX&hbt}bob+cK+*Yr+Yh}hay(?QqMz+-m*5Z0H zjBbg(__$}fTP2U(U=``8((BuqH12^8KCy8~t(>F`onHIK6AR+MeQt;~rejuwB}}B> zn;!mjRH@{8UnQv0t(Kk@I;-04?Wz`;U@iPEb%WcY>Kt-tiZ#FXv22zw@lG&35An@w zsn%Wn1_zj6Ev{J=f5M!sW{pg1s(H7Tb57x#{nEqn;}11&`{k1=AEO2=3_YuA5PvLT z0%5diXDs=?TIu+i-O5<^4|+5oAzD%&&JFCv_q`l{W!3)3lPd>UEa7+$j!h5$G5wXy zP)(elFP*VA)L4~lN!8+6!UV$TAI|85(yjhuy-3wwVMr-s#wAV!g6QdFhX^{Y$Esc^Ae(k61k?H1#rshyLtC+L~lZ`LR|7x z0$Sqt>Ins``gI#es|LagO13q_M=RTe#LGlo)uW4g2Bqo%IVZ7J9#RUt-Y%|M@d|) zg_xv73?s1nKofaBiX}`)yBev=P(Ar_w0}lV^(4}{Tb!|7;$kf>H#_6*|I+OHyYh@H z?j+0e$0xFz`}ZXVTwjJ#Pjiqot4OGkztblLZOM`wll*7Pl(54f%KfLj>!)@N0JQK< zj<|(W!|-_Eog8>86T;XtL{9;jUaun@Xc&DdI!`9*5bO+3A`~$dK6M9Z3%r)+ObZsgbAcp+Hq`x zwYab71WTAe>+$YUOt2RBG~LFb&gK12ovmQEYS16d+-cui!S`|pOup5lwX=i?l!(x_ zjWownn#BZbVdN9rI>8bqFg{*QtnQI2&PR)4>g`gl7N^ijkEDtvOgymsCjZ>i4Lxiy z!CLs;yOpqn3D~Q%NN%J=)0Jki1bN!>)BU-1^Y1p8K&fx(&?-hgBrewCHXTh9#$A>$ zfqIe@#wJ(`HRzplerk)KiQ@XVZKOGtoK#7RV}hSI%AYDBSSw$<%C*4~CNQ^0pIwSt zNwb(>twn?VG1?X1LgKqWUoB4#OFNEQKz*=;i3;75!;-()1ZyQf-XyH`K^IH2R;;WU zmi7ZeS_}y9I%f$JNUc`qX^xRJtJEL2<;L~G>QcE{bsjq$5_@_+ZvON4(3ZhaJ-y9-NrM2mJHwVPo_N%A}l4!j^Y3D$~T=PY3&;bMVsk27BL2NSHt?JZh==!4vTxLu)cw6>dDRxDux@2T>h z&zWE?ymiWZmBH&;UiI@jSkDJbn82IHWTh6HU@g4=Ojc1k!4eR%zGB-t!4f9$ZZhwj zGr?M1(z*?nFoAcIdDjOMti^Ss+u)kQd%Ps)T(dgC5+?9QFz?c3g0=Y0rEY^IOu(L2 z=V=a?X5UDWd!ekwcP@1sEMWpABKs?w$hE-)YvG1R^e$)AN}7H5LnKU~KD2#Qt__Zh zwQye}dNY%4u!ITJzqbu0SPQo=qW3J>21}SgtJKO0P#Q)bCRhu1DWbO~*#=9P;2xZ- zZ`)vkwQ#o~dI!>FY=R|B@VJs|Lj}?-tdQsX|Le!D8ao!e^%7z1SR@>qU@i22iAg6| z3wvC=+Y&Zn6D(l@9t6CDGQSUi3D&~zn%~Dwn&Zn52@~?hRc{+Yuoh^%sW3m0_f5c( zcI>00?<0$rR+`0eA?J9PqLh``j_RWZIfqQ3)bSq3{HbDswa^y4ZLow1v>0z2Ot2Pq z6W%tsOt7PuvVsR8`j)h4-qY-7!$rab_Uh8cqWUN+j*GS6nTWn6O)+T>mu7L=K}dbX z&Ie1FKnlHE2@|Zvtw+xXc695uHni&&`@#3>G_}JVlTNUN39cudU7%&D@&MwJ@Mj3EyxHbaj_OkM1I!^mN0=*SQGxo z!z0WDYoVUBRHZqV9IvyKBG(OeuijNoZO5K#g9+BcPFPCaNspw8Z<6!CP4#kY#F>XWqy-?P?J#S$j4F7=)bm|!iw{jJ+zEv%_!j*3mN zgb8`SaQ@ku3D)BJ*Hgt3CScEd#$kfBkUyE>bQ>%|>B_9nG3f+LnBewi6KM|T^eAbg ztd*~|=MpSoB6dsUlwhSV`C!^fkEDtvOkfS~T~q)!r0J_Z zCiorbuCilFTPLy>Yzr$KW^Ax#Bb9O}{VWRw9mwlT5Dot!`eXVHj=?mf&pCx|R zJdhe85+-7&isNFf*Pbuze|4GHSrJQ^$d`}YoO4+*fwmxJ=qlU)I4w~mc?l`Q*aYGg zt&Z1sh9oAPUxSc`quI>8bqFvDrya~Dgqm|!jTW$HHI%}lBAwC&l3m-Bj= zG&_v3(t93c2@~+DK34QKo7QbG!CLrTT4jDhBuv0N-14&(F>DCITJTwm=U%tLt0I)H z#0JIlJugltX}b!9WUCk#H14}VFEo$YEvgz zg0bYtq8|SbHkd%)YY94vd|!yn*C@8+Vz&EBmvE|B!UW1nTQlUQiV4<2{YT%!qHN?6EMWqzL{4j>X+r7p`$gf!k(BUF1)X3C z6Y%bMmlYGNmG1^bZa(03lyfJ(i^cc7^i;8g2~J^bg0=YlsXDOX?=1!` zZQ3T%94^gbUl{wyP;TN!(Fv9?f!it3FVQ&hML zMD8Fu!CG7*wr(!L-evZ^BULh1ID1ZdIXm9tK;BrhR!Yyz}s@hOK+u!M=&CnoG?;N1&oSsCaySdy3U zE?p)N7A=en-Zog0mk?S{6%z=H7T-eD36?Oy@n;;YWCB^)g|E zGdn+=`@2rCBrhSfVPrf}#_amw$zbDlO)Q+a;)Vd;8-uf3%%TXFt9HP5w(hJjo=Z23 zZZ{}tR6L%h~=r7l9rrSz>RjFB5yGHPD^)C~wCF?mSRlg|B*^0QT{$&Xh zo5ohL@9#UU{hZQVTf0UiFTq-{jk{Qmjo%b8R}q)hzpMotozA{wr(Gx9oJS<*5*HIF zg|Vd^+AuzNqKq}@!IP0js@HN{tc7~Q?KUS>;Q__0-pzlBELXjjwNN(;GsoMh(ut+2 zm9*b}t9A8b^+g1ViwU%t`IE-F^>Os#(n#AoYFmE~X{-^fg zUG-YlLOb5JW|SSTPRv>PcJ@5go94PbO5$Pyy=zhdhjzxqMcb0BmsLyTxL6DQUt%&0 zWg~$2VTUD5V3d#)8phl*OM+c$-DO=;b&GK#^-Qo9#(8OP>W%MTlrSIc`g^3UDl68)I4`YFCsf-t zRj;+%E=!mw*LJ^8TE1bZUTdme8$qwt2-1?9r@1}KEL*2W1U-u-9PhD;d+q1goL+lD z+1RN1x%yWlSPQmghEwlhQAE)?&d9(LCSWf*`nWl_$15gSi|0r6?I<R z1}S4WPAw%R`_#LsWPCJ?bT!vbR&(tJ)oWQ2mvl>5MrV#mz4u(Tl4Ld47FTmEOPCmY zwMBbs!#@LN`3LW|SgYs{H-u$I)(JJAXUlxf5+;V&R3~PuQS?4lAH`ICu!M=MvZq2)n}+d) znrq9exfb&W6Rd^PMQJhWDQq>@PE&L3{i@fp*63~rL(-OXg6CS6Ffn@ax1nfjN8g*H zdK6Nn5u_zIvz=axy56S<{L2!Kw@s(7?OWbXul-YL&Ql`;{$+x-U^_ZqomHA&DB_Cx zm$hI+MjV~sxt1kNpcG`Z)8|@^AT88WbZlRz=GviZu3fEqEysnrkSD!+5g!yV(o>3|M~EYgr3@P-bMEkhvD~ElZd{|Bt3>mYQoHS99%O zDpgFd7RIq?eVkKs?JsJsEvS64i+f&W^>puIhs&OkhNn+BA&MJCru#)m)2y z!USt!oR7BM?P{*=spi@?s@Jj>#(8OdI&r6(Ya1r7iF~Y9$Sh%^b86+Vw0y%*sWDZr zwb!Cdu$J7}cY3YbcbFN9z)GJbOq{vxcKhi8r`M|fZL0Nd5cRmD>QK@5swNRTQ`n7Ov6jiNcsUn)FRIwy4AV|=mmC|Ji6X=i8`cO4! z9(8Mw3D%l5qZGBP?TU~(X9*Mhe6?YKP&OiHB}}juk1>X^SrKy;QB4snVS>jM!^l=S ze^<4w7K*6cw^>-mE1dR8FS_N&hT-UV6;Xuxv*H!O5+-oxHrmf?s~Vi8>cY%JuomvN zc#l^s$xFycWEd~ec*O+5q7^rw66K?bDyKCnRTxEC!UR7VY8X3It~aT_rg;d~s{eDj z+%hzbjY=DT_bP%VOu)Y{C2bi0Q7QUJrL2A)g0()nyNrE4?2K0!;T9;Oq9RzrgnpYU zTM^%=wtHJ1g0+y^X#W7QUlA4DREdNM*o*d$V=C8Qs+LkB55Za}^=Lmot_byKUQ@PM z!UW1K+RsO+T>q+Cyc)AKg0)ba-s2TZ@)9x<8Ad&oYc;-`7~7dZShUa!qWO4Gwf2LG z_(P?NB}||lNAvNE%JpIOcO?(OTIicn>V|PhY2&Y$N)=0(;PZOJh^Q28ck7%9)|y-` ziQ1K_b90AV=PY4@?;WVIL=m5;IzcO8g0*;zF^u(!NKr&xHy<+kFu~)BS^+3uMoZ<( zXzHFD;Px<9knn`y#xKI~ejNXEu-{oHZdAUEN0cw)efQizB=9r6I_GIy3tlRTDgU{F zNSJ{4YiiG@TpM}M4TNAV_~&p_+F9=&FEBZ%c131EwJTx?6Y$Svywu0F0bhpN6`5GQ zGQnE#Ge&K^;eR)^d(kgNrtwX_(WI9s8bRn501nB|@uNL)<7lRkI-P`3uL zN89S|SU4`$k{bn1E5S*_NJU&z|FVP$`1(7Q9p~D>xxrGkkCJl(NDvom!8Y!mJ2v3U zcvlgy!CJ5}wfRIlUY!`PPE7vOd`;vFH|G);6DWnJ%1?4rmG|602-ZS9;kLSys=Vh0 z5*O;G%#JB`ygKopq+O9U%9nA`JvWfJm_Un}eQc_ms;`wV!&JVEqRIowaj_QKG;YK@ zsd}~GWYv*Xo?yypfI7Zd1RRaQ7Od>@9v=LSNs7W%*Zp8woH zBut?HOA7O!8wkN#7!#!o4P(UWSAy%5FQch@ZXj`CypmGT2~{6KsSlPgfe}?|Gyl1P zSZ6Ja^U`|qpBqSAm;t24=!9y!rnFs_Fab}xw0y%*J<43-_79C9EqRjJX(jM}G@x?> z>4O|E{QJ>fi*tjIRX>+=10h%owq=ZQQgvPtIQd}-6R;N@ebjhm%6P>DYw0uRCN_tGRZonrjQX=LQlN{E0Hx z>O@a>u3f0+T9z;YU#7&QzDKX-+CFNoh1Z!0)`Cw~O2jaZskt^s&9!&9=LQlNe6LcX zI+6F>K;mKo-eIXt^#w^a*A`K8Em|VS#ai%RON&waD0OboTg|nt-E#wp3qEUUF*?C> zElZeyCq3HQ;r(B(`Um`p8bMm}JeJdI(f2mHeUK#_FMR#cUW?Vm6g4u)xqyXYOdwDSPN}hW+Jr;S95JCHP>EIb1h4lK+Bhz z$nLe;xq-yRTIkL4d;W6+i3@#DW@Mendu||cF@aG+Vp3lv$#ZTX1Z!bTlrmJ_S~b_c zujbl#_uN3@!gwVmsuS62u3e()tc7u2T95KZmL8hB?8T(jEpE>iM8=N<4_~qO{p3{K$ z^HS>F_V4-64MYO}o)GX~MxQ3ndu|{EYr(7fRIq_-Bk#F^Nb(ZV=jijE8wi1TMGL-V zv27T6&kaPv1pLX-eDEm%$HiLkw@Vr3KQ|Bw6Yz^mx#d4M5Q4REI)HZrc%K`HgbBVW zuRcAX{5^bbAOve6A7x%H<=V)5ZXgmSU{7us+n#Oh+&~D{LKzkkc7kubq`p!1&_2*Fy&hqUSZ=LRBS zg2$Eo=LSN+TPM8;-cRYx$^)tVm3(d>5+>lkjP~=q=LSNs7QCu5#^~pqEXhmANR)j70g*4J0nmqJ{n;whbfixq(QSKs%1+Bk#F^ z5Ud4%yOd%6a|4kuf&Lh+kG$swLa-Km{n8ekQvg0U5D63T?`!Q!J2w!5wU7^K)A`R0 zM8X7*E9$OVm6E>iuBV#VCGdBb@Wc?lIEF6>N_cR&iRyWSatBk*vYm?uKE#(YW8ds4Aj0@K$9?$&`QzQ|4ynG*8c;{)ER!%pwtJx;2~iUHBdL* zI%NLYex3Sm#*iJU=E%0i1OMC+3A2O=wx|48ss`75S0?aA+I)>*EvXyl+Z?FD8#`AE zJW!)zm?cbbi5N!m%l!iDo=-Kq)F_tC-=gC$z5Vp<%H$q3<*T9pd9+X8T4E z!CHr}MZcV97$wHE4?HqE)l4{WKFks(q<=U&ueG0N`hNe%=}}CumV}*e18%80KhUam zsyVCbnGmO)zh1}o3}aRA^??;PrJ4i2Kb^%ACgj_0_~xi0zReEoIgw&6&ncu4ti@j} zRbO{JwllDMONzPa(G!XM^*a8_oY;0ohQBi-fkr>3m<^g9Okypuf$z`h#4Y!12s~3L z)qHfym&q()g1`|Db&Ijl}bG83%DBe?Q-ZQBv(vMt427B01!>y~R%^5?YOCc!;{`5&a% zvjI!I2qzyCPuvi^<;aA5gwW8s zSYT8i^LWwhW=ycwSI-^rop1E1dp@{p{O`eIiolHHrG-@KM49J`n*A3%ZWAn#Fo@fy zT=&(?+2W=u7QtE)cFJnU)yn3st8Hu>ERis5Eb4T#e^U2M*G4RYwIuA=_~2Y!v&d-E zw!sn!!$#_o+x_bv&v47?{qO6V$uFB`!LDCzW(gBtj=kN#YhZ?(s+SKnF~?PFZa&@S zwdPE)R#NF&e*e~Wu8r2anwU)+HMdjcrG-@KM7dgi^Zfhos;+hB==VdJl} z5Bv9Bn&;X$+q#oEvrK8T!GMgAXdy1vny{&xze>U^w{-ih>0%BZU);9A5-$Sw)V}s$ z4>LHjKt4ifXua}ufB#cYPI7Hr$?9pYJ#jtQ?8=jxgo$6rKjRNid(q8D@1lLoPb*#s zq7QOhtR-QmtUmgnpIN!$F`HnCgpsPj$z%K()%&?NViBw*VaLV~8=ospgt8wuCPo3qlHH4wk5~k2N!YQmbakrv%8E|fZW|KO zLcID8a*^6|t~FZ)hPHb&OSC|+mcAd1k4y{nc_P*7TXL!&Yut=wIpL7{$}v{U&%c!u z#uu8gIy;q^6L#|9dJQZ!l%!!T`p}-7FgyiVe->8ZcgH(k1N`m~?&{T&)vHLDz)H63 z?wqjWHR$|8`#|T}sn&O2owrxWLa>&esw?-D3Y6`cVimf2$XekQ{$)X6{+MDltom&d$HiLMUjfc`5NMrh?OS|0#1iarCoahevQZDq9i6LXf2uqzOt2O_EEC-C z%o+x~QN8*%&92qP@kWURekOb!7#=8&$uQuJ>eV)wUFz2YA(k+K(MP`GtM2rv@#=!v zD*H?aXS|ZKVl9ka(J%MHtCo@2D!b;jN9|EfB=D-qw|B+1Gy1@D_iCG9cKTnA=MFX? zVM0;}zn|kBJldyecB6?C>@i#j)^`?f{48NY&j#=ItCzM~5B~0k~z>HIDWhcAs za=9}hVW&sAvs%8^E?USr=EPsO&9zJ2Fk%s`C1J-##dpV=FK_4&XleO=E&twr!PsgwW8ER5|Uc%yZ+- zIos<-A~P%cS;7R?5*ZtG8Sm z8Erb5^~;vFXEp2^MdC#`HhPD8nm?boZs&su2}4U#<ZFPnY#SUGYsv3Usvc51`YY9Lw)5eYbEJgxWEd5dpW(GWx7!3uBn$#R zhxUK3cgrdk!CDe_Qe`NANuf6z*fv-qVc3B0WZS-wYa)w*j&1RkYa6P zkuU*&X~)<1xv6sfyVlnOtJ&UKAy^BZ+X?*-xHcv#|Lzla_qS8!rG-@K#A@ZI-kmtq zCRie25b#;AO*!PIDi*<75_ZaJmh!hBd}fqwgC!D%4fx(~8h*sJ(O~<|K%K2A*3cTK z6M6T;`yJUcfL1qBoYM^321~pMCsiL8EfoCq@PvGX(9ptpLffH7-BeZm>4xC-?y#VU%5*YW;k=QTBZUiv?K1#9KoZt#&NV>UGe2sn!dF%Fw$6up}j@1F`3v>c6N`we0U6 z*{{va46%fXw^XX4`=~oErdrkgGqL|Lq&~2>lrU^;P<11_Np*W;L8{g8K*Q|Me<+>J z5+>N5VH8(2xcX$H?2pG3(+Jj*x^ecLsKH-V4c4E$Fw7DrxI_%2d8fy+`>aewYd2=! z@O4&ZBgZo#J8a}b!p?4c$K6%4uNFzQhHPGv&61@>ZprHKvGPWwY)s~u)P0|GU9wv$ zzt8E68rdviV(+OMS$&nKXx?X;$_H?4>+z<`voc>$#K0Vvb%>oRmPi;jI`-HUdbRBVcLflO zU@ZwdHa;nv6FJoIY1;-%Bn%rf_wNp+%#wBDf;)2}M!l!4u@9DR&Jre$pWhuiH(l0= zYtQ`^Id`SE)qUTUO-!&>qxnCCUiw>~0Q|B8I{S8qF2URp?%P7MCEgmrvxXPaP& zgh4!hws5${(a+qnibb%Ngq^ZFG5Jnwz`_S@8!VA9Y!p*Z+Wa&6BiBYOg0&><*tj*P zfz|oA&$hu53ByLoyY37(9xD5&g6ErB8RZhKTfTZ*lQ6OI?fT)PD>L0xby$;N6?h`v zw!v|+mV}*D9sAj|N)4`S6D*N1Qnl-c_gZPiYg>1nS{D*6#Kl@!58M+r z%(ZTvZz$8!8h^2xZNp0oHgux(r|qq>=gQjzOC$_pTHM3oxK(evIgdrKmV}+0uPfcv z`gC&<+XhP{3>zPI?h_tw&2w$UB3Mhpj*YhO^|szDaT$IFgC!D%jf|25!~6Hnbjzyd zrry?)6PF@G2mF7Oy$QTc)&Kv$%`#7g63Uc$x|hotHX?*lkz`7SQb{E9b+1_@Q$mD_ zBAI2lcb~KMp?NNnNNCWYSwe>YdhNB}=e+hg`}lu%Hc+_BzA*A%i_k z%t;?&Em{Axo2%lb2HNwV`7zr4T#?;OuxeD}5!SS?rnxZ=RUBwnee_2sSAHtw%1hKJ z_mDlb^T!Us9aVm&2EfT z1gpf>F>)q7VSjb?d?yBb#72y&56`hW+)>Gmk&0lI*gD4NW5?Km$3{6Z*dsP#49%Hq z?b~#f+a6zJjj=1292K3@u~G~6F!A5JbFB__u5xpA(HLj<@76oIZA6jXOt7lW%k!-s z6aR2egxeMzXOBMJ%gL3W3c2zU2O3VWzc1IwA=o1}h_Bvx##;IETK6nG6~QX8bzL2u zHqmak>9F%W$R4o~L-z2n4I>r7DzSBpl{2#JK{dvBo;l$W71H%SN8PQSyiN=l5*fY! z`dm?gV3qf&s9wK3)<5b~lNjgNdEqndNLOs^!mvB5u)opmtin#V z?F$Ej*!vpB2m5D6Iz5wFZkUiKfx1Dq#h<&s-SI-@KE|TZwEc4BN2Rt4oup>7n zs2I+BUb~mCj?Ac=WtO^A@AM^CtirB;!h;8bcup{k<6U=0YWzLX95kpv4)&kj?pHZE zSS^5^Y@-3{+XL#ELvp3tyl&C;k;B(!nFGgOo69OZu~Z$Y2=!d!C0=a4DspeLEOXp# z`l$%HLRw5n4)wG1(JvN6BHgmgd^1Wr&uT)j3QuHTE;ta>&uS+!9+R7cTGst6-5Iqng+6k= zvtby$aKh5T40T=dyz_jHZ&Y+wPrT#QDaExfb#L*{t)DyN5rl5RGv~AH2Woe6zCFMv z1!CjOVdJ#L*11I`a{hT^wW_O-m1Xz;wLxfg-z-a1NQ+e%&G8t^dS=))%?6KYnk&=&*Vb!H#QFB1>ns_4&uZqfbxWtFSSOSA0jjX|N7 zTl;zlR*f|mTZP(`YdOC6wQ58apPXf{crP>5YTNJ}%xcntk`v5CULtpSmi_GW_k~8R zS>S1B$<<-^#4FzBFTSXrn_svmly&uwp5DMjp{}YNfp=SsZ+E-O)yP-X)BMMsLf@AP zIp=Va7OR#HT5P3FIGoYx(*cU8@o$!$XWev9FTuH?v_H!6)wsph-?N|Gom(Bx&ePS| zQt?9#LQkG55@HV%TnmOVLe=h~FYASlce%zxuu9ropC?XH<=FanAoRzWok8|6;Vs9t zGqdb}UmhL8`oZJ4^<3{`37^w(WiLGO}I~gYid&EWz>~Q**KIwku&fUp`i|3SZ_CDfa0y~@` z1%GgJ^_kw0g@>%E>g=zCU={XSX>|%ZbG~7?J2EF%ek$b3OSt<)hhUG`Ah63UJhZ;E zGf>}(*L%?mPcy4t9A$VcDU#cf=*}NIG1wzE zVqn*QcFq%Sj8p`x#MUv~Cl)6Ld&EWzJjry9OmW-esCr^4`sk10&WnpX&p6^?0#7of zmP~PTb(ebbnpo^yxY?Yao#!YaScPY*)9YusG2ACFCs%$db~U0NJX$pY#n2&dOqKArm7Qz zJz^sUp4<0i%ct)1sWX73MQfR@zTKQFDx}3KoDFnau+gpEFV#6i#i6yG80_&Q5YI3s zs+0Y)_3fv>3pg=2 zEmn!G>*|m?DH^`~7KdPu*vJ)5jh4J`yD?G`tP)$tcv78sefmjvCkA`OMhu*O72flf z8zU9LDzSBp2i3`4o(uh)80--nF>s=Hdc1st$33|-(~cUu*uw-)^ahTRZ}8k&=2$eR z!C>?B(&El}qNK$toGs?h_`vNYU#kLfMqoJSpkJz|5vsp_hS z&iXfbz-nbY{bCnaJPP+yY127j%|^f)i(~WpSH3kKg;mbi|nsKt3BMR zNMzg(`a5y#@gsDuN~_cJ>-OmH#4#Z@R5uM(^t|;e|EpgwWypFu6Z<@LUdzio-;zBz>ECv&-f*p@ha#e|FtJY0T^=ISG z^MN1KweZUgtYTXI_KqT&E26z3*mGymgMs^Y-sqHrQ&Qir?X)U7bNY>DhtJbIF_@^+ z^Jt*k&s81TFw$T8B)Y3gC9`JU1|EV{xZl0^FE={5RX5A^_&Rz=%Tnf}p){L4Ok5lO zC2;KD>)aT5|1ED1EBAi5jVcEdtinz6PrY!p8>5j@Zdq9(++F>9_qdCJPfizb(!%YI zuu*TtDfjvma?x>y>X`76UD;>+T-%S zbKDpm%Do@`^GtcWw<-sx#VU-bqtEwtV>DA^5T(W+#^A)!qn)&1H>lCoQDJml9+U0H zXszn0x>7%&{@BBWH^xjgY9ChhHAK}F6Rg7MDqnW08{>RMp6^3f*p@BB$Q_Uy+0C}pjz-i)%=$9*N6To?%3#+ zx0cRl{d;VkTk;1LaX=B7ieL{DRpu45+P}BjZNa}z*E081n;o5$K(OlWpD*Xedm@N8 z6_Kq7_As${Okt}>;rVWiThx4LsGc|_fne3J0%vlmCo1BPyT6XkPy~CJNI#Rul0I!1 z=kNYHY^okVJAq);#Hv5##(T*DMGR2HSVgdhiL(Fvl{>M*V7DBHtIZDod%BiAI)Px- zs7hbuZhU>b+aCKB@t-1|Py~CJsNVFO+`c*8-575ih=hhJbt1tk?lFcjOA*5rfj{;n z67hcZvs%m1A1|oh&OIZskIC$67>R^EOGew+JgtWPMm_Tdp z8Cu(oQKrY&;oDUIO(a-_J{{iyWGLcRchA5cCeSP6yNqs1U0zYBD<)Wlu^C^Tdn=-( zBG9+k!vsbb?lHlLV8zj{a!UnoN6MCnURc>5K4`WI8CMt{9!w2AwjBFQ>$8^ zuN~%`hNdP&g_Oib1ZRhg^}~rF9!?kbeN*aL1702A#&B1hBtle(A-0a8%3!OqgxSOC z!VaEnWR>l3mm4D$Au6OKw)$mNb*IHKb*IHu>Qn0rs&{niYWCrWog1^I-n=FMOA+^~ z`)dxVe-jC*1-#z-kxJG}^Bz>W`a>yyRrjT+KZg(xr_1HLG9mSY7>ypTVSPNmpNfH- zpng)n_!d=nfUt+tg&i+Be%mmgy2)dPx&bAT5EWjpu;V7Tc0YG#(Xs{7Jtc<MvnXj{+GiokD9CK95;>mAw^4C*#lw{n?3 zsvLc<{$&rR3p-x&y-K-PwM8DcCrVnZLP}E8vGHNb_>lU+m|$DVbY((Rh$prt4yaZv zt=gPDh#@N2QVUlmM1_>Z*2F(*jQ`}$SK{GxVaMwVOe#WDTyKV9RV@{HwaRF_)3B-0 zLc97~AK$$$_glMh5H)HqdnNbg`gOHEWMl5ZZTs#L)dOwOu&}cp*M_sr#xcU{e5rL$q5mn;@s+TpbRJr zi1LHK${n_#t&^*Rtv<{>x&3A*SCU&D1L-49VmX9B3@?E)pe%`msJKjqF<8}Z^ghinX9AEf{Q^&qCWm zRjcczSPM#Sb*@y&O)0r}n1C&>Ya;sF-tcg@cE!Ug*it6LxTN}3q>5UZAD!y7dB&20 z7J464+j9z7(kmsmI)-XJQ+kPbn1C&>YeJR5l%6OaR>789P`_@ha`iuztA&qFbyfoq z>(zIzB*sh6Rd@Q8RhSTi=q`1%OM0} zBoSdhLR286OtBWkEb7F-tS0>mv#7Kn<`v{t=gOUZlFA_jVt5I6hD#zu1wzWCV_+Up zGmvLBfXcHPxGRH)aC(WTn1GFSMfVcSiI@*DFQRst5S8<~TO&FK<`LhGAZ>GJ>2U#F4tE{IEb7E~ck43&=~qA1pB0c66k3$BmC?2FM1Z;UdM#RRTcvuBn%9K0??){gP&`Oi2-xzvCU^{rhgGnpOnT42y@gOHcz?RoF;jRWrB^MQ!DS0o7Q7d(YF(J8<-0B#-7ZnfE6#}-r9wTCV zQSq<}wv;K>g0a0QW;p3rm_MWiCAYDX$M&M)LApY~me)1m&Tvj$iHB9NrA(TTJsdnT zzryB~L2M5ZMNbqD6OxkN!zJ!_nLs=lQBos12Jd&p!|B47*EQj;I8MpM!z$QPZ|Ym7 z>a3)=dfG0mp0+1+8W(%2=I2a){xpo!idd+K{pvYAk&q`=ye`k}n%J*S4GeW^z#dK) zwme6xGh`J5LbXV#c|%X z#JLaBl2aVm^0W;Cuj35JORy)Ah@WM=r*fsv;B0jk=j2LGoj}NG6l^*DQooU+2z7I> z-AlCzVlW{pydFOzQ|E9vgLBT|*u&|eb8tf4p1a4dPw4g05C@SQN zRk#CI`k2#8Y`2#fek#Q95~?TKZcjAWBQ^-!Oe=j_{qFhAqwSNErbd5JySE7eaSlE+)~x;Y04D}}{0PKTcTQ>|iWP?mu_agWnc+^At0HP095t?i zg}eI1gS+}4^f@tI+wS_g-^>!2Tj7)-0%($qR=y6Yf�G&|7~`|(M%97~)Hr-V zjX_bN1zClg9^-TS+iD%`yf{+6m16cuvCD%{U1eaz`4w%bb#KNVtl30?=;Bep}RduF9i8;0ub zHhTMZwc>~hxx(GDqQWh;@!oz!t%F~<>!6xezgvcRO{duY8@06(qa`xf&V(_r$US*BDM~) zM{E!n&7NF^C0C+Cu2_W`BVJdk51$S8w6Gf>7h<<$BGliiX$rIidEhf=croMUOS@K zLH76&h^M}HrwL~rWI}Aom5h1yIUMyFnf2=ZbL@9<%PBrPhFpn_8&7dFY5tp9xce(6 z*yBg&`%>kTJBY!A*ihkS(qgZ)k{J5arB0i($B)o4R2iZwXGw(6P~kq%k%Mk_V=%!U zKSIY?sOssA8-oe4p~B6i@p3T19zR0ID5}m$4!fUxVM1)EaHD6uJ(ysRAE9HMRAneI zRkt}4Vnc=G*mK6=(=*}5{F=q zAE9I5b3W(Y`3kpDqL0EOHqylnk{BP!1bh4lovX`g{GD{iAQNIkg&RFF&UFkX*yBg& z7;97+PP=mn6JkS!`#>>Y=@?9~$B&4`h)N75#D)qttj5csV#Emc_z^mWD!DCn#e~>U z;ilMldoaNsKSIY)GlMPj6%%4Zg&S|BFF5m6j9`x+;fdk&L?*DIaG`o!5%+C$8bj!N}kPx*id0~Nv*59UiGMT2=@39I)*!!parv;5F09) zC3Iae!5%+C$LOlg8Lm@LUKdB}^Y)n;Q-auq;k;zvHB*C7;gq6V>G6)Oev$O<`^_GK z_<4IGkw_<>w@bPb0|ZWYO1(3~iES9_q}JT3o^yJb~=AD@7=Bj`?XE;vh z?UI(L5JPNT@`Y-rzlzS=B`r~bz)9GJ+h@7C>N8;~o+j)rf9dmfAy|dewo0pKxw%R_ zZa8l%tKgIt#kFQB6_<| zMeGq4`|3V7SLeHaA2~+n?UI(L5JPO8D@BBa zV2{`!Fy`NY%)y>sf_FxpT=}UG!%L{1Xi86HkJt{OPST}^8b&9z;WyAJkL>!7HR7OQaD z=D!a5sSqQHh^>R{5gPXj|ilaXJ zVNR#>b{U<11mYQndY8=nOpn@ZPD^abm5k=(^Y-k$6`Xw(PQPH|ITNQ}rKXo~_g9JM z?c(tx^iyi$dAksjE>t+hD_p3M#7H=A7mpvIWAN!YrzJL2IE`G@A-@}g3HJCAItHJf zGa)uqI8~M0swXcxp=XaDp=0prITK<-h0|fFL-l-4C-m&`BXkTtJ!e8}sBp?HeIfb0 zT|9n-Cx&yD!D)#t<@m#>=C()TdAoT02pxk@&yf=$#D)sBa5k%s8-oe<_z^k=pPn-z zHdJWMf#LdY3?|s)N9Y)Qdd`H{P@z}G%fSSD{0JQ*@w{Eq5*jLuE~&%h^LFw05pIlx z^L8O5U8rQ1z|0q$eVj8(_V^JxM&fz9%uzV~l3t0^FN}|5f<1nO&Xs#|=d1=yhz%7^ z@i5MH3?|s)N9Y)Qdd`H{P~kKZ^OcUl1bh65Sd7@|ITK<-g;UjdITFv?#p6fl7^>u+ z^L8O5U8rz69B&UM*yBg&7>VcYl9td=;gnnYLh^aLc>D-Y45ue@T4GB%(5H2KB%Zg6 z$B)o4+>+ZklR-{|5F0Ag0>);nUwO{k#p6fl7<_upX^9OLS`+h?j==FuJ4;lh515<45Qie0t7li4B#^61uLK zV2>Z6W6V|i&}yGf3!cB6W;VTNZV)%U!NWHYKhbb*@Y?t5wa~LFNlzQ@eq8{q?Pj142}nNOBE5PI|WfXN;vaEtHK z?XS5ps&{B?+TWE4-*PFyEoISWNcVFAP9Tlf!7`f+an8Ti)7p|N)-4lZe+?Tuk#zAh3X5DTu3*ECMTyaTr z55cOQ6M6?9nKsTX$4$#`G~X$`D!lfCG?P6{;BHxK)+9H^r0cIU|IVKiK3p%|L$K=m zUTuTL`p=iT`lo{V>5R?cVD}a#dzirOx0TZ8x-q7FSl-On;mzV?y}u~c9Emv zlnmocjdEt&Yd#4NSGCI?CUBSSJG+*-F#>H%n9XiK7%r&liV0TjZeK50?A90Da@;t$ zggLL>vGBro(@gd-fxB$?{UE-=KHhGggaGLzh6RWv8vO*<%3n5zUIcLG&H|C zqVMT&qp#9U_Ar5)X@hTUcFQqn;Q8>|fqZ6-SJONMt7_d=HrVQsjZ%&=|Aj-X@|yRm z+GQ1Pru|^Zb|+mg(W>1a;olxFWM*ARGugugZl;~uYqy)L5g#23Pyg&H^M+~}9)eXZ z-}^0a?8Dt|u5K;+b@=ClCCztMrJ1b4ZM4;o?Qzoe5(i)36W)BLg1NYLy2&0UaBJ)z z4c~Tim7Z^RxaztJrk&l~L$K=Z?mGkX(*Eb>YT5Yp;jtsCn0ZxQu?n}wzLTYYJJ?HX zU$iWIF0-0B^S?BcJxt)%*wc4?;O1)hs3*f+id8rFRnG7btUA(jOkn@ucidd{>M$uh zb7gJw^(pGygwn$OrPUwSza8u)IxlGvp1!7m*`<5B$sQ(f6KZxJ`BmVppVkUbKUL2> zSv12#uAf^l4M` zP@SWZO;@LR2v%X$w(}lxW3rL>cefOk-I5Tj^44yz z!`99~ruT?VJe!4wc zzh=6JV3pivuVbuvp@P}3$j<1!1ysLMEB(tIo^wjb1lHhZM=p2UW3t)-RPOL*bhWz! z5Q0^Fv%NZ*P&%}w?&fmM4^pV!=6)lxfv z$J!l>E>$}KCRl})`nL01+!p-uOn&pD-v2~5RY^D5!vxmg_?`DfGE} zoyEfh*5G%ucDuRitJd?YTjjOych_?vSjD%_tKZpH>-mq57qVxnS(H6Y@J;iE@qk*- zvp>7auC3N{_Ar4pc*ya6Zmt@r_59MolJ*Wp6Rvz#80V+~;nt9#iZ2xRteS+g;CbQ$0ow ztK@ESJ)#P#^*nn`1N$Dep0kGux!GJ3?s{%xJ!cOSSQ8KRIOyhT{^h*kVI`Z`$JNTo z1grSAcJ+?Q|8hc0W;L}3tA52ECgc`)ovXT=dWE*$-q`Nw?f_0UKjxI23G4=HuK3c; z)tVY*LK9A=*@M+CgVSOa>@FYclnkThe`)r%=F6=gR5`xrG1s}f9jh1LqGVT`Yh6F) zM|X98Kf9T|V*NSmse9G4GkKW6-9>UEykXQS*4UnZ%aq8C^)oyKtA1Jhlx5%Xvb#Fp z_-RwSZQ8QPv-Q(W_Ar6Fi{dvHZf?=U9{Wt8XxfT255cNEAJ4E>k0{{Q)vtpa*ssj` zJ974?43j-f;3gv6yr|pbSltHp+{0a>Z$F%7;-((Czw(Luvn{J)z)2Uk1jX;1JhZW{ zon0jyz4@!=CVQCR*y=8!+=lj_c^X7-QDcw^R`IQm>i63}Ze;J9eO+{;BG|)(w;UG- zRkd3bdpi2~<1J0xyMy~gQNnfqyVpAW(JZGNxFsll2k481YT5<=c|Lmh#wI3vm}vCv zqn2^o!*1=~Q0pdp!2TzqfeXz%1gp3X4deT%_3W$L&WL{bLL*P@GV$=sW35w%Zg*q6 zeY%eQ!*dTs3wKHP5Uk>wHjF*D-C$?+T^${$>WWLwcc*fh)Q#y)E89KuZjNqHD<^xH zz%4=XyJhbjSJ56jX-D+rh~^%GRYTuwXN`VB*7FnBTx);5Wl!{Emo$@AxEV<9#q|>V zo-S>#8oxhUf4|x%Qd&&lHXq#FtNZTs0;TNCOZ%fOe`w|*SoQ184Xj@G%6k6&-eUHf ztG|u*%9n1khY7v|*f73+E5AKB>rAxCu{00Cs&zkJZRHs&tMiC!^4mi*eu}Q#*}`NM z?)j0skiCR;{!(=Pu)OxOFE%yV!vt>diQn(MZ|8T>n?qOGzw}Y98}pJqGoDVeMk3rdx=F;S40=Qd!yY-t+Inr^a(3EUWio8NU^)$Cd)TJytt_OARH9)eZ-niR`ja*wRe+xt|Aocd1PRPddC z+LrZ>*FoHsv%8_JgN3f|7pXq5u|2w2hRGf#yfJQBYDcaf)zqG>)^jFU#rM>!@9Lk( zAI&+}*bb@|WDgTuCiNuzTbg~-w10zj|4cJ^edU#!?+H|QzGP(B59M1KY`&$j$sQ)K zY8StB#66*Z@BW5%qyCFSwWqc45Uk>R0uAHEVrlkW+0#O=HcvO%!vt3CHibsIr{`bK zZ(_gJ`&=lpFU>=+if{chjK^9vwENvrAiQH)bCW$xV1=u__^2CWq*@2xnSEXORW-LW z!79E1P~GUM=JvjMH-}$Qb35Pk#&^bH&R;t!E2y97-%;zw6Ma{Q`>Gi1VS;b6GmQ1$ zRkeRCy)@kUbG;f!TCBnfcdBN0H^y~#O?%|}h2f>IHZ|G91mEFj7==sUWdGZLY%g_AtSB5vm)Y9%^XsYuzS%G{1Tuhtgsd*R(oy zu36hYajJcI_`ncTTv;?xrStn81qqN9=_Dq*`lNjNczFt=3v5SS2^->G^85T5G3W z+8<6+Yb|@2z>2#67CAlNuh!ZoYrYM)S8FX3tm6Ci)UT0Ph3xa|eh+7#OLJCExzUda zzAaCE|9C}Sdr#(H;aYDuH`&7k*2gh}`7-Un)}o%imI zW;37MwyT5CU>(7^ot_H>gyOvvqQnz%}>waq@PXTGY|TJ|u3^{P}mIobYLt!l5IYitHo zT`|EbzH!blIxn#!6-PBSv2wD93AxEm=W6qH{UWsoHZ~u3S7*6toC&PdaxSjsU9|%^ zrdEIaaayc`E%&?QQw;bo+xeVK=lcpdH^^buKkwcq$F{!D>;I2{>eTMi&P{fD{a}JU z5(5NojFJ0hv|e(>1gpf>G5WpLKDx7R0P)sNbwbiP$4H()HfR_WR{DTm6}5iww8e!-U-J zrgNpzw^bY`1{16jTjxrZ!B%AnBUkJZ+sRdgZ?7w_KILtxZ#$h;b;WnlF(Eg=>0H_6 z#@p7L?VT7*uu5#5E7jgMa)kfbBR0~7Eq8E1=Ox6GNX+f>rF+71B|=mZLzm-!svHyE zZ0~%gNIb}|*iu(=%ba1jbrt<~rmidTFoAl*eO@}oVAUSSRo_C1{O+HFN(fz7Zks#x z#U8PpTzPJmbNiJo{Yq~7Vgh{(w{z)Sxqa7(!FRQ>N^G4gcYHVmd&EY%e1BYQ4B9dV z<(4`oFdlJ-o6eOx&Yc*X7OTXTeiiE}*_cZtEzBQ@_WzB53Uj5y)m0T5(7l) zTrt5av31G2t6#-`c5Q*^kc(T=aU+v`ako<^d;=KYIA<8g%+cn0HOrMRINV}_RdRov zjxkW(JMiCcd(jiK{ZuH2m#D91xBY6C1Hm4#LGb-?hJo1)y|H7_;g+ZnomFxhoX*va z>k5SLP`{3k7+j-bL&bNysc$=FPYoAqHQH{kVz7q^Z;Wex-y80$e(N4Fm|&IID3iLM zO3k@-)!Yk$Jz^)8N$K2m78crLm%1qwD%f(17HpY`k_qu75;F5$nGh9d={<(w)~*Q_ z^@CLyxe^oENg1GcZuN^dq4}} zjp2?cXB}jMRbuOY<&G$)v3ll9Cd3036LRy-l?hRSMvqDA?N05A2P!7y z{-5L+Oo&Qi$o+${eie%$Bb5oc?Z`id5TZg1>0`-+j2x&EN0fibd2BO*v5EU%{A0*$ zAS%SW!pz{#8BpPN1l-Ny5N@ANj={4J6S&17#q2}lLsW<%JwG{yc%Whe_XQ-CJe$*k zEh^`Ax*5pZ=5o(ZA|dx4cygs<(EW3wlGsV}mD8_K4tStqLhe2A#Bd1lfRKA3{_ioE zkU2j&2Iopth=Ezce@+w+R7|9v6FDbx>ju-wx#Dy^gnNg|l?l0H1To?_J0#A#;&F1~ zrVCqEyW|*5hzdmfW{1QW;^A~*%lKC@)VCVtcf7FD%WV#F6jy_b4k1` z*s(|qNf&dMydER6r7Y}WLTpX&x1DhFf#gJJ#E{oh6Ywx0w&W^)mVvZnZij8PEoU_> zP|S%TE0I%b?8cn`pb}3aG4FU@>*8$})Gl0=Iwc`0(6XW?6XJo235<_+L;muK!Gx$l z%Q~MN10|OlmA#gf>8|d#>eawg4%81kP%$C9sN@(-h)T+oujxHLF;H4~pkhMyUCA++ z5EW?D!nZa0_{0zoR7}W>ksN~wQ8_WL+LY@lM>be^WL1MLZ2-H;Pi6ppEIEdFp!zds zasYjaQ%WW{1{0X={Cf!#qCyPW86?LL4^&L#dE$_h!z&Y_0)0o|g16+^VBz6j^2g8D zTicMzHLpyF3bc%We*$v`R7@b=OC_rMw4kIVxq=F|%qz(; z%DepsDzv|hF1yWBx&C8NJc-2pT`LAqZ^<#35EbYZr-tYH6Fh$)htdk7FBC$OGRYBaAcx6IVppg=0 zSARn0T_%u2PkUs8<+LPMGCpKykQ_rii3DoI)30JNm=G0c*{>wW5D!#LV9fi^iA;zJ zw9KfsaxOVwr=%l^&^e#LiLYD$CWK( zvj?_3i*QQn{h=A@_N7|0qDS`C^u%BScKp`q(Vf&MgfePGTb@lzB1GlHi0w-r4yD<3 z4n`uw)xYfFbYWvY)~9NJZf|0LRI6I}jQTf`5EWjRJ&|F&{9j%B{`_x+J~V<($wh^9 z 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

IqeWL}Ocfa?xXpH2*PQExoX9 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�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 z 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�Gj!?6<(TnOV9!d{(aD048a2nYY8fi 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f9e29d1e260585fd554bb2eca22a6f5f94bd74ba GIT binary patch literal 1033884 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

I$7?s1PN?!bRJ~t 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%0AwZ1>na9%?DmwZ;$-%TeNxiM$Wn^!P(nd1y2 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

c* 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 IL=1-i594QhXaE2J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d3de7a381d18674388e0ae3ea1a236fcebd8db07 GIT binary patch literal 599484 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